@@ -2,6 +2,7 @@
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/build-qa-opportunities
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -56,11 +57,26 @@ class ImportDocumentBatch extends Command
|
||||
|
||||
$apply = (bool) $this->option('apply');
|
||||
$actorId = $this->option('actor');
|
||||
if ($apply) {
|
||||
// CLI attribution is not an authenticated active installation. Rehearsals stay isolated.
|
||||
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if (! app()->environment('testing') || ! str_ends_with($target->database, '_test') || (int) $target->port !== 55441
|
||||
|| config('database.connections.pgsql.host') !== '127.0.0.1' || config('database.connections.pgsql.url')) {
|
||||
$this->error('CLI apply is restricted to isolated QA. Active imports require the authenticated activation mechanism; --actor is attribution only.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
if ($apply && (! is_numeric($actorId) || ! DB::table('users')->where('id', (int) $actorId)->exists())) {
|
||||
$this->error('Apply requires --actor=<existing-user-id>; no author identity will be fabricated.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($apply && ! User::findOrFail((int) $actorId)->can('manage-portal')) {
|
||||
$this->error('QA import requires an authorized library manager; no permission is granted by naming an account.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$disk = Storage::disk('local');
|
||||
$seenHashes = [];
|
||||
$seenExternalIds = [];
|
||||
|
||||
@@ -6,7 +6,9 @@ use App\Models\InterestRequest;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -29,12 +31,17 @@ class DashboardController extends Controller
|
||||
|
||||
$counts = InterestRequest::query()->selectRaw('status, count(*) as total')
|
||||
->groupBy('status')->pluck('total', 'status');
|
||||
$drafts = Opportunity::where('status', 'draft');
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
|
||||
}
|
||||
|
||||
return Inertia::render('dashboard', ['summary' => [
|
||||
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
|
||||
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
|
||||
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
|
||||
'publishedOpportunities' => Opportunity::published()->count(),
|
||||
'draftOpportunities' => Opportunity::where('status', 'draft')->count(),
|
||||
'draftOpportunities' => $drafts->count(),
|
||||
'activeSectors' => Sector::where('is_active', true)->count(),
|
||||
'recentRequests' => InterestRequest::orderByDesc('id')->limit(5)
|
||||
->get(['id', 'reference', 'sector_interest', 'status', 'created_at']),
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\TerritoryGeometry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class InvestmentTerritoryController extends Controller
|
||||
@@ -14,9 +17,11 @@ class InvestmentTerritoryController extends Controller
|
||||
public function geometries(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['territory_id' => ['required', 'uuid']]);
|
||||
$management = $request->is('management/*');
|
||||
$profile = $request->is('minhas-oportunidades/*');
|
||||
$management = $request->is('management/*') || $profile;
|
||||
if ($management) {
|
||||
abort_unless($request->user()?->can('manage-portal'), 403);
|
||||
$access = app(PortalProfileAccess::class);
|
||||
abort_unless($profile ? ($access->grants($request->user()) !== [] || $access->superuser($request->user())) : $request->user()?->can('manage-portal'), 403);
|
||||
}
|
||||
|
||||
return response()->json(app(TerritoryGeometry::class)->context($data['territory_id'], $management));
|
||||
@@ -29,6 +34,9 @@ class InvestmentTerritoryController extends Controller
|
||||
$data = $request->validate(['kind' => ['required', 'in:'.implode(',', array_keys(self::TABLES))], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$column = $data['kind'] === 'opportunities' ? 'title' : 'name';
|
||||
$query = DB::table(self::TABLES[$data['kind']])->whereNull('deleted_at');
|
||||
if ($data['kind'] === 'opportunities' && Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query->whereIn('id', app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->select('id'));
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$query->whereRaw($column.' ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
|
||||
}
|
||||
@@ -38,6 +46,10 @@ class InvestmentTerritoryController extends Controller
|
||||
|
||||
public function options(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->is('minhas-oportunidades/*')) {
|
||||
$access = app(PortalProfileAccess::class);
|
||||
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
|
||||
}
|
||||
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type'])
|
||||
->whereIn('data_classification', ['public', 'internal']);
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\LandAssistanceCase;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -257,7 +258,9 @@ class LandAssistanceController extends Controller
|
||||
'media_type' => (string) $file->getMimeType(),
|
||||
'storage_key' => $stored,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)),
|
||||
// No antivirus engine is configured in this project; fail closed until an operator scans it.
|
||||
'size_bytes' => $file->getSize(),
|
||||
'uploaded_by' => $request->user()->id,
|
||||
// The configured scanner releases only the exact original hash after a clean result.
|
||||
'status' => 'quarantined',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
@@ -282,22 +285,20 @@ class LandAssistanceController extends Controller
|
||||
: back()->with('status', 'Documento privado anexado; a revisão ainda está pendente.');
|
||||
}
|
||||
|
||||
public function download(Request $request, string $id, string $attachment): BinaryFileResponse
|
||||
public function download(Request $request, string $id, string $attachment): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$this->owned($request, $id);
|
||||
$case = $this->owned($request, $id);
|
||||
$document = DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->where('link.id', $attachment)->where('document.status', 'active')->whereNull('document.deleted_at')
|
||||
->where('document.malware_scan_status', 'clean')->whereColumn('document.malware_scanned_sha256', 'document.sha256')
|
||||
->first(['document.file_name', 'document.storage_key', 'document.sha256', 'document.malware_scanned_sha256']);
|
||||
->first(['document.*']);
|
||||
abort_if($document === null, 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless(is_string($checksum) && hash_equals($document->sha256, $checksum)
|
||||
&& hash_equals($document->sha256, (string) $document->malware_scanned_sha256), 404);
|
||||
$response = app(VerifiedDocumentBinary::class)->respond($document);
|
||||
DB::table('audit_events')->insert(['source_system' => 'land-assistance', 'entity_type' => 'documents', 'entity_id' => $document->id,
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'document_downloaded', 'data_classification' => 'restricted',
|
||||
'after_data' => json_encode(['case_id' => $case->id, 'attachment_id' => $attachment], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function print(Request $request, string $id): Response
|
||||
|
||||
@@ -6,14 +6,20 @@ use App\Domain\Opportunities\Enums\OpportunityStatus;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityManagementInstallation;
|
||||
use App\Services\OpportunityMaturity;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\OpportunityResources;
|
||||
use App\Services\OpportunityWorkflowTasks;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -38,16 +44,32 @@ class OpportunityManagementController extends Controller
|
||||
if (! $service->installed()) {
|
||||
return app(PortalManagementController::class)->catalog($request, 'opportunities');
|
||||
}
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
|
||||
$filters = $request->validate(['queue' => 'nullable|in:review,publication,appeal,assigned', 'q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
|
||||
$query = Opportunity::query();
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query = $access->scope($query, $request->user());
|
||||
if (str_starts_with($request->path(), 'minhas-oportunidades')) {
|
||||
$query->whereNotNull('owner_user_id');
|
||||
}
|
||||
}
|
||||
if ($filters['deleted'] ?? false) {
|
||||
$query->onlyTrashed();
|
||||
$query->withoutGlobalScope(SoftDeletingScope::class)->whereNotNull('opportunities.deleted_at');
|
||||
}
|
||||
if ($filters['q'] ?? null) {
|
||||
$query->where(fn ($q) => $q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%'));
|
||||
$query->where(function ($q) use ($filters) {
|
||||
$q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%');
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$q->orWhere(fn ($draft) => $draft->whereNotNull('owner_user_id')->whereRaw("management_draft::jsonb->'canonical'->>'official_title' ILIKE ?", ['%'.$filters['q'].'%']));
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($filters['status'] ?? null) {
|
||||
$query->where('status', $filters['status']);
|
||||
if (Schema::hasColumn('opportunities', 'workflow_context')) {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)=?", [$filters['status']]);
|
||||
} else {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
}
|
||||
if ($filters['category'] ?? null) {
|
||||
$query->where('opportunity_category', $filters['category']);
|
||||
@@ -59,7 +81,32 @@ class OpportunityManagementController extends Controller
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
}
|
||||
|
||||
return Inertia::render('management/opportunities', ['records' => $query->orderByDesc('id')->paginate(20)->withQueryString(), 'filters' => $filters, 'counts' => Opportunity::selectRaw('status,count(*) AS total')->groupBy('status')->pluck('total', 'status'), 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => Opportunity::where('geography_scope', 'unspecified')->count(), 'sectors' => Sector::orderBy('name')->get(['id', 'name'])]);
|
||||
if ($filters['queue'] ?? null) {
|
||||
$queue = $filters['queue'];
|
||||
if ($queue === 'review') {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status) IN ('submitted','in_review','information_pending')");
|
||||
} elseif ($queue === 'publication') {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)='approved'");
|
||||
} elseif ($queue === 'appeal') {
|
||||
$query->whereRaw("workflow_context->'appeal'->>'pending'='true'");
|
||||
} else {
|
||||
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('tasks')->where('tasks.source_system', 'opportunity-workflow')->where('tasks.assignee_user_id', $request->user()->id)->whereIn('tasks.status', ['open', 'in_progress'])->whereRaw("tasks.external_id LIKE 'portal-opportunity:' || opportunities.id::text || ':%'"));
|
||||
}
|
||||
}
|
||||
|
||||
$base = str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities';
|
||||
$stateSql = Schema::hasColumn('opportunities', 'workflow_context') ? "COALESCE(workflow_context->>'working_state',status)" : 'status';
|
||||
$counts = (clone $query)->selectRaw($stateSql.' AS working_status,count(*) AS total')->groupByRaw($stateSql)->pluck('total', 'working_status');
|
||||
$geographyReviewCount = (clone $query)->where('geography_scope', 'unspecified')->count();
|
||||
$records = $query->orderByDesc('id')->paginate(20)->withQueryString();
|
||||
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
|
||||
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r));
|
||||
$r->setAttribute('working_status', $access->state($r));
|
||||
$r->setAttribute('publication', $r->publicationPresentation());
|
||||
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title);
|
||||
});
|
||||
|
||||
return Inertia::render('management/opportunities', ['basePath' => $base, 'canCreate' => $base === '/management/opportunities' ? $access->legacyWriteAllowed($request->user()) : (bool) ($access->navigation($request->user())['contexts'] ?? []), 'records' => $records, 'filters' => $filters, 'counts' => $counts, 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => $geographyReviewCount, 'sectors' => Sector::orderBy('name')->get(['id', 'name'])]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, OpportunityManagement $service, ?int $id = null): Response
|
||||
@@ -68,10 +115,27 @@ class OpportunityManagementController extends Controller
|
||||
return app(PortalManagementController::class)->edit('opportunities', $id);
|
||||
}
|
||||
$record = $id ? Opportunity::withTrashed()->findOrFail($id) : null;
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (! $record && ! str_starts_with($request->path(), 'minhas-oportunidades')) {
|
||||
abort_unless($access->legacyWriteAllowed($request->user()), 403);
|
||||
}
|
||||
if ($record) {
|
||||
$access->authorize($request->user(), 'view', $record);
|
||||
}
|
||||
Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities',
|
||||
'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null,
|
||||
'workflowState' => $record ? $access->state($record) : null,
|
||||
'publicationState' => $record?->publicationPresentation(),
|
||||
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
|
||||
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
|
||||
'workflowTasks' => $record && $record->owner_user_id !== null ? app(OpportunityWorkflowTasks::class)->listing($record) : [],
|
||||
'profileMode' => $record ? $record->owner_user_id !== null : str_starts_with($request->path(), 'minhas-oportunidades'),
|
||||
'profileActions' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->actions($request->user(), $record) : [], 'profileContext' => $record?->workflow_context, 'profileOrganization' => $record?->organization_id]);
|
||||
$values = $record ? $service->values($record) : [];
|
||||
Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? []));
|
||||
$classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null;
|
||||
|
||||
return Inertia::render('management/opportunity-draft', ['resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view'), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'categories' => OpportunityManagement::CATEGORIES,
|
||||
return Inertia::render('management/opportunity-draft', ['resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'categories' => OpportunityManagement::CATEGORIES,
|
||||
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS],
|
||||
'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true),
|
||||
'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]);
|
||||
|
||||
@@ -3,21 +3,22 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityImageProcessor;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityResourceInstallation;
|
||||
use App\Services\OpportunityResources;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class OpportunityResourceController extends Controller
|
||||
{
|
||||
@@ -40,9 +41,12 @@ class OpportunityResourceController extends Controller
|
||||
public function index(int $id, OpportunityResources $service): Response
|
||||
{
|
||||
$opportunity = Opportunity::withTrashed()->findOrFail($id);
|
||||
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
|
||||
Inertia::share(['basePath' => str_starts_with(request()->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities', 'readOnly' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity)]);
|
||||
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
|
||||
$resources = [];
|
||||
Inertia::share('imageProcessingAvailable', app(OpportunityImageProcessor::class)->available());
|
||||
Inertia::share('countries', app(CountryCatalogue::class)->all());
|
||||
foreach (OpportunityResources::KINDS as $kind) {
|
||||
$resources[$kind] = $service->listing($id, $kind);
|
||||
}
|
||||
@@ -54,7 +58,7 @@ class OpportunityResourceController extends Controller
|
||||
$image->derivativePreviews = [];
|
||||
foreach ($image->payload['derivatives'] ?? [] as $derived) {
|
||||
if (DB::table('documents')->where('id', $derived['document_id'])->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists()) {
|
||||
$image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute('management.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])];
|
||||
$image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute(str_starts_with(request()->path(), 'minhas-oportunidades') ? 'promoter.opportunity-image-preview' : 'management.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +91,7 @@ class OpportunityResourceController extends Controller
|
||||
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
|
||||
}
|
||||
|
||||
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response
|
||||
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$o = Opportunity::findOrFail($id);
|
||||
abort_if($o->status === 'archived', 404);
|
||||
@@ -97,26 +101,20 @@ class OpportunityResourceController extends Controller
|
||||
abort_unless(in_array($documentId, array_column($metadata['derivatives'] ?? [], 'document_id'), true), 404);
|
||||
$doc = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
|
||||
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan da derivada pendente.');
|
||||
$bytes = Crypt::decryptString(Storage::disk('local')->get($doc->storage_key));
|
||||
abort_unless(hash_equals(trim($doc->sha256), hash('sha256', $bytes)), 409, 'Integridade divergente.');
|
||||
|
||||
return response($bytes)->header('Content-Type', 'image/webp')->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'private,no-store');
|
||||
return app(VerifiedDocumentBinary::class)->respond($doc, true);
|
||||
}
|
||||
|
||||
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response
|
||||
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
|
||||
$opportunity = Opportunity::findOrFail($id);
|
||||
abort_if($opportunity->status === 'archived', 404);
|
||||
$record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']);
|
||||
abort_unless($record && $record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
|
||||
$disk = Storage::disk('local');
|
||||
$path = $disk->path($record->storage_key);
|
||||
abort_unless($disk->exists($record->storage_key), 409, 'Ficheiro indisponível.');
|
||||
$bytes = $record->storage_encryption === 'laravel' ? Crypt::decryptString($disk->get($record->storage_key)) : $disk->get($record->storage_key);
|
||||
abort_unless(hash_equals(trim($record->sha256), hash('sha256', $bytes)), 409, 'Integridade do ficheiro divergente.');
|
||||
abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.');
|
||||
abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
|
||||
|
||||
return response($bytes)->header('Content-Type', $record->media_type)->header('Content-Disposition', HeaderUtils::makeDisposition('attachment', $record->file_name, 'document'))->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'no-store');
|
||||
return app(VerifiedDocumentBinary::class)->respond($record);
|
||||
}
|
||||
|
||||
/** @param array<string,list<\stdClass>> $resources
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Services\InvestmentRelations;
|
||||
use App\Services\PartnershipExchange;
|
||||
use App\Services\PartnershipMatching;
|
||||
use App\Services\PartnershipSources;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -317,7 +318,7 @@ class PartnershipExchangeController extends Controller
|
||||
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409);
|
||||
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted',
|
||||
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined']);
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id]);
|
||||
DB::table('partnership_documents')->insert(['room_id' => $id, 'document_id' => $documentId, 'owner_id' => $request->user()->id, 'shared' => $data['shared']]);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_added.v1', ['room_id' => $id, 'document_id' => $documentId, 'shared' => $data['shared']], 'Documento privado anexado');
|
||||
});
|
||||
@@ -330,21 +331,17 @@ class PartnershipExchangeController extends Controller
|
||||
return $request->expectsJson() ? response()->json(['uploaded' => true]) : back()->with('success', 'Documento privado guardado; análise antimalware pendente.');
|
||||
}
|
||||
|
||||
public function download(Request $request, int $id, int $attachment): BinaryFileResponse
|
||||
public function download(Request $request, int $id, int $attachment): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id);
|
||||
$document = DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.room_id', $id)->where('link.id', $attachment)
|
||||
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.storage_key', 'd.file_name', 'd.sha256']);
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.*']);
|
||||
abort_unless($document !== null, 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless(is_string($checksum) && hash_equals($document->sha256, $checksum), 404);
|
||||
$response = app(VerifiedDocumentBinary::class)->respond($document);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_accessed.v1', ['room_id' => $id, 'attachment_id' => $attachment], 'Documento consultado');
|
||||
|
||||
return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function share(Request $request, int $id, int $attachment): RedirectResponse|JsonResponse
|
||||
|
||||
@@ -230,6 +230,9 @@ class PortalManagementController extends Controller
|
||||
]);
|
||||
$query = $model::query();
|
||||
if ($resource === 'opportunities') {
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query = app(\App\Services\PortalProfileAccess::class)->scope(Opportunity::query(), $request->user());
|
||||
}
|
||||
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
|
||||
if (isset($filters['geography_scope'])) {
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
@@ -263,7 +266,7 @@ class PortalManagementController extends Controller
|
||||
'filters' => $filters,
|
||||
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
|
||||
'geographyReviewCount' => $resource === 'opportunities'
|
||||
? Opportunity::where('geography_scope', 'unspecified')->count()
|
||||
? (clone $query)->where('geography_scope', 'unspecified')->count()
|
||||
: 0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\PortalSuperusers;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class PortalProfileController extends Controller
|
||||
{
|
||||
public function requests(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
return Inertia::render('profiles/request', ['requests' => DB::table('portal_profile_requests')->where('user_id', $request->user()->id)->latest()->get(),
|
||||
'types' => OpportunityManagement::CATEGORIES,
|
||||
'organizations' => DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
|
||||
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']),
|
||||
'documents' => DB::table('documents')->where('uploaded_by', $request->user()->id)->whereNull('deleted_at')->where('status', 'active')->where('malware_scan_status', 'clean')
|
||||
->whereColumn('sha256', 'malware_scanned_sha256')->get(['id', 'file_name']), 'grants' => $access->grants($request->user(), 'promoter')]);
|
||||
}
|
||||
|
||||
public function requestProfile(Request $request, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
return response()->json(['id' => $service->request($request->user(), $request->all()), 'message' => 'Pedido recebido; perfil ainda não ativado.'], 201);
|
||||
}
|
||||
|
||||
public function requestStatus(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('portal_profile_requests')->where('id', $id)->where('user_id', $request->user()->id)->first();
|
||||
abort_unless($row !== null, 403);
|
||||
|
||||
return response()->json($row);
|
||||
}
|
||||
|
||||
public function administration(Request $request, PortalProfileAdministration $service): Response
|
||||
{
|
||||
$service->administrative($request->user());
|
||||
|
||||
return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(),
|
||||
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
|
||||
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
|
||||
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
|
||||
'securityRequests' => DB::table('portal_security_approvals')->latest()->limit(30)->get(),
|
||||
'securityAlerts' => DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->whereIn('action', ['superuser_pair_initialized', 'superuser_replaced', 'role_revoked'])->latest('occurred_at')->limit(20)->get(['id', 'action', 'actor_user_id', 'occurred_at'])]);
|
||||
}
|
||||
|
||||
public function decide(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
$service->decide($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['message' => 'Decisão registada; somente os tipos aprovados ficam autorizados.']);
|
||||
}
|
||||
|
||||
public function audit(Request $request, PortalProfileAccess $access): Response|JsonResponse
|
||||
{
|
||||
abort_unless($access->has($request->user(), 'auditor') || ($access->superuser($request->user()) && $access->mfa($request->user())), 403);
|
||||
$filters = $request->validate(['actor_id' => 'nullable|integer|min:1', 'action' => 'nullable|string|max:100', 'record_id' => 'nullable|integer|min:1', 'from' => 'nullable|date', 'until' => 'nullable|date|after_or_equal:from', 'format' => 'nullable|in:json']);
|
||||
$query = DB::table('audit_events')->when($filters['actor_id'] ?? null, fn ($q, $v) => $q->where('actor_user_id', $v))
|
||||
->when($filters['action'] ?? null, fn ($q, $v) => $q->where('action', $v))
|
||||
->when($filters['record_id'] ?? null, fn ($q, $v) => $q->where('after_data->record_id', $v))
|
||||
->when($filters['from'] ?? null, fn ($q, $v) => $q->where('occurred_at', '>=', $v))
|
||||
->when($filters['until'] ?? null, fn ($q, $v) => $q->where('occurred_at', '<=', $v));
|
||||
// Minimized metadata: never decrypt private snapshots or export authentication bodies.
|
||||
$query->select(['id', 'source_system', 'action', 'actor_user_id', 'entity_type', 'occurred_at', 'correlation_id']);
|
||||
if (($filters['format'] ?? null) === 'json') {
|
||||
$records = $query->orderByDesc('occurred_at')->limit(1000)->get();
|
||||
$access->audit($request->user(), 'audit_metadata_exported', ['filters' => $filters, 'count' => $records->count()]);
|
||||
|
||||
return response()->json(['filters' => $filters, 'records' => $records, 'limit' => 1000])->header('Content-Disposition', 'attachment; filename="audit-metadata.json"')->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
$records = $query->orderByDesc('occurred_at')->paginate(30)->withQueryString();
|
||||
$access->audit($request->user(), 'audit_metadata_read', ['filters' => $filters, 'count' => $records->count()]);
|
||||
|
||||
return Inertia::render('profiles/audit', ['records' => $records, 'filters' => $filters]);
|
||||
}
|
||||
|
||||
public function grant(Request $request, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
return response()->json(['id' => $service->grant($request->user(), $request->all())], 201);
|
||||
}
|
||||
|
||||
public function requestDocument(Request $request, string $id, string $documentId, PortalProfileAdministration $service): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$service->administrative($request->user());
|
||||
$row = DB::table('portal_profile_requests')->where('id', $id)->first();
|
||||
abort_unless($row && in_array($documentId, json_decode($row->document_ids, true), true), 404);
|
||||
$document = DB::table('documents')->where('id', $documentId)->first();
|
||||
abort_unless($document !== null, 404);
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($document, true);
|
||||
}
|
||||
|
||||
public function revoke(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
$service->revoke($request->user(), $id, (string) $request->input('reason'));
|
||||
|
||||
return response()->json(['message' => 'Papel revogado; sessões e tokens anteriores invalidados.']);
|
||||
}
|
||||
|
||||
public function opportunities(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
|
||||
|
||||
return app(OpportunityManagementController::class)->index($request, app(OpportunityManagement::class));
|
||||
}
|
||||
|
||||
public function edit(Request $request, PortalProfileAccess $access, ?int $id = null): Response
|
||||
{
|
||||
if ($id) {
|
||||
$access->authorize($request->user(), str_ends_with($request->path(), '/view') ? 'view' : 'edit', Opportunity::withTrashed()->findOrFail($id));
|
||||
} else {
|
||||
abort_unless($access->navigation($request->user())['contexts'] ?? [], 403);
|
||||
}
|
||||
|
||||
return app(OpportunityManagementController::class)->edit($request, app(OpportunityManagement::class), $id);
|
||||
}
|
||||
|
||||
public function workflow(Request $request, int $id, OpportunityProfileWorkflow $service): JsonResponse
|
||||
{
|
||||
$record = $service->transition($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['id' => $id, 'status' => app(PortalProfileAccess::class)->state($record), 'publication_status' => $record->status, 'etag' => app(OpportunityManagement::class)->etag($record), 'message' => 'Ação interna registada; não representa decisão externa.']);
|
||||
}
|
||||
|
||||
public function replacement(Request $request, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['old_user_id' => 'required|integer', 'new_user_id' => 'required|integer', 'reason' => 'required|string|min:20|max:1000']);
|
||||
|
||||
return response()->json(['id' => $service->requestReplacement($request->user(), $data['old_user_id'], $data['new_user_id'], $data['reason'])], 201);
|
||||
}
|
||||
|
||||
public function approveSecurity(Request $request, string $id, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$service->approve($request->user(), $id);
|
||||
|
||||
return response()->json(['message' => 'Aprovação independente registada.']);
|
||||
}
|
||||
|
||||
public function replace(Request $request, string $id, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$service->applyReplacement($request->user(), $id);
|
||||
|
||||
return response()->json(['message' => 'Titular substituído atomicamente; continuam dois titulares.']);
|
||||
}
|
||||
|
||||
public function elevation(Request $request, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['action' => 'required|string', 'record_id' => 'nullable|integer|exists:opportunities,id', 'reason' => 'required|string|min:20|max:1000']);
|
||||
|
||||
return response()->json(['id' => $service->requestElevation($request->user(), $data['action'], $data['record_id'] ?? null, $data['reason'])], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\PortalProfileInstallation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PortalProfileInstallationController extends Controller
|
||||
{
|
||||
public function preview(Request $request, PortalProfileInstallation $installation): JsonResponse|Response
|
||||
{
|
||||
$plan = $installation->preview($request->user());
|
||||
|
||||
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/installation', $plan);
|
||||
}
|
||||
|
||||
public function apply(Request $request, PortalProfileInstallation $installation): JsonResponse
|
||||
{
|
||||
return response()->json($installation->apply($request->user(), $request->all()));
|
||||
}
|
||||
|
||||
public function bootstrap(Request $request, PortalProfileInstallation $installation): JsonResponse
|
||||
{
|
||||
return response()->json($installation->bootstrap($request->user(), $request->all()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
class AuditProfileDecision
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (! $request->user() || ! $access->available() || ! preg_match('#^(perfil/|perfis/|minhas-oportunidades)#', $request->path())) {
|
||||
return $next($request);
|
||||
}
|
||||
try {
|
||||
$response = $next($request);
|
||||
} catch (\Throwable $error) {
|
||||
$status = $error instanceof HttpExceptionInterface ? $error->getStatusCode() : ($error instanceof ValidationException ? 422 : 500);
|
||||
$this->record($request, $access, $status);
|
||||
throw $error;
|
||||
}
|
||||
if ($response->getStatusCode() >= 400) {
|
||||
$this->record($request, $access, $response->getStatusCode());
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function record(Request $request, PortalProfileAccess $access, int $status): void
|
||||
{
|
||||
$access->audit($request->user(), 'request_denied', ['method' => $request->method(), 'path' => $request->path(), 'status' => $status]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsurePortalProfiles
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
abort_unless(config('portal.profile_access_enabled'), 404);
|
||||
abort_unless(app(PortalProfileAccess::class)->available(), 503, 'Autorização por perfis ainda não instalada.');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -24,6 +26,14 @@ class HandleInertiaRequests extends Middleware
|
||||
*/
|
||||
public function version(Request $request): ?string
|
||||
{
|
||||
if (app()->environment('testing') && config('portal.qa_build_directory')) {
|
||||
$manifest = public_path(config('portal.qa_build_directory').'/manifest.json');
|
||||
|
||||
$version = is_file($manifest) ? hash_file('xxh128', $manifest) : false;
|
||||
|
||||
return $version === false ? null : $version;
|
||||
}
|
||||
|
||||
return parent::version($request);
|
||||
}
|
||||
|
||||
@@ -39,11 +49,18 @@ class HandleInertiaRequests extends Middleware
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
|
||||
'portalOperator' => config('portal.operator_name'),
|
||||
'educationEnabled' => (bool) config('education.enabled', false),
|
||||
'aiEnabled' => (bool) config('investmoz_ai.enabled', false),
|
||||
'existingBusinessEnabled' => (bool) config('existing-business.enabled', false),
|
||||
'investorPathEnabled' => app(InvestorPath::class)->available(),
|
||||
'opportunityManagement' => config('portal.opportunity_dashboard_access_enabled') && $request->user() && app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()) ? [
|
||||
'installed' => app(OpportunityManagement::class)->installed(),
|
||||
'manage' => ['label' => 'Gestão de Oportunidades', 'href' => '/management/opportunities'],
|
||||
'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')],
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PortalProfileSession
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
$available = $access->available();
|
||||
if ($request->user() && config('portal.profile_access_enabled') && ! $available) {
|
||||
$recovery = $request->is('management/profile-install', 'management/profile-install/bootstrap')
|
||||
|| ($request->is('logout') && $request->isMethod('POST'));
|
||||
abort_if(! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && ! $recovery, 503,
|
||||
'Autorizações indisponíveis: recuperar a consistência dos perfis antes de gravar. Pode terminar a sessão ou consultar o instalador.');
|
||||
}
|
||||
if ($request->user() && $available) {
|
||||
$revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision');
|
||||
$bound = $request->session()->get('portal.access_revision');
|
||||
if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return $request->expectsJson() ? response()->json(['message' => 'A autorização foi alterada. Inicie uma nova sessão.'], 401)
|
||||
: redirect('/login')->withErrors(['email' => 'A autorização foi alterada. Inicie uma nova sessão.']);
|
||||
}
|
||||
$request->session()->put('portal.access_revision', $revision);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ProtectPrivilegedIdentity
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (! $actor || ! $access->available()) {
|
||||
return $next($request);
|
||||
}
|
||||
if (! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && $access->has($actor, 'auditor')
|
||||
&& $request->is('management/*', 'api/v1/investment/admin/*', 'api/v2/investment/admin/*')) {
|
||||
$access->audit($actor, 'auditor_administrative_write_denied', ['method' => $request->method(), 'path' => $request->path()]);
|
||||
abort(403, 'Auditor só permite leitura administrativa, mesmo com outro papel ou permissão legada.');
|
||||
}
|
||||
$mandatoryMfa = $access->superuser($actor) || $access->has($actor, 'validator') || $access->has($actor, 'publisher');
|
||||
$denied = $request->isMethod('DELETE') && $request->is('user/two-factor-authentication') && $mandatoryMfa;
|
||||
$denied = $denied || ($access->superuser($actor) && $request->is('settings/profile') && $request->isMethod('PATCH')
|
||||
&& $request->has('email') && $request->input('email') !== $actor->email);
|
||||
if ($request->isMethod('DELETE') && $request->is('settings/profile')) {
|
||||
$denied = $denied || $access->superuser($actor)
|
||||
|| DB::table('portal_role_assignments')->where('user_id', $actor->id)->orWhere('approved_by', $actor->id)->exists()
|
||||
|| DB::table('portal_profile_requests')->where('user_id', $actor->id)->orWhere('decided_by', $actor->id)->exists();
|
||||
}
|
||||
if ($denied) {
|
||||
$access->audit($actor, 'privileged_identity_change_denied', ['method' => $request->method(), 'path' => $request->path()]);
|
||||
abort(403, 'Esta alteração removeria MFA obrigatório, um titular ou histórico de autorização. Use revogação ou substituição controlada; não desative MFA para recuperar acesso.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ProtectProfileOpportunity
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$id = null;
|
||||
if (preg_match('#^(?:(?:management|api/v1)/opportunities|api/v1/investment/admin/opportunities|minhas-oportunidades|management/territories/opportunities)/(\d+)(?:/|$)#', $request->path(), $match)) {
|
||||
$id = (int) $match[1];
|
||||
} elseif ($request->is('management/opportunity-origin/preview', 'management/opportunity-origin/apply')) {
|
||||
$id = filter_var($request->input('id'), FILTER_VALIDATE_INT) ?: null;
|
||||
} elseif ($request->is('api/v2/investment/admin/zones/*/links')) {
|
||||
$id = filter_var($request->input('opportunity_id'), FILTER_VALIDATE_INT) ?: null;
|
||||
}
|
||||
if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$record = Opportunity::withTrashed()->find($id);
|
||||
if ($record && $record->owner_user_id === null && ! $request->isMethod('GET')) {
|
||||
abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 403, 'O papel v1 não contorna os controlos através de um registo legado.');
|
||||
}
|
||||
if ($record && ($record->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades'))) {
|
||||
abort_if($record->owner_user_id === null && str_starts_with($request->path(), 'minhas-oportunidades'), 403, 'Registo legado não convertido para a política v1.');
|
||||
$access = app(PortalProfileAccess::class);
|
||||
$access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') ? 'view' : 'edit', $record);
|
||||
abort_if($request->is('management/opportunity-origin/apply'), 403, 'O cadastro privado v1 não usa a classificação editorial dos exemplos legados.');
|
||||
abort_if($request->isMethod('POST') && str_ends_with($request->path(), '/lifecycle') && ! str_contains($request->path(), '/resources/'), 403, 'Use o workflow com decisões segregadas; o perfil não concede exclusão.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,12 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* @property int|null $owner_user_id
|
||||
* @property string|null $organization_id
|
||||
* @property array<string,mixed>|null $workflow_context
|
||||
* @property string|null $primary_territory_id
|
||||
* @property int $territory_version
|
||||
* @property int $management_version
|
||||
@@ -23,7 +27,7 @@ class Opportunity extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $hidden = ['management_draft', 'management_creation_key', 'management_version', 'first_submitted_at', 'management_closed_once'];
|
||||
protected $hidden = ['management_draft', 'management_creation_key', 'management_version', 'first_submitted_at', 'management_closed_once', 'owner_user_id', 'organization_id', 'workflow_context'];
|
||||
|
||||
protected $fillable = [
|
||||
'sector_id', 'reference', 'title', 'slug', 'summary', 'description',
|
||||
@@ -37,6 +41,7 @@ class Opportunity extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'workflow_context' => 'array',
|
||||
'is_featured' => 'boolean',
|
||||
'territory_version' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
@@ -44,6 +49,20 @@ class Opportunity extends Model
|
||||
'estimated_investment_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
/** @return array{scope:string,label:string,public_catalog:bool} */
|
||||
public function publicationPresentation(): array
|
||||
{
|
||||
$public = $this->exists && self::query()->whereKey($this->id)->published()->exists();
|
||||
$visible = ! $this->trashed() && $this->status === 'published' && $this->published_at !== null && $this->published_at->lte(now());
|
||||
$scope = $public ? 'public' : ($visible && $this->owner_user_id !== null ? 'restricted' : 'unpublished');
|
||||
|
||||
return ['scope' => $scope, 'public_catalog' => $public, 'label' => match ($scope) {
|
||||
'public' => 'Publicada no catálogo público',
|
||||
'restricted' => 'Disponibilizada com acesso restrito — perfis e organização autorizados',
|
||||
default => 'Sem versão disponibilizada; aprovação interna é uma etapa distinta',
|
||||
}];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Sector, $this> */
|
||||
public function sector(): BelongsTo
|
||||
{
|
||||
@@ -67,6 +86,10 @@ class Opportunity extends Model
|
||||
*/
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
if (Schema::hasColumn('opportunities', 'workflow_context')) {
|
||||
$query->where(fn ($q) => $q->whereNull('owner_user_id')->orWhere('workflow_context->publication->visibility', 'public'));
|
||||
}
|
||||
|
||||
return $query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now())
|
||||
->whereRaw("COALESCE((SELECT origin.after_data->>'classification' FROM audit_events origin WHERE origin.source_system='opportunity-origin-review' AND origin.after_data->>'opportunity_id'=opportunities.id::text ORDER BY origin.occurred_at DESC,origin.id DESC LIMIT 1),'undetermined') <> 'example'");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property int $portal_access_revision
|
||||
* @property string $password
|
||||
* @property string|null $two_factor_secret
|
||||
* @property string|null $two_factor_recovery_codes
|
||||
|
||||
@@ -9,16 +9,20 @@ use App\Services\Malware\ClamDScanner;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use App\Services\Mne\BusinessRepository;
|
||||
use App\Services\Mne\CompatibleBusinessRepository;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -39,6 +43,17 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
Event::listen(ValidTwoFactorAuthenticationCodeProvided::class, function ($event): void {
|
||||
if (app(PortalProfileAccess::class)->available()) {
|
||||
session()->put(['portal.mfa_actor' => $event->user->id, 'portal.mfa_at' => time()]);
|
||||
}
|
||||
});
|
||||
Event::listen(Login::class, function ($event): void {
|
||||
if (app(PortalProfileAccess::class)->available()) {
|
||||
session()->put('portal.access_revision', (int) DB::table('users')->where('id', $event->user->id)->value('portal_access_revision'));
|
||||
}
|
||||
});
|
||||
|
||||
Gate::define('manage-portal', fn (User $user) => $user->hasVerifiedEmail()
|
||||
&& in_array(strtolower($user->email), config('portal.managers', []), true));
|
||||
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Opis\JsonSchema\CompliantValidator;
|
||||
use Opis\JsonSchema\Errors\ErrorFormatter;
|
||||
use Opis\JsonSchema\Errors\ValidationError;
|
||||
|
||||
/** Explicit adapter of §25/26 to existing IDs and resource collections; never renumbers records. */
|
||||
class OpportunityCanonicalContract
|
||||
{
|
||||
public const VERSION = 'opportunity-canonical-adapter-20261010-semantic1';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function schema(bool $draft = false): array
|
||||
{
|
||||
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
|
||||
if ($bytes === false) {
|
||||
throw new \RuntimeException('Opportunity schema unavailable.');
|
||||
}
|
||||
$schema = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
// The original conditions were omitted from the extracted Create/Patch definitions.
|
||||
foreach (['opportunity', 'realEstate', 'landRights', 'businessTransaction'] as $name) {
|
||||
$schema['$defs'][$name.'Create']['allOf'] = $schema['$defs'][$name]['allOf'] ?? [];
|
||||
}
|
||||
$schema = app(OpportunityCategoryFields::class)->enrich($schema);
|
||||
$schema = $this->writeSchema($schema, $draft);
|
||||
$properties = &$schema['$defs']['opportunityCreate']['properties'];
|
||||
unset($properties['mutation_context']); // Actor, audit reason and revision are server-owned.
|
||||
$properties['sector_id'] = ['type' => 'integer', 'minimum' => 1];
|
||||
$properties['promoter_id'] = ['type' => ['string', 'integer']];
|
||||
// References point to the existing collections; their metadata is recovered on the server.
|
||||
foreach (['locations', 'documents', 'images'] as $field) {
|
||||
$properties[$field]['items'] = ['type' => 'string', 'format' => 'uuid'];
|
||||
}
|
||||
$properties['evidences'] = ['type' => 'array', 'items' => ['$ref' => '#/$defs/evidenceCreate']];
|
||||
if (! $draft) {
|
||||
$properties['evidences']['minItems'] = 1;
|
||||
}
|
||||
$properties['evidences']['items'] = ['type' => 'object', 'additionalProperties' => false,
|
||||
'properties' => ['title' => ['type' => 'string'], 'evidence_type' => ['type' => 'string'],
|
||||
'document_id' => ['type' => 'string', 'format' => 'uuid'], 'description' => ['type' => 'string']],
|
||||
...($draft ? [] : ['required' => ['title', 'evidence_type', 'document_id']])];
|
||||
$schema['$ref'] = '#/$defs/opportunityCreate';
|
||||
$schema['$id'] = 'urn:investmoz:'.self::VERSION.($draft ? ':draft' : ':submit');
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $node
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function writeSchema(array $node, bool $draft): array
|
||||
{
|
||||
if (isset($node['$ref']) && preg_match('~^#/\$defs/(.+)$~', $node['$ref'], $match)) {
|
||||
// Collections in the normative Create still referenced response definitions.
|
||||
$defs = ['product', 'asset', 'input', 'market', 'impact', 'moneyItem', 'milestone', 'license', 'risk', 'relation', 'employment', 'environmentalSocial', 'localizedPublication', 'realEstate', 'landRights', 'businessTransaction', 'parcel', 'improvement', 'community'];
|
||||
if (in_array($match[1], $defs, true)) {
|
||||
$node['$ref'] .= 'Create';
|
||||
}
|
||||
}
|
||||
if ($draft) {
|
||||
unset($node['required'], $node['minItems'], $node['minProperties'], $node['contains'], $node['allOf']);
|
||||
if (isset($node['type'])) {
|
||||
$node['type'] = array_values(array_unique([...(array) $node['type'], 'null']));
|
||||
}
|
||||
if (isset($node['enum']) && ! in_array(null, $node['enum'], true)) {
|
||||
$node['enum'][] = null;
|
||||
}
|
||||
if (isset($node['minLength'])) {
|
||||
$node['minLength'] = 0;
|
||||
}
|
||||
if (($node['const'] ?? null) === true) {
|
||||
unset($node['const']);
|
||||
$node['type'] = 'boolean';
|
||||
}
|
||||
}
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
if (in_array($key, ['properties', '$defs'], true)) {
|
||||
foreach ($value as $name => $child) {
|
||||
$value[$name] = $this->writeSchema($child, $draft);
|
||||
}
|
||||
$node[$key] = $value;
|
||||
} elseif (in_array($key, ['items', 'if', 'then', 'else'], true)) {
|
||||
$node[$key] = $this->writeSchema($value, $draft);
|
||||
} elseif (in_array($key, ['allOf', 'oneOf', 'anyOf'], true)) {
|
||||
$node[$key] = array_map(fn ($child) => $this->writeSchema($child, $draft), $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function draft(array $payload): array
|
||||
{
|
||||
abort_unless(config('portal.opportunity_specialized_drafts_enabled'), 422, 'Contrato especializado indisponível.');
|
||||
$this->validateSchema($payload, true);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
private function validateSchema(array $payload, bool $draft): void
|
||||
{
|
||||
$schema = $this->schema($draft);
|
||||
$validator = new CompliantValidator(null, 100, false);
|
||||
$data = $this->schemaData($payload, $schema['$defs']['opportunityCreate'], $schema['$defs']);
|
||||
$result = $validator->validate($data, json_decode(json_encode($schema, JSON_THROW_ON_ERROR)));
|
||||
if ($error = $result->error()) {
|
||||
$errors = [];
|
||||
foreach ((new ErrorFormatter)->format($error) as $path => $messages) {
|
||||
$errors['canonical'.str_replace('/', '.', $path)] = $messages;
|
||||
}
|
||||
$required = function (ValidationError $node) use (&$required, &$errors): void {
|
||||
if ($node->keyword() === 'required') {
|
||||
$prefix = 'canonical.'.implode('.', $node->data()->fullPath());
|
||||
foreach ($node->args()['missing'] ?? [] as $field) {
|
||||
$errors[rtrim($prefix, '.').'.'.$field] = ['Preencha este campo antes de submeter.'];
|
||||
}
|
||||
}
|
||||
foreach ($node->subErrors() as $child) {
|
||||
$required($child);
|
||||
}
|
||||
};
|
||||
$required($error);
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $node
|
||||
* @param array<string,mixed> $defs
|
||||
*/
|
||||
private function schemaData(mixed $value, array $node, array $defs): mixed
|
||||
{
|
||||
if (isset($node['$ref'])) {
|
||||
$name = basename($node['$ref']);
|
||||
$node = $defs[$name] ?? $node;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (in_array('object', (array) ($node['type'] ?? []), true) || isset($node['properties'])) {
|
||||
$result = new \stdClass;
|
||||
foreach ($value as $key => $item) {
|
||||
$result->{$key} = $this->schemaData($item, $node['properties'][$key] ?? [], $defs);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return array_map(fn ($item) => $this->schemaData($item, $node['items'] ?? [], $defs), $value);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateSubmission(Opportunity $record): array
|
||||
{
|
||||
$values = app(OpportunityManagement::class)->values($record);
|
||||
$payload = $values['canonical'] ?? [];
|
||||
$this->validateSchema($payload, false);
|
||||
$errors = [];
|
||||
$check = function (bool $valid, string $path, string $message) use (&$errors): void {
|
||||
if (! $valid) {
|
||||
$errors['canonical.'.$path] = $message;
|
||||
}
|
||||
};
|
||||
$check(($payload['opportunity_category'] ?? null) === $record->opportunity_category, 'opportunity_category', 'A categoria deve coincidir com o registo e a autorização.');
|
||||
$check(($payload['promoter_id'] ?? null) === ($record->organization_id ?? (int) $record->owner_user_id), 'promoter_id', 'Use a organização representada ou o autor individual; não outra identidade.');
|
||||
$sector = DB::table('sectors')->where('id', $payload['sector_id'])->whereNull('deleted_at')->where('is_active', true)->first();
|
||||
$subsector = DB::table('economic_activities')->where('id', $payload['subsector_id'])->where('status', 'published')->first();
|
||||
$activity = DB::table('economic_activities')->where('id', $payload['economic_activity_id'])->where('status', 'published')->first();
|
||||
$root = $subsector?->parent_id ? DB::table('economic_activities')->where('id', $subsector->parent_id)->where('status', 'published')->first() : null;
|
||||
$check($sector !== null && $root !== null && (int) $root->sector_id === (int) $sector->id
|
||||
&& $activity !== null && $activity->parent_id === $subsector->id && $activity->catalog_version_id === $root->catalog_version_id,
|
||||
'economic_activity_id', 'Sector → subsector → atividade requer referências publicadas da mesma versão e hierarquia.');
|
||||
foreach (['internal_contact_id', 'public_contact_id'] as $field) {
|
||||
if (! empty($payload[$field])) {
|
||||
$contacts = DB::table('contacts')->where('contacts.id', $payload[$field])->whereNull('contacts.deleted_at');
|
||||
if ($record->organization_id) {
|
||||
$contacts->whereExists(fn ($q) => $q->selectRaw('1')->from('investor_contacts as ic')->join('investors as i', 'i.id', '=', 'ic.investor_id')
|
||||
->whereColumn('ic.contact_id', 'contacts.id')->where('i.organization_id', $record->organization_id)->whereNull('i.deleted_at'));
|
||||
} else {
|
||||
$contacts->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$record->owner_user_id);
|
||||
}
|
||||
$check($contacts->exists(), $field, 'Contacto inexistente ou sem relação com o Promotor.');
|
||||
}
|
||||
}
|
||||
$resources = [];
|
||||
foreach (['locations', 'documents', 'images'] as $kind) {
|
||||
$table = app(OpportunityResources::class)->table($kind);
|
||||
foreach ($payload[$kind] ?? [] as $index => $id) {
|
||||
$row = DB::table($table)->where('id', $id)->where('opportunity_id', $record->id)->whereNull('deleted_at')->first();
|
||||
$check($row !== null, "$kind.$index", 'Recurso inexistente, eliminado ou de outra oportunidade.');
|
||||
if (! $row) {
|
||||
continue;
|
||||
}
|
||||
$row->payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
if ($kind !== 'locations') {
|
||||
$file = DB::table('documents')->where('id', $row->document_id)->first();
|
||||
$check($file !== null && $file->deleted_at === null && $file->status === 'active' && $file->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($file->sha256), trim((string) $file->malware_scanned_sha256)), "$kind.$index", 'Original ativo com análise antimalware correspondente obrigatória.');
|
||||
if ($file) {
|
||||
// Verify actual bytes/cipher integrity as well as the recorded scan hash.
|
||||
app(VerifiedDocumentBinary::class)->respond($file);
|
||||
$row->sha256 = trim($file->sha256);
|
||||
}
|
||||
} else {
|
||||
$location = $row->payload;
|
||||
$check(! empty($location['district_id']) && ! empty($location['role']) && array_key_exists('is_primary', $location), "locations.$index", 'Localização requer distrito, papel e indicação de principal.');
|
||||
app(InvestmentGeography::class)->selection($location, true);
|
||||
$country = $location['country_code'] ?? null;
|
||||
$check($country === 'MZ', "locations.$index.country_code", 'A hierarquia administrativa moçambicana deve declarar MZ; outros países exigem localização própria.');
|
||||
}
|
||||
$resources[$kind][$id] = (array) $row;
|
||||
}
|
||||
}
|
||||
$locations = $resources['locations'] ?? [];
|
||||
$primary = array_filter($locations, fn ($l) => $l['payload']['is_primary'] ?? false);
|
||||
$check(count($primary) === 1, 'locations', 'Selecione exatamente uma localização principal.');
|
||||
$check($payload['location_mode'] !== 'single' || count($locations) === 1, 'location_mode', 'Modo single exige uma localização.');
|
||||
foreach ($payload['evidences'] as $i => $evidence) {
|
||||
$check(isset($resources['documents'][$evidence['document_id']]), "evidences.$i.document_id", 'Evidência deve usar um documento ativo desta oportunidade incluído no contrato.');
|
||||
}
|
||||
$this->categoryRules($payload, $record, $resources, $errors);
|
||||
app(OpportunityCategoryFields::class)->validate($payload, $record, $resources, $errors);
|
||||
if ($errors) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
ksort($payload);
|
||||
$snapshot = ['adapter_version' => self::VERSION, 'schema_sha256' => hash_file('sha256', resource_path('data/investmoz-opportunity-modular.schema.json')),
|
||||
'payload' => $payload, 'resources' => $resources];
|
||||
$snapshot['hash'] = hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $p
|
||||
* @param array<string,mixed> $resources
|
||||
* @param array<string,string> $errors
|
||||
*/
|
||||
private function categoryRules(array $p, Opportunity $record, array $resources, array &$errors): void
|
||||
{
|
||||
$category = $p['opportunity_category'];
|
||||
if (in_array($category, ['concession', 'financing', 'supply', 'other'], true)) {
|
||||
$errors['canonical.module_profile_version'] = '§25.4: não existe perfil modular aprovado/configurado. O rascunho permanece disponível; não inventar regras.';
|
||||
}
|
||||
foreach (['real_estate', 'land_rights', 'business_transaction'] as $module) {
|
||||
$applicable = match ($module) {
|
||||
'real_estate' => ['real_estate'], 'land_rights' => ['land_rights'], default => ['business_sale', 'business_partnership', 'franchise']
|
||||
};
|
||||
if (isset($p[$module]) && ! in_array($category, $applicable, true)) {
|
||||
$errors['canonical.'.$module] = 'Módulo incompatível com a categoria. Preserve-o noutra versão antes de mudar de categoria.';
|
||||
}
|
||||
}
|
||||
if ($category === 'investment_project') {
|
||||
$total = $p['total_investment'];
|
||||
$sum = array_sum(array_column($p['investment_breakdown'], 'amount'));
|
||||
$secured = array_sum(array_column($p['secured_financing'], 'amount'));
|
||||
if (abs($sum - $total) > 0.010001 || $secured > $total || abs($p['funding_sought'] - ($total - $secured)) > 0.010001) {
|
||||
$errors['canonical.investment_breakdown'] = 'Rubricas e financiamento devem reconciliar com o total (tolerância normativa 0,01).';
|
||||
}
|
||||
if (! in_array($p['maturity_stage'], array_keys(OpportunityMaturity::STAGES), true)) {
|
||||
$errors['canonical.maturity_stage'] = 'Use um código explícito do catálogo de oito etapas; não reinterpretar a escala histórica.';
|
||||
}
|
||||
foreach (['investment_breakdown', 'secured_financing'] as $field) {
|
||||
foreach ($p[$field] as $i => $amount) {
|
||||
if ($amount['currency'] !== $p['currency']) {
|
||||
$errors["canonical.$field.$i.currency"] = 'Moedas distintas requerem cenário/câmbio explícito; não somar valores de moedas diferentes.';
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($p['markets'] as $i => $market) {
|
||||
if (isset($market['country_code']) && Validator::make(['country' => $market['country_code']], ['country' => app(CountryCatalogue::class)->rule()])->fails()) {
|
||||
$errors["canonical.markets.$i.country_code"] = 'Use país do catálogo ISO existente.';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($category === 'real_estate') {
|
||||
$re = $p['real_estate'];
|
||||
if (($re['transaction_type'] === 'sale' && ($re['price'] ?? 0) <= 0) || ($re['transaction_type'] === 'lease' && (($re['rent'] ?? 0) <= 0 || empty($re['rent_periodicity'])))) {
|
||||
$errors['canonical.real_estate.price'] = 'Venda exige preço positivo; arrendamento exige renda positiva e periodicidade.';
|
||||
}
|
||||
if (in_array($re['construction_status'], ['completed', 'occupied', 'renovation'], true) && empty($re['property_condition'])) {
|
||||
$errors['canonical.real_estate.property_condition'] = 'Imóvel existente exige condição identificada.';
|
||||
}
|
||||
if (in_array($re['construction_status'], ['planned', 'under_construction'], true) && empty($re['planning_permit_document_id'])) {
|
||||
$errors['canonical.real_estate.planning_permit_document_id'] = 'Estado construtivo exige licença de construção (§25.1).';
|
||||
}
|
||||
if (in_array($re['construction_status'], ['completed', 'occupied', 'renovation'], true) && empty($re['use_license_document_id'])) {
|
||||
$errors['canonical.real_estate.use_license_document_id'] = 'Imóvel existente exige licença de utilização aplicável (§25.1).';
|
||||
}
|
||||
foreach (['planning_permit_document_id', 'use_license_document_id'] as $field) {
|
||||
if (! empty($re[$field]) && ! isset($resources['documents'][$re[$field]])) {
|
||||
$errors['canonical.real_estate.'.$field] = 'Licença deve referir documento ativo desta oportunidade.';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($category === 'land_rights') {
|
||||
$land = $p['land_rights'];
|
||||
if (! empty($land['issuing_authority_id']) && ! DB::table('authorities')->where('id', $land['issuing_authority_id'])->where('status', 'published')->exists()) {
|
||||
$errors['canonical.land_rights.issuing_authority_id'] = 'Entidade emissora deve usar autoridade canónica publicada.';
|
||||
}
|
||||
if (! empty($land['issue_date']) && ($land['issue_date'] > today()->toDateString() || (! empty($land['expiry_date']) && $land['expiry_date'] <= $land['issue_date']))) {
|
||||
$errors['canonical.land_rights.issue_date'] = 'Emissão não futura e validade posterior à emissão.';
|
||||
}
|
||||
foreach ($land['parcels'] as $i => $parcel) {
|
||||
if (! isset($resources['locations'][$parcel['location_id']])) {
|
||||
$errors["canonical.land_rights.parcels.$i.location_id"] = 'Parcela deve usar uma localização desta oportunidade.';
|
||||
}
|
||||
try {
|
||||
$g = DB::selectOne('SELECT ST_IsValid(g) AND NOT ST_IsEmpty(g) AND ST_GeometryType(g)=\'ST_MultiPolygon\' AND ST_XMin(Box3D(g))>=-180 AND ST_XMax(Box3D(g))<=180 AND ST_YMin(Box3D(g))>=-90 AND ST_YMax(Box3D(g))<=90 AS valid FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) g) s', [json_encode($parcel['geometry'])]);
|
||||
if (! $g?->valid) {
|
||||
throw new \RuntimeException;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$errors["canonical.land_rights.parcels.$i.geometry"] = 'Parcela requer MultiPolygon válido, não vazio, EPSG:4326; limite administrativo não é parcela.';
|
||||
}
|
||||
}
|
||||
if (in_array($land['duat_status'], ['provisional', 'definitive'], true) && empty($land['duat_number'])) {
|
||||
$errors['canonical.land_rights.duat_number'] = 'DUAT provisório/definitivo exige número e entidade emissora.';
|
||||
}
|
||||
}
|
||||
if (isset($p['business_transaction'])) {
|
||||
$business = $p['business_transaction'];
|
||||
if ($business['legal_entity_id'] !== $record->organization_id || ! DB::table('organizations')->where('id', $business['legal_entity_id'])->whereNull('deleted_at')->exists()) {
|
||||
$errors['canonical.business_transaction.legal_entity_id'] = 'Entidade ativa representada obrigatória.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $snapshot */
|
||||
public function validatePublication(array $snapshot): void
|
||||
{
|
||||
$p = $snapshot['payload'];
|
||||
$errors = [];
|
||||
app(OpportunityCategoryFields::class)->publication($p, $errors);
|
||||
foreach ($snapshot['resources']['documents'] ?? [] as $id => $document) {
|
||||
if (! empty($document['payload']['valid_until']) && $document['payload']['valid_until'] < today()->toDateString()) {
|
||||
$errors['documents.'.$id] = 'Documento incluído expirou; preserve o original e reveja a versão.';
|
||||
}
|
||||
}
|
||||
if ($p['opportunity_category'] === 'land_rights') {
|
||||
foreach ($p['land_rights']['required_authorizations'] as $i => $authorization) {
|
||||
if (! in_array($authorization['status'] ?? '', ['approved', 'not_required'], true)
|
||||
|| (($authorization['status'] ?? '') === 'approved' && ! isset($snapshot['resources']['documents'][$authorization['document_id'] ?? '']))) {
|
||||
$errors['land_rights.required_authorizations.'.$i] = 'Autorização externa pendente ou sem documento. Aprovação interna não substitui esse ato.';
|
||||
}
|
||||
}
|
||||
foreach ($p['land_rights']['overlaps_conflicts'] as $i => $conflict) {
|
||||
if (($conflict['type'] ?? '') !== 'none' && ($conflict['status'] ?? '') !== 'resolved') {
|
||||
$errors['land_rights.overlaps_conflicts.'.$i] = 'Conflito impeditivo deve ser resolvido com evidência antes de publicar.';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($p['visibility'] === 'public') {
|
||||
$images = $snapshot['resources']['images'] ?? [];
|
||||
if (count(array_filter($images, fn ($image) => $image['payload']['is_cover'] ?? false)) !== 1) {
|
||||
$errors['images'] = 'Publicação pública exige uma capa explícita.';
|
||||
}
|
||||
foreach ($images as $id => $image) {
|
||||
$meta = $image['payload'];
|
||||
if (empty($meta['alt_text']) || empty($meta['caption']) || empty($meta['rights_status']) || empty($meta['derivatives'])
|
||||
|| (! empty($meta['rights_valid_until']) && $meta['rights_valid_until'] < today()->toDateString())) {
|
||||
$errors['images.'.$id] = 'Legenda, texto alternativo, direitos válidos e derivadas verificadas obrigatórios.';
|
||||
}
|
||||
foreach ($meta['derivatives'] ?? [] as $derived) {
|
||||
$file = DB::table('documents')->where('id', $derived['document_id'])->whereNull('deleted_at')->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->first();
|
||||
if (! $file) {
|
||||
$errors['images.'.$id] = 'Scan de uma derivada ainda não demonstrado.';
|
||||
} else {
|
||||
app(VerifiedDocumentBinary::class)->respond($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($errors) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function gates(Opportunity $record): array
|
||||
{
|
||||
return match ($record->opportunity_category) {
|
||||
'investment_project' => ['technical_review', 'legal_territorial_review'],
|
||||
'real_estate' => ['property_review', 'registry_urban_review', 'building_compliance_review'],
|
||||
'land_rights' => ['cadastral_screening', 'land_legal_review', 'community_environment_review', 'competent_authority_clearance'],
|
||||
'business_sale' => ['corporate_screening', 'financial_review', 'legal_tax_due_diligence', 'confidentiality_clearance'],
|
||||
'business_partnership' => ['corporate_screening', 'financial_review', 'partnership_governance_review', 'legal_tax_due_diligence'],
|
||||
'franchise' => ['brand_license_review', 'financial_review', 'franchise_legal_review', 'confidentiality_clearance'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Same exact projection for preview and publication; never expose private business analysis.
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array{title:string,summary:string,description:string} */
|
||||
public function publicProjection(array $payload): array
|
||||
{
|
||||
$business = isset($payload['business_transaction']) && ($payload['visibility'] ?? '') === 'public';
|
||||
|
||||
return ['title' => (string) ($payload['official_title'] ?? ''), 'summary' => (string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['executive_summary'] ?? '')),
|
||||
'description' => (string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['demand_statement'] ?? ''))];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function options(User $actor, ?Opportunity $record): array
|
||||
{
|
||||
$orgs = array_values(array_unique(array_column(app(PortalProfileAccess::class)->navigation($actor)['contexts'] ?? [], 'organization_id')));
|
||||
if ($record?->organization_id) {
|
||||
$orgs = [$record->organization_id];
|
||||
}
|
||||
$contacts = DB::table('contacts')->whereNull('contacts.deleted_at')->where(function ($q) use ($orgs, $actor) {
|
||||
$q->where(fn ($own) => $own->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$actor->id))
|
||||
->orWhereExists(fn ($i) => $i->selectRaw('1')->from('investor_contacts as ic')->join('investors as inv', 'inv.id', '=', 'ic.investor_id')->whereColumn('ic.contact_id', 'contacts.id')->whereIn('inv.organization_id', $orgs)->whereNull('inv.deleted_at'));
|
||||
})->get(['id', 'full_name']);
|
||||
|
||||
return ['version' => self::VERSION, 'schema' => $this->schema(true)['$defs']['opportunityCreate'],
|
||||
'definitions' => $this->schema(true)['$defs'],
|
||||
'activities' => DB::table('economic_activities')->where('status', 'published')->get(['id', 'parent_id', 'sector_id', 'label_pt_mz', 'catalog_version_id']),
|
||||
'contacts' => $contacts, 'countries' => app(CountryCatalogue::class)->all(),
|
||||
'authorities' => DB::table('authorities')->where('status', 'published')->get(['id', 'label_pt_mz']),
|
||||
'gates' => $record ? $this->gates($record) : [], 'resources' => $record ? array_combine(['locations', 'documents', 'images'], array_map(fn ($k) => array_values(array_filter(app(OpportunityResources::class)->listing($record->id, $k), fn ($row) => empty($row->deleted_at))), ['locations', 'documents', 'images'])) : []];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** §25 and common fields 415–422: explicit elaboration of opaque objects, shared by API and editor. */
|
||||
class OpportunityCategoryFields
|
||||
{
|
||||
public const VERSION = 'semantic-fields-20261010-1';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function text(): array
|
||||
{
|
||||
return ['type' => 'string', 'minLength' => 1, 'maxLength' => 1000];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function doc(): array
|
||||
{
|
||||
return ['type' => 'string', 'format' => 'uuid'];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function money(): array
|
||||
{
|
||||
return ['type' => 'number', 'minimum' => 0];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $properties
|
||||
* @param list<string> $required
|
||||
* @return array<string,mixed> */
|
||||
private function object(array $properties, array $required = []): array
|
||||
{
|
||||
return ['type' => 'object', 'properties' => $properties, 'required' => $required, 'additionalProperties' => true];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $properties
|
||||
* @param list<string> $required
|
||||
* @return array<string,mixed> */
|
||||
private function rows(array $properties, array $required = []): array
|
||||
{
|
||||
return ['type' => 'array', 'items' => $this->object($properties, $required)];
|
||||
}
|
||||
|
||||
/** Preserve normative artifact and legacy keys; enrich its runtime adapter explicitly.
|
||||
* @param array<string,mixed> $schema
|
||||
* @return array<string,mixed> */
|
||||
public function enrich(array $schema): array
|
||||
{
|
||||
$s = $this->text();
|
||||
$d = $this->doc();
|
||||
$n = $this->money();
|
||||
$date = ['type' => 'string', 'format' => 'date'];
|
||||
$strings = ['type' => 'array', 'items' => $s];
|
||||
$bool = ['type' => 'boolean'];
|
||||
$valuation = $this->object(['value' => $n, 'date' => $date, 'method' => $s, 'appraiser' => $s, 'document_id' => $d, 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'divergence_justification' => $s], ['value', 'date', 'method', 'appraiser', 'document_id']);
|
||||
$defs = &$schema['$defs'];
|
||||
$common = &$defs['opportunityCreate']['properties'];
|
||||
foreach (['markets' => 'marketCreate', 'risks' => 'riskCreate', 'licenses' => 'licenseCreate', 'milestones' => 'milestoneCreate'] as $field => $def) {
|
||||
$common[$field]['items'] = ['$ref' => '#/$defs/'.$def];
|
||||
}
|
||||
$common['maturity_stage']['enum'] = [...array_keys(OpportunityMaturity::STAGES), null];
|
||||
$common['financial_indicators'] = $this->object(['npv' => ['type' => 'number'], 'irr' => ['type' => 'number'], 'payback_years' => $n, 'scenario' => $s, 'horizon_years' => ['type' => 'integer', 'minimum' => 1], 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'discount_rate' => $n, 'model_document_id' => $d], ['npv', 'irr', 'payback_years', 'scenario', 'horizon_years', 'currency', 'discount_rate', 'model_document_id']);
|
||||
$defs['milestoneCreate']['properties'] += ['start_date' => $date, 'end_date' => $date, 'responsible_name' => $s, 'dependencies' => $strings];
|
||||
$defs['milestoneCreate']['required'] = array_values(array_unique([...$defs['milestoneCreate']['required'], 'start_date', 'end_date', 'responsible_name']));
|
||||
$defs['riskCreate']['properties']['mitigation']['minLength'] = 1;
|
||||
$re = &$defs['realEstateCreate']['properties'];
|
||||
$re['valuation'] = $valuation;
|
||||
$re['utilities'] = $this->rows(['type' => ['enum' => ['electricity', 'water', 'sanitation', 'telecommunications', 'access']], 'status' => ['enum' => ['existing', 'planned', 'unavailable']]], ['type', 'status']);
|
||||
$re['encumbrances'] = $this->rows(['type' => $s, 'details' => $s, 'document_id' => $d], ['type']);
|
||||
$re += ['title_document_id' => $d, 'title_holder_name' => $s, 'issuing_authority_id' => $d, 'expected_completion_date' => $date, 'occupancy_details' => $s, 'occupancy_document_id' => $d, 'amenities_other' => $s, 'urban_limit_document_id' => $d, 'max_built_area' => ['type' => 'number', 'exclusiveMinimum' => 0]];
|
||||
$re['viewing'] = $this->object(['mode' => $s, 'contact_id' => $d, 'window' => $s, 'instructions' => $s]);
|
||||
$defs['realEstateCreate']['required'] = [...$defs['realEstateCreate']['required'], 'title_document_id', 'title_holder_name', 'issuing_authority_id'];
|
||||
$lr = &$defs['landRightsCreate']['properties'];
|
||||
$lr['overlaps_conflicts'] = $this->rows(['type' => $s, 'status' => $s, 'details' => $s, 'resolution_document_id' => $d], ['type', 'status']);
|
||||
$lr['required_authorizations'] = $this->rows(['name' => $s, 'authority_id' => $d, 'status' => ['enum' => ['pending', 'approved', 'rejected', 'not_required']], 'document_id' => $d, 'condition' => $s], ['name', 'status']);
|
||||
$lr['community_consultation'] = $this->object(['status' => $s, 'minutes_document_id' => $d, 'participants' => $strings, 'map_document_id' => $d, 'commitments' => $s, 'date' => $date], ['status', 'minutes_document_id', 'participants', 'map_document_id', 'commitments', 'date']);
|
||||
$lr['consideration'] = $this->object(['object' => ['enum' => ['improvements', 'assets', 'operation', 'partnership']], 'amount' => $n, 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$']], ['object', 'amount', 'currency']);
|
||||
$lr += ['holder_document_id' => $d, 'communities_affected' => $bool, 'exploitation_plan_required' => $bool];
|
||||
$defs['landRightsCreate']['required'] = [...$defs['landRightsCreate']['required'], 'holder_document_id', 'communities_affected', 'exploitation_plan_required'];
|
||||
$bt = &$defs['businessTransactionCreate']['properties'];
|
||||
$bt['valuation'] = $valuation;
|
||||
$bt['entity_registration'] = $this->object(['tax_identifier' => $s, 'registration_number' => $s, 'legal_document_id' => $d, 'beneficial_owner_document_id' => $d], ['tax_identifier', 'registration_number', 'legal_document_id', 'beneficial_owner_document_id']);
|
||||
$bt['financial_periods'] = $this->rows(['year' => ['type' => 'integer', 'minimum' => 1900], 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'revenue' => $n, 'profit' => ['type' => 'number'], 'ebitda' => ['type' => 'number'], 'assets' => $n, 'liabilities' => $n, 'cash_flow' => ['type' => 'number'], 'document_id' => $d], ['year', 'currency', 'revenue', 'profit', 'assets', 'liabilities', 'cash_flow', 'document_id']);
|
||||
$bt['assets_liabilities'] = $this->object(['assets' => $s, 'excluded_assets' => $s, 'liabilities' => $s, 'guarantees' => $s, 'reconciliation_document_id' => $d], ['assets', 'excluded_assets', 'liabilities', 'guarantees', 'reconciliation_document_id']);
|
||||
$bt['employees'] = $this->object(['total' => ['type' => 'integer', 'minimum' => 0], 'contracts' => $s, 'obligations' => $s, 'labor_liabilities' => $s], ['total', 'contracts', 'obligations', 'labor_liabilities']);
|
||||
$bt['contracts_licenses'] = $this->rows(['name' => $s, 'transferability' => ['enum' => ['transferable', 'consent_required', 'not_transferable']], 'expiry_date' => $date, 'consent_document_id' => $d, 'document_id' => $d], ['name', 'transferability', 'expiry_date', 'document_id']);
|
||||
$bt['litigation_contingencies'] = $this->rows(['type' => $s, 'details' => $s, 'value' => $n, 'status' => $s, 'probability' => ['type' => 'number', 'minimum' => 0, 'maximum' => 100], 'entity' => $s, 'document_id' => $d], ['type']);
|
||||
$bt['partner_contribution'] = $this->object(['capital' => $n, 'assets' => $s, 'technology' => $s, 'market' => $s, 'management' => $s, 'calendar' => $s], ['capital', 'assets', 'technology', 'market', 'management', 'calendar']);
|
||||
$bt['governance_rights'] = $this->object(['voting' => $s, 'board' => $s, 'reserved_matters' => $s, 'dividends' => $s, 'exit' => $s, 'deadlock' => $s], ['voting', 'board', 'reserved_matters', 'dividends', 'exit', 'deadlock']);
|
||||
$bt['franchise_terms'] = $this->object(['brand' => $s, 'territory' => $s, 'term' => $s, 'initial_fee' => $n, 'royalties' => $s, 'support' => $s, 'exclusivity' => $s, 'renewal' => $s, 'termination' => $s, 'brand_license_document_id' => $d], ['brand', 'territory', 'term', 'initial_fee', 'royalties', 'support', 'exclusivity', 'renewal', 'termination', 'brand_license_document_id']);
|
||||
$bt += ['available_financial_years' => ['type' => 'integer', 'minimum' => 1], 'nda_document_id' => $d, 'teaser' => $s];
|
||||
$defs['businessTransactionCreate']['required'] = [...$defs['businessTransactionCreate']['required'], 'entity_registration', 'available_financial_years'];
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $p
|
||||
* @param array<string,mixed> $resources
|
||||
* @param array<string,string> $errors */
|
||||
public function validate(array $p, Opportunity $record, array $resources, array &$errors): void
|
||||
{
|
||||
$fail = function (bool $ok, string $path, string $message) use (&$errors) {
|
||||
if (! $ok) {
|
||||
$errors['canonical.'.$path] = $message;
|
||||
}
|
||||
};
|
||||
// Every nested reference belongs to this record, not merely to an existing document.
|
||||
$walk = function (array $node, string $path = '') use (&$walk, $resources, $fail) {
|
||||
foreach ($node as $key => $value) {
|
||||
$here = ltrim($path.'.'.$key, '.');
|
||||
if (is_array($value)) {
|
||||
$walk($value, $here);
|
||||
} elseif (is_string($key) && (str_ends_with($key, 'document_id') || in_array($key, ['image_id', 'location_id'], true)) && $value !== null && $value !== '') {
|
||||
$kind = $key === 'image_id' ? 'images' : ($key === 'location_id' ? 'locations' : 'documents');
|
||||
$fail(isset($resources[$kind][$value]), $here, 'Selecione um recurso ativo desta oportunidade, incluído no contrato.');
|
||||
}
|
||||
if ($key === 'authority_id' || $key === 'issuing_authority_id') {
|
||||
$fail($value === null || DB::table('authorities')->where('id', $value)->where('status', 'published')->where('valid_from', '<=', today())->where(fn ($q) => $q->whereNull('valid_to')->orWhere('valid_to', '>=', today()))->exists(), $here, 'Selecione uma entidade emissora publicada e aplicável nesta data.');
|
||||
}
|
||||
}
|
||||
};
|
||||
$walk($p);
|
||||
if (isset($p['real_estate']) || isset($p['business_transaction'])) {
|
||||
$fail(isset($p['currency']) && is_string($p['currency']) && preg_match('/^[A-Z]{3}$/D', $p['currency']) === 1, 'currency', 'Indique a moeda de todos os valores desta versão.');
|
||||
}
|
||||
if ($p['opportunity_category'] === 'investment_project') {
|
||||
$milestones = $p['milestones'];
|
||||
$fail(count($milestones) >= 2, 'milestones', 'Indique pelo menos dois marcos.');
|
||||
$titles = array_column($milestones, 'title');
|
||||
$fail(count($titles) === count(array_unique($titles)), 'milestones', 'Os nomes dos marcos devem identificar cada dependência sem ambiguidade.');
|
||||
$edges = [];
|
||||
foreach ($milestones as $i => $m) {
|
||||
$fail($m['end_date'] >= $m['start_date'], "milestones.$i.end_date", 'O fim não pode anteceder o início.');
|
||||
$edges[$m['title']] = $m['dependencies'] ?? [];
|
||||
$fail(array_diff($edges[$m['title']], $titles) === [], "milestones.$i.dependencies", 'Uma dependência deve indicar outro marco deste cronograma.');
|
||||
}
|
||||
$seen = [];
|
||||
$active = [];
|
||||
$visit = function ($title) use (&$visit, &$seen, &$active, $edges): bool {
|
||||
if (isset($active[$title])) {
|
||||
return false;
|
||||
}if (isset($seen[$title])) {
|
||||
return true;
|
||||
}$active[$title] = true;
|
||||
foreach ($edges[$title] ?? [] as $parent) {
|
||||
if (! $visit($parent)) {
|
||||
return false;
|
||||
}
|
||||
}unset($active[$title]);
|
||||
$seen[$title] = true;
|
||||
|
||||
return true;
|
||||
};
|
||||
foreach ($titles as $title) {
|
||||
$fail($visit($title), 'milestones', 'As dependências não podem formar um ciclo.');
|
||||
}
|
||||
foreach ($p['risks'] as $i => $risk) {
|
||||
$fail(($risk['likelihood'] < 4 && $risk['severity'] < 4) || ! empty($risk['mitigation']), "risks.$i.mitigation", 'Risco com probabilidade ou gravidade alta exige mitigação.');
|
||||
}
|
||||
if (in_array($p['maturity_stage'], ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational'], true)) {
|
||||
$fail(! empty($p['financial_indicators']), 'financial_indicators', 'A partir de viabilidade, indique cenário, indicadores e modelo financeiro.');
|
||||
$fail(count(array_filter($p['evidences'], fn ($e) => in_array($e['evidence_type'], ['feasibility_study', 'feasibility'], true))) > 0, 'evidences', 'Associe o estudo de viabilidade desta etapa.');
|
||||
}
|
||||
}
|
||||
if (isset($p['real_estate'])) {
|
||||
$re = $p['real_estate'];
|
||||
$types = array_column($re['utilities'], 'type');
|
||||
$fail(count($types) === 5 && count(array_unique($types)) === 5, 'real_estate.utilities', 'Declare os cinco serviços: eletricidade, água, saneamento, telecomunicações e acesso.');
|
||||
if ($re['occupancy_status'] !== 'vacant') {
|
||||
foreach (['occupancy_details', 'occupancy_document_id'] as $field) {
|
||||
$fail(! empty($re[$field]), 'real_estate.'.$field, 'Ocupação exige detalhe contratual restrito e documento.');
|
||||
}
|
||||
}
|
||||
if (in_array($re['construction_status'], ['planned', 'under_construction'], true)) {
|
||||
$fail(! empty($re['expected_completion_date']) && $re['expected_completion_date'] >= today()->toDateString(), 'real_estate.expected_completion_date', 'Indique prazo de conclusão futuro ou atual.');
|
||||
}
|
||||
if (in_array('other', $re['amenities'] ?? [], true)) {
|
||||
$fail(! empty($re['amenities_other']), 'real_estate.amenities_other', 'Descreva a comodidade indicada como outra.');
|
||||
}
|
||||
if (isset($re['max_built_area'])) {
|
||||
$fail($re['built_area'] <= $re['max_built_area'], 'real_estate.built_area', 'A área excede o limite urbanístico declarado.');
|
||||
$fail(! empty($re['urban_limit_document_id']), 'real_estate.urban_limit_document_id', 'Associe a fonte do limite urbanístico.');
|
||||
}
|
||||
foreach ($re['encumbrances'] as $i => $row) {
|
||||
if ($row['type'] !== 'none') {
|
||||
foreach (['details', 'document_id'] as $field) {
|
||||
$fail(! empty($row[$field]), "real_estate.encumbrances.$i.$field", 'Ónus ou restrição exige descrição e documento.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($p['land_rights'])) {
|
||||
$lr = $p['land_rights'];
|
||||
if (in_array($lr['duat_status'], ['provisional', 'definitive'], true)) {
|
||||
$fail(! empty($lr['issuing_authority_id']), 'land_rights.issuing_authority_id', 'DUAT provisório/definitivo exige entidade emissora.');
|
||||
if (! empty($lr['duat_number']) && ! empty($lr['issuing_authority_id'])) {
|
||||
$fail(! Opportunity::whereKeyNot($record->id)->whereNotNull('owner_user_id')->whereRaw("management_draft->'canonical'->'land_rights'->>'duat_number'=?", [$lr['duat_number']])->whereRaw("management_draft->'canonical'->'land_rights'->>'issuing_authority_id'=?", [$lr['issuing_authority_id']])->whereNotIn('status', ['archived', 'rejected'])->exists(), 'land_rights.duat_number', 'Este número já está associado à mesma entidade emissora; reconciliar antes de submeter.');
|
||||
}
|
||||
}
|
||||
if (in_array($lr['operation_type'], ['transfer_of_improvements', 'lease_of_improvements'], true)) {
|
||||
$fail(! empty($lr['improvements']), 'land_rights.improvements', 'A operação sobre benfeitorias exige a respetiva descrição/evidência.');
|
||||
}
|
||||
foreach ($lr['improvements'] ?? [] as $i => $row) {
|
||||
foreach (['assessed_value', 'currency', 'valuation_date', 'document_id', 'image_id'] as $field) {
|
||||
$fail(isset($row[$field]), "land_rights.improvements.$i.$field", 'Benfeitoria exige valor, data, moeda, documento e fotografia.');
|
||||
}
|
||||
}
|
||||
$fail(! $lr['exploitation_plan_required'] || ! empty($lr['exploitation_plan_document_id']), 'land_rights.exploitation_plan_document_id', 'Plano de exploração aplicável exige documento.');
|
||||
$fail(! $lr['communities_affected'] || (! empty($lr['communities']) && ! empty($lr['community_consultation'])), 'land_rights.community_consultation', 'Comunidade afetada exige comunidade, representantes e consulta documentada.');
|
||||
foreach ($lr['communities'] ?? [] as $i => $row) {
|
||||
foreach (['representative_name', 'consultation_date', 'location_id'] as $field) {
|
||||
$fail(! empty($row[$field]), "land_rights.communities.$i.$field", 'Identifique representante, data e localização da comunidade.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($p['business_transaction'])) {
|
||||
$bt = $p['business_transaction'];
|
||||
$type = $bt['transaction_type'];
|
||||
$allowed = match ($p['opportunity_category']) {
|
||||
'business_sale' => ['full_sale', 'partial_sale'],'business_partnership' => ['partnership', 'joint_venture'],'franchise' => ['franchise'],default => []
|
||||
};
|
||||
$fail(in_array($type, $allowed, true), 'business_transaction.transaction_type', 'Modalidade incompatível com esta categoria.');
|
||||
$years = array_column($bt['financial_periods'], 'year');
|
||||
$fail(count($years) >= min(3, $bt['available_financial_years']) && count($years) === count(array_unique($years)), 'business_transaction.financial_periods', 'Inclua pelo menos três exercícios quando existentes, sem repetir anos.');
|
||||
foreach ($years as $i => $year) {
|
||||
$fail($year <= (int) today()->format('Y'), "business_transaction.financial_periods.$i.year", 'Não apresente um exercício futuro como demonstração realizada.');
|
||||
}
|
||||
$fail($bt['litigation_contingencies'] !== [], 'business_transaction.litigation_contingencies', 'Declare contingências, incluindo explicitamente a ausência.');
|
||||
foreach ($bt['litigation_contingencies'] as $i => $row) {
|
||||
if ($row['type'] !== 'none') {
|
||||
foreach (['value', 'status', 'probability', 'entity', 'document_id'] as $field) {
|
||||
$fail(isset($row[$field]), "business_transaction.litigation_contingencies.$i.$field", 'Contingência exige valor, estado, probabilidade, responsável/evidência restrita.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($bt['confidentiality_level'] === 'nda_required') {
|
||||
$fail(! empty($bt['nda_document_id']), 'business_transaction.nda_document_id', 'Associe o acordo de confidencialidade aplicável.');
|
||||
}
|
||||
if ($p['visibility'] === 'public') {
|
||||
$fail(! empty($bt['teaser']), 'business_transaction.teaser', 'Prepare o texto público sem informação confidencial.');
|
||||
}
|
||||
}
|
||||
foreach (['real_estate', 'business_transaction'] as $module) {
|
||||
if (isset($p[$module])) {
|
||||
$v = $p[$module]['valuation'];
|
||||
$fail($v['value'] > 0, "$module.valuation.value", 'Avaliação deve ter valor positivo.');
|
||||
$fail($v['date'] <= today()->toDateString(), "$module.valuation.date", 'A data da avaliação não pode ser futura.');
|
||||
// No arbitrary materiality threshold: reviewer determines divergence and its evidence.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $p
|
||||
* @param array<string,string> $errors */
|
||||
public function publication(array $p, array &$errors): void
|
||||
{
|
||||
if (isset($p['business_transaction'])) {
|
||||
foreach ($p['business_transaction']['contracts_licenses'] as $i => $row) {
|
||||
if ($row['expiry_date'] < today()->toDateString() || $row['transferability'] === 'not_transferable' || ($row['transferability'] === 'consent_required' && empty($row['consent_document_id']))) {
|
||||
$errors["business_transaction.contracts_licenses.$i"] = 'Contrato/licença vencido, intransmissível ou sem consentimento exigido.';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($p['land_rights'])) {
|
||||
$lr = $p['land_rights'];
|
||||
if (! empty($lr['expiry_date']) && $lr['expiry_date'] < today()->toDateString()) {
|
||||
$errors['land_rights.expiry_date'] = 'Direito expirado; decisão interna não renova o ato externo.';
|
||||
}
|
||||
if ($lr['communities_affected'] && ! in_array($lr['community_consultation']['status'] ?? '', ['completed', 'approved'], true)) {
|
||||
$errors['land_rights.community_consultation'] = 'Consulta necessária ainda não concluída/documentada.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class OpportunityImageProcessor
|
||||
/** @param array<string,mixed> $input */
|
||||
public function prepare(User $actor, int $opportunityId, string $id, array $input): void
|
||||
{
|
||||
abort_unless($actor->can('manage-portal'), 403);
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'edit', Opportunity::withTrashed()->findOrFail($opportunityId));
|
||||
abort_unless($this->available(), 423, 'GD/WebP não disponível neste processo. Original e metadados preservados; processamento pendente.');
|
||||
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
||||
$stored = [];
|
||||
@@ -28,6 +28,7 @@ class OpportunityImageProcessor
|
||||
try {
|
||||
DB::transaction(function () use ($actor, $opportunityId, $id, $data, &$stored, &$documents) {
|
||||
$o = Opportunity::withTrashed()->whereKey($opportunityId)->lockForUpdate()->firstOrFail();
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'edit', $o);
|
||||
abort_if($o->trashed() || $o->status === 'archived', 409, 'Restaure antes de processar.');
|
||||
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($o), $data['etag']), 409, 'Conflito de versão; preserve os dados.');
|
||||
$image = DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
|
||||
@@ -20,13 +20,15 @@ class OpportunityManagement
|
||||
|
||||
public const MATURITY_FIELDS = ['maturity_level', 'maturity_label', 'maturity_stage', 'maturity_catalogue_version'];
|
||||
|
||||
public const PRIVATE_DRAFT_FIELDS = [...self::MATURITY_FIELDS, 'real_estate', 'canonical'];
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function values(Opportunity $record): array
|
||||
{
|
||||
$base = $record->only(self::FIELDS);
|
||||
$draft = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
|
||||
return [...$base, ...($this->closed($record) ? $draft : array_intersect_key($draft, array_flip(self::MATURITY_FIELDS)))];
|
||||
return [...$base, ...($this->closed($record) ? $draft : array_intersect_key($draft, array_flip(self::PRIVATE_DRAFT_FIELDS)))];
|
||||
}
|
||||
|
||||
public function installed(): bool
|
||||
@@ -47,7 +49,20 @@ class OpportunityManagement
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, array $input, ?int $id = null): Opportunity
|
||||
{
|
||||
abort_unless($actor->can('manage-portal'), 403);
|
||||
$access = app(PortalProfileAccess::class);
|
||||
$profileCreation = $id === null && str_starts_with(request()->path(), 'minhas-oportunidades');
|
||||
if ($id !== null) {
|
||||
$existingRecord = Opportunity::withTrashed()->findOrFail($id);
|
||||
$access->authorize($actor, 'edit', $existingRecord);
|
||||
if ($existingRecord->owner_user_id !== null) {
|
||||
abort_unless($access->canEdit($actor, $existingRecord, $input['opportunity_category'] ?? null), 403);
|
||||
abort_if(array_key_exists('organization_id', $input) && $input['organization_id'] !== $existingRecord->organization_id, 403, 'A organização não pode ser alterada por edição.');
|
||||
}
|
||||
} elseif ($profileCreation) {
|
||||
abort_unless($access->canCreate($actor, $input['organization_id'] ?? null, $input['opportunity_category'] ?? ''), 403);
|
||||
} else {
|
||||
abort_unless($access->legacyWriteAllowed($actor), 403, 'O papel v1 não usa a criação legada para contornar o seu âmbito.');
|
||||
}
|
||||
abort_unless($this->installed(), 503, 'Instalação autenticada ainda necessária.');
|
||||
$data = Validator::make($input, [
|
||||
'etag' => [$id ? 'required' : 'nullable', 'string', 'size:64'],
|
||||
@@ -62,18 +77,30 @@ class OpportunityManagement
|
||||
'expected_impact' => ['nullable', 'string', 'max:10000'],
|
||||
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
|
||||
'opportunity_category' => ['nullable', Rule::in(self::CATEGORIES)],
|
||||
'real_estate' => ['sometimes', 'array'],
|
||||
'canonical' => ['sometimes', 'array'],
|
||||
'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
|
||||
'estimated_investment_currency' => ['nullable', 'in:MZN,USD,EUR,ZAR'],
|
||||
'reason' => ['required', 'string', 'min:20', 'max:1000'],
|
||||
'organization_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
|
||||
'owner_user_id' => 'prohibited', 'workflow_context' => 'prohibited', 'status' => 'prohibited', 'first_submitted_at' => 'prohibited',
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
])->validate();
|
||||
$data = app(OpportunityMaturity::class)->normalize($data);
|
||||
if (array_key_exists('real_estate', $data)) {
|
||||
$data['real_estate'] = app(OpportunityRealEstateDraft::class)->validate($data['real_estate']);
|
||||
}
|
||||
if (array_key_exists('canonical', $data)) {
|
||||
$data['canonical'] = app(OpportunityCanonicalContract::class)->draft($data['canonical']);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $data, $id) {
|
||||
return DB::transaction(function () use ($actor, $data, $id, $profileCreation, $access) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(91026, 100000)');
|
||||
if (! $id && ($existing = Opportunity::withTrashed()->where('management_creation_key', $data['creation_key'])->first())) {
|
||||
$access->authorize($actor, 'view', $existing);
|
||||
abort_unless($profileCreation === ($existing->owner_user_id !== null) && (! $profileCreation || $existing->organization_id === ($data['organization_id'] ?? null)), 409);
|
||||
$snapshot = $this->values($existing);
|
||||
foreach (array_intersect_key($data, array_flip([...self::FIELDS, ...self::MATURITY_FIELDS])) as $key => $value) {
|
||||
foreach (array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS])) as $key => $value) {
|
||||
if (($snapshot[$key] ?? null) != $value) {
|
||||
throw ValidationException::withMessages(['creation_key' => 'Esta chave já foi utilizada com dados diferentes.']);
|
||||
}
|
||||
@@ -83,11 +110,33 @@ class OpportunityManagement
|
||||
}
|
||||
$record = $id ? Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail() : new Opportunity;
|
||||
if ($id) {
|
||||
$access->authorize($actor, 'edit', $record);
|
||||
$this->check($record, $data['etag']);
|
||||
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
|
||||
}
|
||||
$before = $record->getAttributes();
|
||||
$changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::MATURITY_FIELDS]));
|
||||
$changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS]));
|
||||
if (array_key_exists('canonical', $changes)) {
|
||||
$previous = $record->exists ? $this->values($record) : [];
|
||||
$changes['canonical'] = [...($previous['canonical'] ?? []), ...$changes['canonical']];
|
||||
if (($changes['canonical']['opportunity_category'] ?? ($changes['opportunity_category'] ?? $record->opportunity_category)) !== ($changes['opportunity_category'] ?? $record->opportunity_category)) {
|
||||
throw ValidationException::withMessages(['canonical.opportunity_category' => 'Contrato e registo devem usar a mesma categoria.']);
|
||||
}
|
||||
// One entry of common data feeds the existing fields; a closed/public version stays untouched.
|
||||
foreach (['official_title' => 'title', 'executive_summary' => 'summary', 'demand_statement' => 'description', 'sector_id' => 'sector_id', 'promoter_type' => 'promoter_type'] as $source => $target) {
|
||||
if (array_key_exists($source, $changes['canonical'])) {
|
||||
$changes[$target] = $changes['canonical'][$source];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (array_key_exists('real_estate', $changes)) {
|
||||
$previousValues = $record->exists ? $this->values($record) : [];
|
||||
$category = array_key_exists('opportunity_category', $data) ? $data['opportunity_category'] : ($previousValues['opportunity_category'] ?? null);
|
||||
if ($category !== 'real_estate') {
|
||||
throw ValidationException::withMessages(['real_estate' => 'O contrato imobiliário pertence à categoria real_estate.']);
|
||||
}
|
||||
$changes['real_estate'] = [...($previousValues['real_estate'] ?? []), ...$changes['real_estate']];
|
||||
}
|
||||
if (array_intersect_key($data, InvestmentGeography::FIELDS) !== []) {
|
||||
$path = app(InvestmentGeography::class)->selection($data, true);
|
||||
$changes['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
||||
@@ -102,6 +151,12 @@ class OpportunityManagement
|
||||
$record->management_creation_key = $data['creation_key'];
|
||||
$record->management_version = 1;
|
||||
$record->estimated_investment_currency = null;
|
||||
if ($profileCreation) {
|
||||
abort_unless($access->canCreate($actor, $data['organization_id'] ?? null, $data['opportunity_category'] ?? ''), 403);
|
||||
$record->owner_user_id = $actor->id;
|
||||
$record->organization_id = $data['organization_id'] ?? null;
|
||||
$record->workflow_context = ['policy_version' => PortalProfileAccess::VERSION, 'contributors' => [$actor->id]];
|
||||
}
|
||||
} else {
|
||||
$record->management_version++;
|
||||
}
|
||||
@@ -111,17 +166,24 @@ class OpportunityManagement
|
||||
$record->management_draft = json_encode([...$previous, ...$changes], JSON_THROW_ON_ERROR);
|
||||
} else {
|
||||
foreach ($changes as $key => $value) {
|
||||
if (in_array($key, ['maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) {
|
||||
if (in_array($key, ['real_estate', 'canonical', 'maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) {
|
||||
continue;
|
||||
}
|
||||
$record->{$key} = $value;
|
||||
}
|
||||
if (isset($changes['maturity_catalogue_version'])) {
|
||||
if (isset($changes['maturity_catalogue_version']) || array_key_exists('real_estate', $changes) || array_key_exists('canonical', $changes)) {
|
||||
// The existing draft JSON holds the versioned catalogue; legacy columns stay intact.
|
||||
$previous = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$record->management_draft = json_encode([...$previous, ...array_intersect_key($changes, array_flip(self::MATURITY_FIELDS))], JSON_THROW_ON_ERROR);
|
||||
$record->management_draft = json_encode([...$previous, ...array_intersect_key($changes, array_flip(self::PRIVATE_DRAFT_FIELDS))], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
if ($record->owner_user_id !== null) {
|
||||
$context = $record->workflow_context ?? [];
|
||||
unset($context['approval'], $context['reviews']);
|
||||
$context['content_revision'] = ($context['content_revision'] ?? 0) + 1;
|
||||
$context['contributors'] = array_values(array_unique([...($context['contributors'] ?? []), $actor->id]));
|
||||
$record->workflow_context = $context;
|
||||
}
|
||||
$record->save();
|
||||
$this->audit($record, $actor, $before, $id ? 'draft_updated' : 'draft_created', $data['reason']);
|
||||
|
||||
@@ -132,11 +194,13 @@ class OpportunityManagement
|
||||
/** @param array<string,mixed> $input */
|
||||
public function lifecycle(User $actor, int $id, array $input): Opportunity
|
||||
{
|
||||
abort_unless($actor->can('manage-portal'), 403);
|
||||
$data = Validator::make($input, ['etag' => ['required', 'string', 'size:64'], 'action' => ['required', 'in:delete,restore'], 'reason' => ['required', 'string', 'min:20', 'max:1000'], 'confirm' => ['required', 'accepted']])->validate();
|
||||
abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($actor), 403);
|
||||
$actions = config('portal.opportunity_dashboard_access_enabled') ? 'in:delete,archive,restore' : 'in:delete,restore';
|
||||
$data = Validator::make($input, ['etag' => ['required', 'string', 'size:64'], 'action' => ['required', $actions], 'reason' => ['required', 'string', 'min:20', 'max:1000'], 'confirm' => ['required', 'accepted']])->validate();
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data) {
|
||||
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
abort_if($record->owner_user_id !== null, 403, 'A v1 exige pedido e decisão de arquivo; não concede exclusão ou restauro ao Promotor.');
|
||||
$this->check($record, $data['etag']);
|
||||
$before = $record->getAttributes();
|
||||
if ($data['action'] === 'restore') {
|
||||
@@ -152,8 +216,10 @@ class OpportunityManagement
|
||||
}
|
||||
} else {
|
||||
abort_if($record->trashed() || $record->status === 'archived', 409, 'Registo já eliminado/arquivado.');
|
||||
if ($this->closed($record)) {
|
||||
$record->management_closed_once = true;
|
||||
if ($data['action'] === 'archive' || $this->closed($record)) {
|
||||
if ($this->closed($record)) {
|
||||
$record->management_closed_once = true;
|
||||
}
|
||||
$record->status = 'archived';
|
||||
} else {
|
||||
$record->deleted_at = now();
|
||||
|
||||
@@ -15,7 +15,12 @@ class OpportunityManagementInstallation
|
||||
$files = [__FILE__, app_path('Models/Opportunity.php'), app_path('Services/OpportunityManagement.php'), app_path('Http/Controllers/OpportunityManagementController.php'), app_path('Http/Controllers/PortalManagementController.php'), database_path('migrations/'.self::MIGRATION.'.php'), base_path('routes/web.php'), resource_path('js/pages/management/opportunity-draft.tsx'), resource_path('js/pages/management/opportunities.tsx')];
|
||||
|
||||
$files[] = app_path('Services/OpportunityMaturity.php');
|
||||
$files = [...$files, app_path('Services/OpportunityRealEstateDraft.php'), app_path('Http/Middleware/HandleInertiaRequests.php'), config_path('portal.php'),
|
||||
resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/pages/dashboard.tsx'), resource_path('js/components/app-sidebar.tsx'), resource_path('js/types/opportunity-management.ts'), base_path('vite.config.ts')];
|
||||
|
||||
$files = [...$files, resource_path('js/lib/portal-navigation.ts'), resource_path('js/components/portal-grouped-menu.tsx')];
|
||||
|
||||
$files = [...$files, ...PortalProfileAccess::fingerprintFiles()];
|
||||
return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class OpportunityProfileWorkflow
|
||||
{
|
||||
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management,
|
||||
private OpportunityCanonicalContract $contract, private OpportunityWorkflowTasks $tasks) {}
|
||||
|
||||
/** @return list<string> */
|
||||
public function actions(User $actor, Opportunity $record): array
|
||||
{
|
||||
if ($record->owner_user_id === null || ! $this->access->canView($actor, $record)) {
|
||||
return [];
|
||||
}
|
||||
$actions = [];
|
||||
$state = $this->access->state($record);
|
||||
if ($this->access->scoped($actor, 'promoter', $record->organization_id, $record->opportunity_category)
|
||||
&& ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id)
|
||||
&& $this->access->operational($actor, 'edit', $record->id)) {
|
||||
if (in_array($state, ['draft', 'needs_information'], true)) {
|
||||
$actions[] = 'submit';
|
||||
}
|
||||
if (in_array($state, ['approved', 'published', 'suspended'], true)) {
|
||||
$actions[] = 'request_revision';
|
||||
}
|
||||
if (! in_array($state, ['archived', 'rejected'], true)) {
|
||||
$actions[] = 'request_archive';
|
||||
}
|
||||
if ($state === 'rejected' && empty($record->workflow_context['appeal']['pending'])) {
|
||||
$actions[] = 'appeal';
|
||||
}
|
||||
}
|
||||
if ($this->access->mfa($actor) && ! $this->access->conflict($actor, $record)) {
|
||||
if ($this->access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category) && $this->access->operational($actor, 'validate', $record->id)) {
|
||||
if ($state === 'submitted') {
|
||||
$actions[] = 'start_review';
|
||||
}
|
||||
if (in_array($state, ['submitted', 'in_review', 'information_pending'], true)) {
|
||||
array_push($actions, 'request_correction', 'pending_opinion', 'reject', 'review_gate', 'approve');
|
||||
if ($this->responsibility($actor, $record, 'validation_coordinator')) {
|
||||
$actions[] = 'assign_review';
|
||||
}
|
||||
}
|
||||
if ($state === 'rejected' && ($record->workflow_context['appeal']['pending'] ?? false)
|
||||
&& $this->responsibility($actor, $record, 'appeal_reviewer')
|
||||
&& (int) ($record->workflow_context['rejection']['actor_id'] ?? 0) !== $actor->id) {
|
||||
array_push($actions, 'appeal_uphold', 'appeal_reopen');
|
||||
}
|
||||
if (count(array_filter($record->workflow_context['opinions'] ?? [], fn ($opinion) => ($opinion['recipient_id'] ?? 0) === $actor->id && ($opinion['status'] ?? '') === 'pending')) > 0) {
|
||||
$actions[] = 'respond_opinion';
|
||||
}
|
||||
}
|
||||
if ($this->access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)) {
|
||||
if ($state === 'approved' && $this->access->operational($actor, 'publish', $record->id)
|
||||
&& (int) ($record->workflow_context['approval']['actor_id'] ?? 0) !== $actor->id) {
|
||||
$actions[] = 'publish';
|
||||
if ($this->responsibility($actor, $record, 'publication_coordinator')) {
|
||||
$actions[] = 'assign_publication';
|
||||
}
|
||||
}
|
||||
if ($this->access->operational($actor, 'archive', $record->id)) {
|
||||
if ($record->status === 'published') {
|
||||
$actions[] = 'suspend';
|
||||
}
|
||||
if ($state !== 'archived') {
|
||||
$actions[] = 'archive';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($record->workflow_context['assignment']['assignee_user_id']) && (int) $record->workflow_context['assignment']['assignee_user_id'] !== $actor->id) {
|
||||
$actions = array_values(array_diff($actions, ['start_review', 'request_correction', 'pending_opinion', 'reject', 'review_gate', 'approve']));
|
||||
}
|
||||
if (isset($record->workflow_context['publication_assignment']['assignee_user_id']) && (int) $record->workflow_context['publication_assignment']['assignee_user_id'] !== $actor->id) {
|
||||
$actions = array_values(array_diff($actions, ['publish']));
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/** Scoped names/IDs only; no secrets or implicit assignees.
|
||||
* @return array<string,mixed> */
|
||||
public function participants(User $actor, Opportunity $record): array
|
||||
{
|
||||
$this->access->authorize($actor, 'view', $record);
|
||||
if (! $this->access->has($actor, 'validator') && ! $this->access->has($actor, 'publisher')) {
|
||||
return ['validators' => [], 'publishers' => []];
|
||||
}
|
||||
$result = ['validators' => [], 'publishers' => []];
|
||||
foreach (User::whereNotNull('email_verified_at')->whereNotNull('two_factor_confirmed_at')->get() as $candidate) {
|
||||
if ($this->access->conflict($candidate, $record)) {
|
||||
continue;
|
||||
}
|
||||
foreach (['validator' => 'validators', 'publisher' => 'publishers'] as $role => $list) {
|
||||
if ($this->access->scoped($candidate, $role, $record->organization_id, $record->opportunity_category) && $this->access->operational($candidate, $role === 'validator' ? 'validate' : 'publish', $record->id)) {
|
||||
if ($role === 'publisher' && (int) ($record->workflow_context['approval']['actor_id'] ?? 0) === $candidate->id) {
|
||||
continue;
|
||||
}
|
||||
$result[$list][] = ['id' => $candidate->id, 'name' => $candidate->name, 'technical' => $this->responsibility($candidate, $record, 'technical_reviewer'), 'legal' => $this->responsibility($candidate, $record, 'legal_reviewer')];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function submissionGaps(Opportunity $record): array
|
||||
{
|
||||
try {
|
||||
$this->contract->validateSubmission($record);
|
||||
|
||||
return [];
|
||||
} catch (ValidationException $error) {
|
||||
return array_keys($error->errors());
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function transition(User $actor, int $id, array $input): Opportunity
|
||||
{
|
||||
$record = Opportunity::withTrashed()->findOrFail($id);
|
||||
$this->access->authorize($actor, 'view', $record);
|
||||
abort_unless($record->owner_user_id !== null && ! $record->trashed(), 409, 'Workflow apenas para registos explicitamente sujeitos à política.');
|
||||
$data = Validator::make($input, ['etag' => 'required|string|size:64',
|
||||
'action' => 'required|in:submit,start_review,request_correction,pending_opinion,respond_opinion,reject,approve,publish,suspend,archive,request_archive,appeal,request_revision,review_gate,assign_review,assign_publication,appeal_uphold,appeal_reopen',
|
||||
'reason' => 'required|string|min:20|max:1000', 'correction_items' => 'sometimes|array', 'correction_items.*' => 'required|string|max:250',
|
||||
'gate' => 'sometimes|string|max:80', 'gate_decision' => 'sometimes|in:approved,rejected', 'assignee_user_id' => 'sometimes|integer|min:1',
|
||||
'opinion_id' => 'sometimes|uuid', 'recipient_user_id' => 'sometimes|integer|min:1',
|
||||
'evidence_document_ids' => 'sometimes|array', 'evidence_document_ids.*' => 'uuid', 'due_at' => 'prohibited',
|
||||
'confirm_publication' => 'sometimes|boolean', 'preview_hash' => 'sometimes|string|size:64',
|
||||
])->validate();
|
||||
$action = $data['action'];
|
||||
$this->authorizeAction($actor, $record, $action);
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data, $action) {
|
||||
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.');
|
||||
$this->authorizeAction($actor, $record, $action);
|
||||
$context = $record->workflow_context ?? [];
|
||||
$state = $this->access->state($record);
|
||||
$before = ['status' => $state, 'publication_status' => $record->status, 'version' => $record->management_version];
|
||||
$from = match ($action) {
|
||||
'submit' => ['draft', 'needs_information'], 'start_review' => ['submitted'],
|
||||
'request_correction', 'pending_opinion', 'respond_opinion', 'reject', 'approve', 'review_gate', 'assign_review' => ['submitted', 'in_review', 'information_pending'],
|
||||
'publish' => ['approved'], 'suspend' => [$state],
|
||||
'assign_publication' => ['approved'],
|
||||
'request_revision' => ['approved', 'published', 'suspended'],
|
||||
'archive' => ['draft', 'submitted', 'in_review', 'information_pending', 'needs_information', 'approved', 'rejected', 'published', 'suspended'],
|
||||
'request_archive' => ['draft', 'submitted', 'in_review', 'information_pending', 'needs_information', 'approved', 'published', 'suspended'],
|
||||
'appeal', 'appeal_uphold', 'appeal_reopen' => ['rejected'],
|
||||
default => [],
|
||||
};
|
||||
abort_unless(in_array($state, $from, true), 409, 'Transição incompatível com o estado atual.');
|
||||
$decision = ['actor_id' => $actor->id, 'action' => $action, 'reason' => $data['reason'], 'at' => now()->toIso8601String(),
|
||||
'content_revision' => $context['content_revision'] ?? 1];
|
||||
$next = $state;
|
||||
$snapshot = [];
|
||||
if (in_array($action, ['submit', 'review_gate', 'pending_opinion', 'respond_opinion', 'approve', 'publish'], true)) {
|
||||
$snapshot = $this->contract->validateSubmission($record);
|
||||
if ($action !== 'submit') {
|
||||
abort_unless(hash_equals($context['submission']['hash'] ?? '', $snapshot['hash']), 409, 'Conteúdo ou recursos mudaram desde a submissão. Exige nova submissão/revisão.');
|
||||
}
|
||||
}
|
||||
if ($action === 'submit') {
|
||||
foreach ($context['opinions'] ?? [] as $opinionId => $opinion) {
|
||||
if (($opinion['status'] ?? '') === 'pending') {
|
||||
$context['opinions'][$opinionId]['status'] = 'superseded';
|
||||
$this->tasks->complete($opinion['task_id'] ?? null, 'Nova versão submetida; parecer anterior preservado.');
|
||||
}
|
||||
}
|
||||
$context['submission'] = [...$snapshot, ...$decision];
|
||||
$context['sla'] = ['policy_version' => null, 'due_at' => null, 'status' => 'unconfigured'];
|
||||
unset($context['approval'], $context['reviews'], $context['assignment'],$context['publication_assignment']);
|
||||
$record->first_submitted_at ??= now()->toIso8601String();
|
||||
$record->management_closed_once = true;
|
||||
$record->workflow_context = $context;
|
||||
$context['review_task_id'] = $this->tasks->ensure($record, 'opportunity_review', 'Validação da oportunidade '.$record->reference);
|
||||
$this->tasks->complete($context['correction_task_id'] ?? null, $data['reason']);
|
||||
$next = 'submitted';
|
||||
} elseif ($action === 'start_review' || $action === 'assign_review') {
|
||||
$subject = $action === 'start_review' ? $actor : User::whereKey($data['assignee_user_id'] ?? 0)->firstOrFail();
|
||||
abort_unless($this->access->scoped($subject, 'validator', $record->organization_id, $record->opportunity_category)
|
||||
&& $this->access->operational($subject, 'validate', $record->id) && ! $this->access->conflict($subject, $record)
|
||||
&& $subject->two_factor_confirmed_at !== null, 403, 'Responsável requer papel válido, MFA configurado e independência.');
|
||||
$context['review_task_id'] ??= $this->tasks->ensure($record, 'opportunity_review', 'Validação da oportunidade '.$record->reference);
|
||||
$this->tasks->assign($record, $context['review_task_id'], $subject);
|
||||
$context['assignment'] = [...$decision, 'assignee_user_id' => $subject->id];
|
||||
$next = 'in_review';
|
||||
} elseif ($action === 'review_gate') {
|
||||
$gate = $data['gate'] ?? '';
|
||||
abort_unless(in_array($gate, $this->contract->gates($record), true), 422, 'Gate não aplicável.');
|
||||
$legal = preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1;
|
||||
abort_unless($this->responsibility($actor, $record, $legal ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Responsabilidade de revisão necessária.');
|
||||
$documents = $data['evidence_document_ids'] ?? [];
|
||||
abort_unless($documents !== [] && array_diff($documents, array_keys($snapshot['resources']['documents'] ?? [])) === [], 422, 'Selecione evidência documental desta versão.');
|
||||
abort_unless(isset($data['gate_decision']), 422, 'Indique decisão fundamentada do gate.');
|
||||
$context['reviews'][$gate] = [...$decision, 'decision' => $data['gate_decision'], 'hash' => $snapshot['hash'], 'evidence_document_ids' => $documents];
|
||||
$next = 'in_review';
|
||||
} elseif ($action === 'approve') {
|
||||
abort_if(count(array_filter($context['opinions'] ?? [], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['status'] ?? '') === 'pending')) > 0, 422, 'Parecer da versão atual ainda pendente.');
|
||||
abort_if(count(array_filter($context['opinions'] ?? [], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['response']['decision'] ?? '') === 'rejected')) > 0, 422, 'Parecer desfavorável exige correção e nova versão.');
|
||||
foreach ($this->contract->gates($record) as $gate) {
|
||||
$review = $context['reviews'][$gate] ?? [];
|
||||
abort_unless(($review['decision'] ?? null) === 'approved' && ($review['hash'] ?? '') === $snapshot['hash'], 422, 'Gate '.$gate.' pendente, rejeitado ou de outra versão.');
|
||||
$reviewer = User::whereKey($review['actor_id'])->firstOrFail();
|
||||
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category)
|
||||
&& ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
|
||||
&& $this->responsibility($reviewer, $record, preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Revisor revogado, sem responsabilidade ou com conflito.');
|
||||
}
|
||||
$context['approval'] = [...$decision, 'hash' => $snapshot['hash'], 'gates' => $context['reviews'] ?? []];
|
||||
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
|
||||
$record->workflow_context = $context;
|
||||
$context['publication_task_id'] = $this->tasks->ensure($record, 'opportunity_publication', 'Preparação editorial '.$record->reference);
|
||||
$next = 'approved';
|
||||
} elseif ($action === 'assign_publication') {
|
||||
$subject = User::whereKey($data['assignee_user_id'] ?? 0)->firstOrFail();
|
||||
abort_unless($this->access->scoped($subject, 'publisher', $record->organization_id, $record->opportunity_category) && $this->access->operational($subject, 'publish', $record->id) && ! $this->access->conflict($subject, $record) && $subject->two_factor_confirmed_at !== null && (int) ($context['approval']['actor_id'] ?? 0) !== $subject->id, 403, 'Publicador exige MFA, âmbito e independência da aprovação.');
|
||||
$context['publication_task_id'] ??= $this->tasks->ensure($record, 'opportunity_publication', 'Preparação editorial '.$record->reference);
|
||||
$this->tasks->assign($record, $context['publication_task_id'], $subject);
|
||||
$context['publication_assignment'] = [...$decision, 'assignee_user_id' => $subject->id];
|
||||
} elseif ($action === 'publish') {
|
||||
Validator::make($data, ['confirm_publication' => 'required|accepted', 'preview_hash' => 'required|string|size:64'])->validate();
|
||||
$this->contract->validatePublication($snapshot);
|
||||
abort_unless(hash_equals($snapshot['hash'], $data['preview_hash']), 409, 'Pré-visualização diferente da versão aprovada.');
|
||||
abort_unless(($context['approval']['hash'] ?? '') === $snapshot['hash'], 409, 'Aprovação não corresponde ao conteúdo atual.');
|
||||
foreach ($this->contract->gates($record) as $gate) {
|
||||
$review = $context['approval']['gates'][$gate] ?? [];
|
||||
abort_unless(($review['decision'] ?? null) === 'approved' && ($review['hash'] ?? '') === $snapshot['hash'], 422, 'Revisão aprovada desta versão necessária.');
|
||||
$reviewer = User::whereKey($review['actor_id'])->firstOrFail();
|
||||
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category)
|
||||
&& ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
|
||||
&& $this->responsibility($reviewer, $record, preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Revisor revogado, sem responsabilidade ou com conflito na publicação.');
|
||||
}
|
||||
$approver = User::whereKey($context['approval']['actor_id'] ?? 0)->firstOrFail();
|
||||
abort_unless($this->access->scoped($approver, 'validator', $record->organization_id, $record->opportunity_category)
|
||||
&& ! $this->access->conflict($approver, $record) && $this->access->operational($approver, 'validate', $record->id), 403, 'Aprovador revogado ou com conflito.');
|
||||
$p = $snapshot['payload'];
|
||||
if (! empty($p['review_due_date'])) {
|
||||
abort_unless($p['review_due_date'] > today()->toDateString(), 422, 'Data de revisão deve ser futura.');
|
||||
}
|
||||
if (isset($p['business_transaction'])) {
|
||||
abort_unless(in_array($p['business_transaction']['due_diligence_status'], ['data_room_ready', 'in_progress', 'completed', 'exceptions'], true), 422, 'Data room mínimo não preparado.');
|
||||
}
|
||||
if (isset($context['publication'])) {
|
||||
$context['publication_history'][] = $context['publication'];
|
||||
}
|
||||
$context['publication'] = [...$decision, 'hash' => $snapshot['hash'], 'visibility' => $p['visibility'],
|
||||
'public_fields' => ['title', 'summary', 'description', 'sector_id', 'province', 'district']];
|
||||
// Minimal public projection; contacts, valuations, evidence and originals remain private.
|
||||
$projection = $this->contract->publicProjection($p);
|
||||
$record->title = $projection['title'];
|
||||
$record->summary = $projection['summary'];
|
||||
$record->description = $projection['description'];
|
||||
$record->sector_id = $p['sector_id'];
|
||||
$locations = array_values(array_filter($snapshot['resources']['locations'], fn ($location) => $location['payload']['is_primary'] ?? false));
|
||||
$location = $locations[0]; // The contract requires exactly one primary location.
|
||||
$path = app(InvestmentGeography::class)->selection($location['payload'], true);
|
||||
$last = array_key_last($path);
|
||||
abort_unless($last !== null, 422, 'Localização principal sem hierarquia.');
|
||||
$record->primary_territory_id = $path[$last]->id;
|
||||
$record->province = $path[0]->name;
|
||||
$record->district = $path[1]->name;
|
||||
$record->geography_scope = $p['geographic_scope'] === 'national' ? 'national' : 'specific';
|
||||
$record->published_at = now();
|
||||
$record->status = 'published';
|
||||
$next = 'published';
|
||||
$this->tasks->complete($context['publication_task_id'] ?? null, $data['reason']);
|
||||
} elseif ($action === 'pending_opinion') {
|
||||
$gate = $data['gate'] ?? '';
|
||||
abort_unless(in_array($gate, $this->contract->gates($record), true), 422, 'Indique a revisão aplicável ao parecer.');
|
||||
$subject = User::whereKey($data['recipient_user_id'] ?? 0)->firstOrFail();
|
||||
abort_unless($this->access->scoped($subject, 'validator', $record->organization_id, $record->opportunity_category)
|
||||
&& $this->access->operational($subject, 'validate', $record->id) && ! $this->access->conflict($subject, $record) && $subject->two_factor_confirmed_at !== null
|
||||
&& $this->responsibility($subject, $record, preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Destinatário exige responsabilidade, MFA e independência.');
|
||||
abort_if($subject->id === $actor->id, 422, 'O parecer deve ser solicitado a outro revisor.');
|
||||
$opinionId = (string) Str::uuid();
|
||||
$taskId = $this->tasks->ensure($record, 'opportunity_opinion:'.$opinionId, 'Parecer da oportunidade '.$record->reference);
|
||||
$this->tasks->assign($record, $taskId, $subject);
|
||||
$context['opinions'][$opinionId] = [...$decision, 'id' => $opinionId, 'recipient_id' => $subject->id, 'gate' => $gate, 'status' => 'pending', 'hash' => $snapshot['hash'], 'task_id' => $taskId];
|
||||
$next = 'information_pending';
|
||||
} elseif ($action === 'respond_opinion') {
|
||||
$opinionId = $data['opinion_id'] ?? '';
|
||||
$opinion = $context['opinions'][$opinionId] ?? [];
|
||||
abort_unless(($opinion['recipient_id'] ?? 0) === $actor->id, 403, 'Parecer atribuído a outro revisor.');
|
||||
abort_unless($this->responsibility($actor, $record, preg_match('/legal|registry|authority|confidentiality|license/', $opinion['gate'] ?? '') === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Responsabilidade do parecer revogada.');
|
||||
abort_unless(($opinion['status'] ?? '') === 'pending' && ($opinion['hash'] ?? '') === $snapshot['hash'], 409, 'Parecer encerrado ou de outra versão.');
|
||||
$documents = $data['evidence_document_ids'] ?? [];
|
||||
abort_unless(isset($data['gate_decision']) && $documents !== [] && array_diff($documents, array_keys($snapshot['resources']['documents'] ?? [])) === [], 422, 'Resposta exige decisão e documentos desta versão.');
|
||||
$context['opinions'][$opinionId]['status'] = 'answered';
|
||||
$context['opinions'][$opinionId]['response'] = [...$decision, 'decision' => $data['gate_decision'], 'evidence_document_ids' => $documents];
|
||||
$this->tasks->complete($opinion['task_id'], $data['reason']);
|
||||
$next = count(array_filter($context['opinions'], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['status'] ?? '') === 'pending')) > 0 ? 'information_pending' : 'in_review';
|
||||
} elseif ($action === 'request_correction') {
|
||||
abort_unless($data['correction_items'] ?? [], 422, 'Indique campos e documentos a corrigir.');
|
||||
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
|
||||
$context['correction_items'] = $data['correction_items'];
|
||||
$context['correction_cycles'] = ($context['correction_cycles'] ?? 0) + 1;
|
||||
$record->workflow_context = $context;
|
||||
$context['correction_task_id'] = $this->tasks->ensure($record, 'opportunity_correction', 'Correções da oportunidade '.$record->reference);
|
||||
$next = 'needs_information';
|
||||
} elseif ($action === 'request_revision' || $action === 'appeal_reopen') {
|
||||
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
|
||||
$this->tasks->complete($context['publication_task_id'] ?? null, $data['reason']);
|
||||
$context['decision_history'][] = ['approval' => $context['approval'] ?? null, 'reviews' => $context['reviews'] ?? null, 'submission' => $context['submission'] ?? null];
|
||||
unset($context['approval'], $context['reviews']);
|
||||
$next = 'needs_information';
|
||||
} elseif ($action === 'request_archive') {
|
||||
$context['request_archive'] = $decision;
|
||||
} elseif ($action === 'appeal') {
|
||||
abort_if($context['appeal']['pending'] ?? false, 409, 'Recurso já pendente.');
|
||||
$context['appeal'] = [...$decision, 'pending' => true];
|
||||
$context['appeal_task_id'] = $this->tasks->ensure($record, 'opportunity_appeal', 'Recurso da oportunidade '.$record->reference);
|
||||
} elseif ($action === 'appeal_uphold') {
|
||||
$next = 'rejected';
|
||||
} else {
|
||||
$next = match ($action) {
|
||||
'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived'
|
||||
};
|
||||
if ($action === 'reject') {
|
||||
$context['rejection'] = $decision;
|
||||
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
|
||||
}
|
||||
if (in_array($action, ['suspend', 'archive'], true)) {
|
||||
$record->status = $next;
|
||||
foreach (['review_task_id', 'correction_task_id', 'publication_task_id', 'appeal_task_id'] as $task) {
|
||||
$this->tasks->complete($context[$task] ?? null, $data['reason']);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (in_array($action, ['appeal_uphold', 'appeal_reopen'], true)) {
|
||||
$context['appeal']['pending'] = false;
|
||||
$context['appeal']['decision'] = $decision;
|
||||
$this->tasks->complete($context['appeal_task_id'] ?? null, $data['reason']);
|
||||
}
|
||||
if (in_array($action, ['request_correction', 'reject'], true)) {
|
||||
unset($context['approval'], $context['reviews']);
|
||||
}
|
||||
if (in_array($action, ['request_correction', 'reject', 'suspend', 'archive', 'request_revision', 'appeal_reopen'], true)) {
|
||||
foreach ($context['opinions'] ?? [] as $opinionId => $opinion) {
|
||||
if (($opinion['status'] ?? '') === 'pending') {
|
||||
$context['opinions'][$opinionId]['status'] = 'superseded';
|
||||
$this->tasks->complete($opinion['task_id'] ?? null, $data['reason']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$context['working_state'] = $next;
|
||||
$context['last_decision'] = $decision;
|
||||
$context['events'][] = [...$decision, 'from' => $state, 'to' => $next];
|
||||
if ($record->status !== 'published' || in_array($action, ['suspend', 'archive'], true)) {
|
||||
$record->status = $next;
|
||||
}
|
||||
$record->management_version++;
|
||||
$record->workflow_context = $context;
|
||||
$record->save();
|
||||
$this->access->audit($actor, 'workflow_'.$action, ['record_id' => $record->id, 'before' => $before,
|
||||
'after' => ['status' => $next, 'publication_status' => $record->status, 'version' => $record->management_version], 'reason' => $data['reason']]);
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
|
||||
private function responsibility(User $actor, Opportunity $record, string $name): bool
|
||||
{
|
||||
foreach ($this->access->grants($actor, $name === 'publication_coordinator' ? 'publisher' : 'validator') as $grant) {
|
||||
if (($grant->organization_id === null || $grant->organization_id === $record->organization_id)
|
||||
&& in_array($record->opportunity_category, json_decode($grant->opportunity_types, true), true)
|
||||
&& in_array($name, json_decode($grant->responsibilities, true), true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function authorizeAction(User $actor, Opportunity $record, string $action): void
|
||||
{
|
||||
$this->access->authorize($actor, 'view', $record);
|
||||
$promoter = $this->access->scoped($actor, 'promoter', $record->organization_id, $record->opportunity_category)
|
||||
&& ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id);
|
||||
if (in_array($action, ['submit', 'request_archive', 'appeal', 'request_revision'], true)) {
|
||||
abort_unless($promoter && $this->access->operational($actor, 'edit', $record->id), 403);
|
||||
} else {
|
||||
$role = in_array($action, ['publish', 'assign_publication', 'suspend', 'archive'], true) ? 'publisher' : 'validator';
|
||||
$operation = $role === 'publisher' ? (in_array($action, ['publish', 'assign_publication'], true) ? 'publish' : 'archive') : 'validate';
|
||||
abort_unless($this->access->scoped($actor, $role, $record->organization_id, $record->opportunity_category)
|
||||
&& $this->access->mfa($actor) && $this->access->operational($actor, $operation, $record->id) && ! $this->access->conflict($actor, $record), 403, 'Papel, MFA e independência necessários.');
|
||||
if ($action === 'publish') {
|
||||
abort_if((int) ($record->workflow_context['approval']['actor_id'] ?? 0) === $actor->id, 403, 'O aprovador não publica a mesma versão.');
|
||||
abort_if(isset($record->workflow_context['publication_assignment']['assignee_user_id']) && (int) $record->workflow_context['publication_assignment']['assignee_user_id'] !== $actor->id, 403, 'Publicação atribuída a outro Publicador; requer reatribuição explícita.');
|
||||
}
|
||||
if ($action === 'assign_publication') {
|
||||
abort_unless($this->responsibility($actor, $record, 'publication_coordinator'), 403);
|
||||
}
|
||||
if ($action === 'assign_review') {
|
||||
abort_unless($this->responsibility($actor, $record, 'validation_coordinator'), 403);
|
||||
}
|
||||
if (in_array($action, ['start_review', 'review_gate', 'request_correction', 'pending_opinion', 'reject', 'approve'], true)
|
||||
&& isset($record->workflow_context['assignment']['assignee_user_id'])) {
|
||||
abort_unless((int) $record->workflow_context['assignment']['assignee_user_id'] === $actor->id, 403, 'Tarefa atribuída a outro Validador; o coordenador deve registar a reatribuição.');
|
||||
}
|
||||
if (in_array($action, ['appeal_uphold', 'appeal_reopen'], true)) {
|
||||
abort_unless($this->responsibility($actor, $record, 'appeal_reviewer') && ($record->workflow_context['appeal']['pending'] ?? false)
|
||||
&& (int) ($record->workflow_context['rejection']['actor_id'] ?? 0) !== $actor->id, 403, 'Recurso exige instância independente da rejeição.');
|
||||
}
|
||||
if ($action === 'suspend') {
|
||||
abort_unless($record->status === 'published', 409);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** First draft adapter, not a complete contract validator or a publication gate. */
|
||||
class OpportunityRealEstateDraft
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $values
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function validate(array $values): array
|
||||
{
|
||||
abort_unless(config('portal.opportunity_specialized_drafts_enabled'), 422, 'Campos especializados ainda não disponibilizados neste ambiente.');
|
||||
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
|
||||
if ($bytes === false) {
|
||||
throw new \RuntimeException('Normative opportunity schema unavailable.');
|
||||
}
|
||||
$schema = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
$properties = $schema['$defs']['realEstatePatch']['properties'];
|
||||
if ($values === [] || array_diff(array_keys($values), array_keys($properties))) {
|
||||
throw ValidationException::withMessages(['real_estate' => 'Indique campos do contrato imobiliário; campos desconhecidos ou de resposta não são graváveis.']);
|
||||
}
|
||||
$errors = [];
|
||||
foreach ($values as $key => $value) {
|
||||
$this->check($value, $properties[$key], 'real_estate.'.$key, $errors);
|
||||
}
|
||||
if ($errors) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $schema
|
||||
* @param array<string,string> $errors
|
||||
*/
|
||||
private function check(mixed $value, array $schema, string $path, array &$errors): void
|
||||
{
|
||||
$types = (array) ($schema['type'] ?? []);
|
||||
$valid = $types === [] || array_filter($types, fn ($type) => match ($type) {
|
||||
'null' => $value === null,
|
||||
'string' => is_string($value),
|
||||
'integer' => is_int($value),
|
||||
'number' => is_int($value) || is_float($value),
|
||||
'boolean' => is_bool($value),
|
||||
'array' => is_array($value) && array_is_list($value),
|
||||
// Laravel request arrays represent JSON objects, including empty ones.
|
||||
'object' => is_array($value) && ($value === [] || ! array_is_list($value)),
|
||||
default => false,
|
||||
}) !== [];
|
||||
if (! $valid || (isset($schema['enum']) && ! in_array($value, $schema['enum'], true))) {
|
||||
$errors[$path] = 'Tipo ou valor fora do contrato imobiliário §26.';
|
||||
|
||||
return;
|
||||
}
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
$rules = [];
|
||||
foreach (['minLength' => 'min', 'maxLength' => 'max', 'minItems' => 'min'] as $keyword => $rule) {
|
||||
if (isset($schema[$keyword])) {
|
||||
$rules[] = $rule.':'.$schema[$keyword];
|
||||
}
|
||||
}
|
||||
if (in_array('number', $types, true) || in_array('integer', $types, true)) {
|
||||
$rules[] = 'numeric';
|
||||
foreach (['minimum' => 'min', 'maximum' => 'max', 'exclusiveMinimum' => 'gt'] as $keyword => $rule) {
|
||||
if (isset($schema[$keyword])) {
|
||||
$rules[] = $rule.':'.$schema[$keyword];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (($schema['format'] ?? null) === 'uuid') {
|
||||
$rules[] = 'uuid';
|
||||
}
|
||||
if ($rules && Validator::make(['value' => $value], ['value' => $rules])->fails()) {
|
||||
$errors[$path] = 'Valor fora dos limites/formato do contrato imobiliário §26.';
|
||||
}
|
||||
if (isset($schema['items']) && is_array($value)) {
|
||||
foreach ($value as $index => $item) {
|
||||
$this->check($item, $schema['items'], $path.'.'.$index, $errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,16 @@ class OpportunityResourceInstallation
|
||||
$files = [...$files, app_path('Services/ExistingBusinessIntake.php'), resource_path('js/pages/land-assistance/case.tsx')];
|
||||
$files = [...$files, resource_path('js/pages/investment/detail.tsx'), resource_path('js/pages/partnerships/catalogue.tsx')];
|
||||
|
||||
$files = [...$files, app_path('Services/VerifiedDocumentBinary.php'), app_path('Services/TerritoryGeometryImport.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), config_path('geometry-import.php'), resource_path('js/pages/management/territory-geometry-import.tsx'),
|
||||
$files = [...$files, app_path('Services/VerifiedDocumentBinary.php'), app_path('Console/Commands/ImportDocumentBatch.php'), app_path('Services/TerritoryGeometryImport.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), config_path('geometry-import.php'), config_path('geometry-correspondences.php'), app_path('Http/Controllers/PartnershipExchangeController.php'), resource_path('js/pages/management/territory-geometry-import.tsx'),
|
||||
base_path('bootstrap/app.php'), app_path('Providers/FortifyServiceProvider.php'), config_path('fortify.php'),
|
||||
resource_path('js/pages/auth/login.tsx'), resource_path('js/pages/auth/two-factor-challenge.tsx')];
|
||||
|
||||
$files = [...$files, app_path('Services/OpportunityRealEstateDraft.php'), app_path('Http/Middleware/HandleInertiaRequests.php'), config_path('portal.php'),
|
||||
resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/pages/dashboard.tsx'), resource_path('js/components/app-sidebar.tsx'), resource_path('js/types/opportunity-management.ts'), base_path('vite.config.ts')];
|
||||
|
||||
$files = [...$files, resource_path('js/lib/portal-navigation.ts'), resource_path('js/components/portal-grouped-menu.tsx')];
|
||||
|
||||
$files = [...$files, ...PortalProfileAccess::fingerprintFiles()];
|
||||
return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class OpportunityResources
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, int $opportunityId, string $kind, array $input, ?UploadedFile $file = null, ?string $id = null): \stdClass
|
||||
{
|
||||
abort_unless($actor->can('manage-portal'), 403);
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'edit', Opportunity::withTrashed()->findOrFail($opportunityId));
|
||||
abort_unless($this->installed(), 503);
|
||||
$table = $this->table($kind);
|
||||
$data = Validator::make($input, [
|
||||
@@ -69,6 +69,7 @@ class OpportunityResources
|
||||
try {
|
||||
$result = DB::transaction(function () use ($actor, $opportunityId, $kind, $table, $data, $payload, $file, $id, $stored, $documentId) {
|
||||
$opportunity = $this->lock($opportunityId, $data['etag']);
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
|
||||
$record = $id ? DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first() : null;
|
||||
abort_if($id && ! $record, 404);
|
||||
abort_if($record && ($record->deleted_at || (int) $record->version !== (int) $data['version']), 409, 'Recurso alterado ou eliminado; preserve o preenchimento e reveja a versão.');
|
||||
@@ -147,11 +148,12 @@ class OpportunityResources
|
||||
/** @param array<string,mixed> $input */
|
||||
public function lifecycle(User $actor, int $opportunityId, string $kind, string $id, array $input): void
|
||||
{
|
||||
abort_unless($actor->can('manage-portal'), 403);
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'edit', Opportunity::withTrashed()->findOrFail($opportunityId));
|
||||
abort_unless($this->installed(), 503);
|
||||
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:delete,restore', 'reason' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate();
|
||||
DB::transaction(function () use ($actor, $opportunityId, $kind, $id, $data) {
|
||||
$opportunity = $this->lock($opportunityId, $data['etag']);
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
|
||||
$table = $this->table($kind);
|
||||
$record = DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first();
|
||||
abort_unless($record !== null, 404);
|
||||
@@ -210,6 +212,7 @@ class OpportunityResources
|
||||
{
|
||||
$rules = ['title' => 'nullable|string|max:120', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'visibility' => 'nullable|in:internal,authenticated,public'];
|
||||
if ($kind === 'locations') {
|
||||
$rules += ['country_code' => ['nullable', app(CountryCatalogue::class)->rule()]];
|
||||
$rules += ['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid', 'role' => 'nullable|in:headquarters,production,raw_material,storage,logistics,market,support,other', 'role_description' => 'nullable|string|max:1000', 'operational_unit' => 'nullable|string|max:120', 'is_primary' => 'nullable|boolean', 'address' => 'nullable|string|max:300', 'geometry' => 'nullable|array', 'area_value' => 'nullable|numeric|gt:0', 'area_unit' => 'nullable|in:sqm,ha,sqkm', 'allocation_percentage' => 'nullable|numeric|between:0,100', 'territorial_purpose' => 'nullable|in:implementation,context,impact,coverage', 'multi_district_rationale' => 'nullable|string|max:1000'];
|
||||
} elseif ($kind === 'images') {
|
||||
$rules += ['role' => 'nullable|in:cover,gallery,location,asset,product,plan,rendering,evidence,other', 'role_description' => 'nullable|string|max:1000', 'is_cover' => 'nullable|boolean', 'caption' => 'nullable|string|max:300', 'alt_text' => 'nullable|string|max:250', 'location_id' => ['nullable', 'uuid', Rule::exists('opportunity_location_drafts', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'captured_at' => 'nullable|date_format:Y-m-d|before_or_equal:today', 'author_source' => 'nullable|string|max:150', 'rights_status' => 'nullable|in:owned,licensed,authorized,public_domain', 'rights_document_id' => ['nullable', 'uuid', Rule::exists('opportunity_documents', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'rights_valid_until' => 'nullable|date_format:Y-m-d', 'sort_order' => 'nullable|integer|min:0'];
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Existing CRM tasks, explicit portal binding; no invented CRM opportunity or assignee. */
|
||||
class OpportunityWorkflowTasks
|
||||
{
|
||||
public function ensure(Opportunity $record, string $kind, string $title): string
|
||||
{
|
||||
$key = 'portal-opportunity:'.$record->id.':'.$kind.':'.($record->workflow_context['content_revision'] ?? 1);
|
||||
$existing = DB::table('tasks')->where('source_system', 'opportunity-workflow')->where('external_id', $key)->first();
|
||||
if ($existing) {
|
||||
return $existing->id;
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('tasks')->insert(['id' => $id, 'source_system' => 'opportunity-workflow', 'external_id' => $key,
|
||||
'correlation_id' => $record->management_creation_key, 'task_type' => $kind, 'title' => $title,
|
||||
'status' => 'open', 'due_at' => null, 'assignee_user_id' => null, 'data_classification' => 'restricted']);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function assign(Opportunity $record, string $taskId, User $subject): void
|
||||
{
|
||||
abort_unless(DB::table('tasks')->where('id', $taskId)->where('source_system', 'opportunity-workflow')
|
||||
->where('external_id', 'like', 'portal-opportunity:'.$record->id.':%')->whereIn('status', ['open', 'in_progress'])->exists(), 409, 'Tarefa não pertence à versão aberta.');
|
||||
DB::table('tasks')->where('id', $taskId)->update(['assignee_user_id' => $subject->id, 'status' => 'in_progress', 'version_no' => DB::raw('version_no+1')]);
|
||||
}
|
||||
|
||||
public function complete(?string $taskId, string $outcome): void
|
||||
{
|
||||
if ($taskId) {
|
||||
DB::table('tasks')->where('id', $taskId)->where('source_system', 'opportunity-workflow')->whereIn('status', ['open', 'in_progress'])
|
||||
->update(['status' => 'done', 'outcome' => $outcome, 'completed_at' => now(), 'version_no' => DB::raw('version_no+1')]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<\stdClass> */
|
||||
public function listing(Opportunity $record): array
|
||||
{
|
||||
return array_values(DB::table('tasks')->where('source_system', 'opportunity-workflow')->where('external_id', 'like', 'portal-opportunity:'.$record->id.':%')
|
||||
->get(['id', 'task_type', 'status', 'title', 'assignee_user_id', 'due_at', 'completed_at', 'outcome'])->all());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PortalProfileAccess
|
||||
{
|
||||
public const VERSION = 'profiles-v1-20261010';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function fingerprintFiles(): array
|
||||
{
|
||||
return [__FILE__, app_path('Models/User.php'), app_path('Models/Opportunity.php'), config_path('portal.php'), base_path('routes/web.php'),
|
||||
app_path('Http/Middleware/HandleInertiaRequests.php'), app_path('Services/OpportunityManagement.php'),
|
||||
app_path('Services/OpportunityResources.php'), app_path('Services/OpportunityImageProcessor.php'),
|
||||
app_path('Services/PortalProfileAdministration.php'), app_path('Services/PortalSuperusers.php'),
|
||||
app_path('Services/OpportunityProfileWorkflow.php'), app_path('Http/Controllers/PortalProfileController.php'), app_path('Http/Controllers/DashboardController.php'),
|
||||
app_path('Http/Controllers/OpportunityManagementController.php'),
|
||||
app_path('Services/OpportunityCanonicalContract.php'), app_path('Services/OpportunityCategoryFields.php'), app_path('Services/OpportunityWorkflowTasks.php'), base_path('composer.json'), base_path('composer.lock'),
|
||||
resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/components/opportunity-canonical-fields.tsx'), resource_path('js/components/opportunity-parcel-geometry.tsx'),
|
||||
app_path('Http/Controllers/OpportunityResourceController.php'),
|
||||
app_path('Http/Controllers/InvestmentTerritoryController.php'),
|
||||
app_path('Http/Middleware/PortalProfileSession.php'), app_path('Http/Middleware/ProtectProfileOpportunity.php'),
|
||||
app_path('Http/Middleware/ProtectPrivilegedIdentity.php'), app_path('Http/Middleware/EnsurePortalProfiles.php'),
|
||||
app_path('Http/Middleware/AuditProfileDecision.php'), app_path('Providers/AppServiceProvider.php'),
|
||||
database_path('migrations/2026_10_10_040000_create_portal_profile_authorization.php'), base_path('bootstrap/app.php'),
|
||||
resource_path('js/pages/profiles/request.tsx'), resource_path('js/pages/profiles/administration.tsx'), resource_path('js/pages/profiles/audit.tsx'),
|
||||
resource_path('js/components/profile-navigation.tsx'), resource_path('js/components/opportunity-profile-actions.tsx'),
|
||||
resource_path('js/pages/management/opportunities.tsx'), resource_path('js/pages/management/opportunity-draft.tsx'),
|
||||
resource_path('js/types/profile-access.ts'), resource_path('js/lib/profile-json.ts'), resource_path('js/pages/management/shared.tsx'),
|
||||
app_path('Services/PortalProfileInstallation.php'), app_path('Http/Controllers/PortalProfileInstallationController.php'), config_path('portal_profiles.php'), resource_path('js/pages/profiles/installation.tsx')];
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return config('portal.profile_access_enabled') && Schema::hasTable('portal_role_assignments')
|
||||
&& Schema::hasTable('portal_superuser_pair') && Schema::hasTable('portal_profile_requests')
|
||||
&& Schema::hasTable('portal_security_approvals') && Schema::hasColumn('users', 'portal_access_revision')
|
||||
&& Schema::hasColumn('opportunities', 'workflow_context') && Schema::hasColumn('opportunities', 'owner_user_id')
|
||||
&& Schema::hasColumn('opportunities', 'organization_id');
|
||||
}
|
||||
|
||||
/** @return list<\stdClass> */
|
||||
public function grants(User $actor, ?string $role = null): array
|
||||
{
|
||||
if (! $this->available() || ! $actor->hasVerifiedEmail()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(DB::table('portal_role_assignments')->where('user_id', $actor->id)->whereNull('revoked_at')->where('starts_at', '<=', now())
|
||||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->when($role, fn ($q) => $q->where('role', $role))->get()->all());
|
||||
}
|
||||
|
||||
public function has(User $actor, string $role): bool
|
||||
{
|
||||
return $role === 'superuser' ? $this->superuser($actor) : $this->grants($actor, $role) !== [];
|
||||
}
|
||||
|
||||
public function superuser(User $actor): bool
|
||||
{
|
||||
return $this->available() && $actor->hasVerifiedEmail() && DB::table('portal_superuser_pair')->where('id', 1)
|
||||
->where(fn ($q) => $q->where('first_user_id', $actor->id)->orWhere('second_user_id', $actor->id))->exists();
|
||||
}
|
||||
|
||||
public function represents(User $actor, ?string $organization): bool
|
||||
{
|
||||
return $organization === null || DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')
|
||||
->where('m.user_id', $actor->id)->where('m.organization_id', $organization)
|
||||
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->exists();
|
||||
}
|
||||
|
||||
public function mfa(User $actor): bool
|
||||
{
|
||||
// Enrollment alone is insufficient: Fortify must have completed MFA in this session.
|
||||
return $actor->two_factor_confirmed_at !== null && $actor->two_factor_secret !== null
|
||||
&& session('login.id') === null && session('portal.mfa_actor') === $actor->id
|
||||
&& (int) session('portal.mfa_at', 0) >= time() - 900;
|
||||
}
|
||||
|
||||
public function scoped(User $actor, string $role, ?string $organization, string $type): bool
|
||||
{
|
||||
foreach ($this->grants($actor, $role) as $grant) {
|
||||
if (($grant->organization_id === $organization || ($role !== 'promoter' && $grant->organization_id === null))
|
||||
&& in_array($type, json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR), true)) {
|
||||
return $role !== 'promoter' || $this->represents($actor, $organization);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function operational(User $actor, string $action, ?int $recordId = null): bool
|
||||
{
|
||||
if ($this->has($actor, 'auditor')) {
|
||||
return false;
|
||||
}
|
||||
if (! $this->superuser($actor)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DB::table('portal_security_approvals')->where('kind', 'elevation')->where('subject_user_id', $actor->id)
|
||||
->whereNotNull('approved_by')->whereNull('consumed_at')->where('expires_at', '>', now())
|
||||
->whereExists(fn ($q) => $q->selectRaw('1')->from('portal_superuser_pair')
|
||||
->where(fn ($holders) => $holders->whereColumn('first_user_id', 'portal_security_approvals.approved_by')->orWhereColumn('second_user_id', 'portal_security_approvals.approved_by')))
|
||||
->where('scope->action', $action)->where('scope->record_id', $recordId)->exists();
|
||||
}
|
||||
|
||||
public function canCreate(User $actor, ?string $organization, string $type): bool
|
||||
{
|
||||
return $this->available() && $this->scoped($actor, 'promoter', $organization, $type) && $this->operational($actor, 'create');
|
||||
}
|
||||
|
||||
public function legacyWriteAllowed(User $actor): bool
|
||||
{
|
||||
// A deliberate v1 assignment cannot use the old allowlist to bypass its scope.
|
||||
if (! $actor->can('manage-portal')) {
|
||||
return false;
|
||||
}
|
||||
if (! Schema::hasTable('portal_role_assignments')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Revocation/expiry must not silently restore an older, wider privilege.
|
||||
return ! DB::table('portal_role_assignments')->where('user_id', $actor->id)->exists()
|
||||
&& ! DB::table('portal_superuser_pair')->where(fn ($q) => $q->where('first_user_id', $actor->id)->orWhere('second_user_id', $actor->id))->exists()
|
||||
&& ! DB::table('portal_security_approvals')->where('kind', 'replacement')->whereNotNull('consumed_at')->where('scope->old_user_id', $actor->id)->exists();
|
||||
}
|
||||
|
||||
public function canView(User $actor, Opportunity $record): bool
|
||||
{
|
||||
if ($record->owner_user_id === null) {
|
||||
return $actor->can('manage-portal');
|
||||
}
|
||||
if (! $this->available() || ! $actor->hasVerifiedEmail()) {
|
||||
return false;
|
||||
}
|
||||
if ($this->has($actor, 'auditor') || $this->superuser($actor)) {
|
||||
return true;
|
||||
}
|
||||
$personal = $record->organization_id !== null || (int) $record->owner_user_id === $actor->id;
|
||||
|
||||
return ($personal && $this->scoped($actor, 'promoter', $record->organization_id, $record->opportunity_category))
|
||||
|| $this->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category)
|
||||
|| $this->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category);
|
||||
}
|
||||
|
||||
public function canEdit(User $actor, Opportunity $record, ?string $newType = null): bool
|
||||
{
|
||||
if ($record->owner_user_id === null) {
|
||||
return $this->legacyWriteAllowed($actor);
|
||||
}
|
||||
|
||||
return $this->canView($actor, $record) && ! $record->trashed()
|
||||
&& in_array($this->state($record), ['draft', 'needs_information'], true)
|
||||
&& $this->scoped($actor, 'promoter', $record->organization_id, $newType ?? $record->opportunity_category)
|
||||
&& $this->operational($actor, 'edit', $record->id);
|
||||
}
|
||||
|
||||
public function conflict(User $actor, Opportunity $record): bool
|
||||
{
|
||||
$context = $record->workflow_context ?? [];
|
||||
|
||||
return (int) $record->owner_user_id === $actor->id || in_array($actor->id, $context['contributors'] ?? [], true)
|
||||
|| ($record->organization_id !== null && DB::table('memberships')->where('user_id', $actor->id)
|
||||
->where('organization_id', $record->organization_id)->whereNull('deleted_at')->exists());
|
||||
}
|
||||
|
||||
public function state(Opportunity $record): string
|
||||
{
|
||||
return $record->workflow_context['working_state'] ?? $record->status;
|
||||
}
|
||||
|
||||
public function authorize(User $actor, string $action, Opportunity $record): void
|
||||
{
|
||||
$allowed = $action === 'view' ? $this->canView($actor, $record) : $this->canEdit($actor, $record);
|
||||
if (! $allowed) {
|
||||
$this->audit($actor, 'access_denied', ['record_id' => $record->id, 'action' => $action]);
|
||||
}
|
||||
abort_unless($allowed, 403, 'O perfil, representação, tipo ou estado não autoriza esta operação.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Opportunity> $query
|
||||
* @return Builder<Opportunity>
|
||||
*/
|
||||
public function scope(Builder $query, User $actor): Builder
|
||||
{
|
||||
if ($this->has($actor, 'auditor') || $this->superuser($actor)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function ($scope) use ($actor) {
|
||||
$scope->whereRaw('1=0');
|
||||
if ($actor->can('manage-portal')) {
|
||||
$scope->orWhereNull('owner_user_id');
|
||||
}
|
||||
foreach ($this->grants($actor) as $grant) {
|
||||
if ($grant->role === 'promoter' && ! $this->represents($actor, $grant->organization_id)) {
|
||||
continue;
|
||||
}
|
||||
$scope->orWhere(function ($allowed) use ($actor, $grant) {
|
||||
$allowed->whereNotNull('owner_user_id')->whereIn('opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR));
|
||||
if ($grant->organization_id !== null) {
|
||||
$allowed->where('organization_id', $grant->organization_id);
|
||||
} elseif ($grant->role === 'promoter') {
|
||||
$allowed->whereNull('organization_id')->where('owner_user_id', $actor->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function navigation(?User $actor): ?array
|
||||
{
|
||||
if (! $actor || ! $this->available() || ! $actor->hasVerifiedEmail()) {
|
||||
return null;
|
||||
}
|
||||
$contexts = [];
|
||||
foreach ($this->grants($actor, 'promoter') as $grant) {
|
||||
if (! $this->represents($actor, $grant->organization_id)) {
|
||||
continue;
|
||||
}
|
||||
foreach (json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR) as $type) {
|
||||
if ($this->canCreate($actor, $grant->organization_id, $type)) {
|
||||
$contexts[] = ['organization_id' => $grant->organization_id, 'type' => $type];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['contexts' => $contexts, 'mine' => $contexts ? '/minhas-oportunidades' : null, 'create' => $contexts ? '/minhas-oportunidades/create' : null,
|
||||
'request' => '/perfil/promotor', 'review' => $this->has($actor, 'validator') || $this->has($actor, 'publisher') || $this->has($actor, 'auditor') || $this->superuser($actor) ? '/minhas-oportunidades' : null,
|
||||
'audit' => $this->has($actor, 'auditor') || $this->superuser($actor) ? '/perfis/auditoria' : null, 'administration' => $this->superuser($actor) ? '/perfis/gestao' : null];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function audit(User $actor, string $action, array $data): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-profiles-v1', 'entity_type' => 'authorization', 'actor_user_id' => $actor->id,
|
||||
'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
|
||||
'after_data' => json_encode([...$data, 'policy_version' => self::VERSION, 'origin_ip' => request()->ip()], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
|
||||
public function revokeSessions(User $subject): void
|
||||
{
|
||||
DB::table('users')->where('id', $subject->id)->increment('portal_access_revision');
|
||||
// Invalidate remember-me as well; no password or MFA changes.
|
||||
DB::table('users')->where('id', $subject->id)->update(['remember_token' => null]);
|
||||
if (Schema::hasTable('sessions')) {
|
||||
DB::table('sessions')->where('user_id', $subject->id)->delete();
|
||||
}
|
||||
if (Schema::hasTable('personal_access_tokens')) {
|
||||
DB::table('personal_access_tokens')->where('tokenable_type', User::class)->where('tokenable_id', $subject->id)->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PortalProfileAdministration
|
||||
{
|
||||
public function __construct(private PortalProfileAccess $access) {}
|
||||
|
||||
public function administrative(User $actor): void
|
||||
{
|
||||
abort_unless($this->access->superuser($actor) && $this->access->mfa($actor), 403, 'Superutilizador com MFA recente necessário.');
|
||||
abort_if($this->access->has($actor, 'auditor'), 403, 'A restrição de leitura do Auditor prevalece.');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function grant(User $actor, array $input): string
|
||||
{
|
||||
$this->administrative($actor);
|
||||
$data = Validator::make($input, ['user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher', 'auditor'])],
|
||||
'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
|
||||
'responsibilities' => 'sometimes|array', 'responsibilities.*' => ['required', Rule::in(['validation_coordinator', 'technical_reviewer', 'legal_reviewer', 'appeal_reviewer', 'publication_coordinator'])],
|
||||
'expires_at' => 'nullable|date|after:now', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
||||
abort_if((int) $data['user_id'] === $actor->id, 403, 'Não pode aprovar a sua atribuição.');
|
||||
abort_if($data['role'] === 'promoter', 422, 'Promotor exige pedido e revisão documental independente.');
|
||||
$responsibilities = match ($data['role']) {
|
||||
'validator' => ['validation_coordinator', 'technical_reviewer', 'legal_reviewer', 'appeal_reviewer'],
|
||||
'publisher' => ['publication_coordinator'],
|
||||
default => [],
|
||||
};
|
||||
abort_unless(array_diff($data['responsibilities'] ?? [], $responsibilities) === [], 422, 'Responsabilidade incompatível com o papel.');
|
||||
$subject = User::whereKey($data['user_id'])->firstOrFail();
|
||||
abort_unless($subject->hasVerifiedEmail(), 422);
|
||||
if (in_array($data['role'], ['validator', 'publisher'], true)) {
|
||||
abort_unless($subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null, 422, 'Configure e confirme MFA antes de atribuir este papel.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $data) {
|
||||
$id = (string) Str::uuid();
|
||||
$this->insertGrant($id, $actor, $data);
|
||||
$this->access->audit($actor, 'role_granted', ['assignment_id' => $id, 'subject_user_id' => $data['user_id'], 'role' => $data['role'], 'reason' => $data['reason']]);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function insertGrant(string $id, User $approver, array $data): void
|
||||
{
|
||||
DB::table('portal_role_assignments')->insert(['id' => $id, 'user_id' => $data['user_id'], 'role' => $data['role'], 'organization_id' => $data['organization_id'] ?? null,
|
||||
'opportunity_types' => json_encode(array_values(array_unique($data['opportunity_types'])), JSON_THROW_ON_ERROR), 'responsibilities' => json_encode($data['responsibilities'] ?? [], JSON_THROW_ON_ERROR),
|
||||
'source' => $data['source'] ?? 'independent-administration', 'request_id' => $data['request_id'] ?? null, 'approved_by' => $approver->id,
|
||||
'starts_at' => now(), 'expires_at' => $data['expires_at'] ?? null, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
public function revoke(User $actor, string $id, string $reason): void
|
||||
{
|
||||
$this->administrative($actor);
|
||||
Validator::make(['reason' => $reason], ['reason' => 'required|string|min:20|max:1000'])->validate();
|
||||
DB::transaction(function () use ($actor, $id, $reason) {
|
||||
$row = DB::table('portal_role_assignments')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($row !== null, 404);
|
||||
if ($row->revoked_at !== null) {
|
||||
return;
|
||||
}
|
||||
DB::table('portal_role_assignments')->where('id', $id)->update(['revoked_at' => now(), 'updated_at' => now()]);
|
||||
$this->access->revokeSessions(User::whereKey($row->user_id)->firstOrFail());
|
||||
$this->access->audit($actor, 'role_revoked', ['assignment_id' => $id, 'subject_user_id' => $row->user_id, 'reason' => $reason]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function request(User $actor, array $input): string
|
||||
{
|
||||
abort_unless($this->access->available() && $actor->hasVerifiedEmail(), 403);
|
||||
$data = Validator::make($input, ['creation_key' => 'required|uuid', 'representation_type' => 'required|in:individual,company,public_entity,association,partner',
|
||||
'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
|
||||
'document_ids' => 'required|array|min:1', 'document_ids.*' => 'required|uuid|exists:documents,id', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
||||
$organization = $data['organization_id'] ?? null;
|
||||
abort_unless(($data['representation_type'] === 'individual') === ($organization === null), 422, 'Pessoa singular usa âmbito próprio; as entidades exigem organização.');
|
||||
abort_unless($this->access->represents($actor, $organization), 403, 'Representação ativa de escrita necessária.');
|
||||
foreach ($data['document_ids'] as $id) {
|
||||
abort_unless(DB::table('documents')->where('id', $id)->where('uploaded_by', $actor->id)->whereNull('deleted_at')->where('status', 'active')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists(), 422, 'Comprovativo próprio, íntegro e analisado necessário.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $data, $organization) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(101026, 40000)');
|
||||
$existing = DB::table('portal_profile_requests')->where('creation_key', $data['creation_key'])->first();
|
||||
if ($existing) {
|
||||
abort_unless((int) $existing->user_id === $actor->id, 403);
|
||||
abort_unless($existing->organization_id === $organization && $existing->representation_type === $data['representation_type']
|
||||
&& json_decode($existing->opportunity_types, true) === $data['opportunity_types'] && json_decode($existing->document_ids, true) === $data['document_ids'] && $existing->reason === $data['reason'], 409, 'Chave já usada com outro pedido.');
|
||||
|
||||
return $existing->id;
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('portal_profile_requests')->insert(['id' => $id, 'user_id' => $actor->id, 'organization_id' => $organization,
|
||||
'representation_type' => $data['representation_type'], 'opportunity_types' => json_encode($data['opportunity_types'], JSON_THROW_ON_ERROR),
|
||||
'document_ids' => json_encode($data['document_ids'], JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'creation_key' => $data['creation_key'], 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->access->audit($actor, 'promoter_requested', ['request_id' => $id, 'types' => $data['opportunity_types']]);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function decide(User $actor, string $id, array $input): void
|
||||
{
|
||||
$this->administrative($actor);
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:20|max:1000',
|
||||
'verified_identity' => 'required|boolean|accepted_if:decision,approved', 'verified_representation' => 'required|boolean|accepted_if:decision,approved', 'verified_documents' => 'required|boolean|accepted_if:decision,approved',
|
||||
'approved_types' => 'present|array', 'approved_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)], 'expires_at' => 'nullable|date|after:now'])->validate();
|
||||
DB::transaction(function () use ($actor, $id, $data) {
|
||||
$row = DB::table('portal_profile_requests')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($row !== null, 404);
|
||||
abort_if((int) $row->user_id === $actor->id, 403, 'Não pode aprovar o seu pedido.');
|
||||
abort_unless($row->status === 'pending' && (int) $row->version === (int) $data['version'], 409);
|
||||
$requested = json_decode($row->opportunity_types, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(array_diff($data['approved_types'], $requested) === [], 422, 'Só podem ser aprovados tipos pedidos.');
|
||||
$subject = User::whereKey($row->user_id)->firstOrFail();
|
||||
if ($data['decision'] === 'approved') {
|
||||
abort_unless($this->access->represents($subject, $row->organization_id), 403, 'Representação entretanto revogada.');
|
||||
foreach (json_decode($row->document_ids, true) as $document) {
|
||||
abort_unless(DB::table('documents')->where('id', $document)->where('uploaded_by', $subject->id)->whereNull('deleted_at')->where('status', 'active')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists(), 422, 'Comprovativo entretanto indisponível.');
|
||||
}
|
||||
}
|
||||
if ($data['decision'] === 'approved') {
|
||||
abort_unless($data['approved_types'] !== [], 422, 'Escolha ao menos um tipo.');
|
||||
$this->insertGrant((string) Str::uuid(), $actor, ['user_id' => $subject->id, 'role' => 'promoter', 'organization_id' => $row->organization_id,
|
||||
'opportunity_types' => $data['approved_types'], 'request_id' => $id, 'source' => 'documented-promoter-request', 'expires_at' => $data['expires_at'] ?? null]);
|
||||
}
|
||||
DB::table('portal_profile_requests')->where('id', $id)->update(['status' => $data['decision'], 'version' => $row->version + 1, 'decided_by' => $actor->id,
|
||||
'decision_reason' => $data['reason'], 'decided_at' => now(), 'updated_at' => now()]);
|
||||
$this->access->audit($actor, 'promoter_request_decided', ['request_id' => $id, 'decision' => $data['decision'], 'approved_types' => $data['approved_types'], 'reason' => $data['reason'], 'checks' => array_intersect_key($data, array_flip(['verified_identity', 'verified_representation', 'verified_documents']))]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/** Additive installation and separate, explicit bootstrap. No identities are inferred. */
|
||||
class PortalProfileInstallation
|
||||
{
|
||||
public const MIGRATION = '2026_10_10_040000_create_portal_profile_authorization';
|
||||
|
||||
public const TABLES = ['portal_profile_requests', 'portal_role_assignments', 'portal_superuser_pair', 'portal_security_approvals'];
|
||||
|
||||
public function __construct(private PortalProfileAccess $access) {}
|
||||
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$files = [...PortalProfileAccess::fingerprintFiles(), __FILE__, config_path('portal_profiles.php'),
|
||||
app_path('Http/Controllers/PortalProfileInstallationController.php'), resource_path('js/pages/profiles/installation.tsx')];
|
||||
|
||||
return hash('sha256', json_encode(array_map(fn ($path) => hash_file('sha256', $path), array_unique($files)), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function authorize(User $actor, bool $write = false): void
|
||||
{
|
||||
abort_unless($actor->hasVerifiedEmail(), 403);
|
||||
if (Schema::hasTable('portal_role_assignments')) {
|
||||
abort_if(DB::table('portal_role_assignments')->where('user_id', $actor->id)->where('role', 'auditor')->whereNull('revoked_at')
|
||||
->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists(), 403, 'A restrição do Auditor prevalece.');
|
||||
}
|
||||
$pair = Schema::hasTable('portal_superuser_pair') ? DB::table('portal_superuser_pair')->where('id', 1)->first() : null;
|
||||
abort_unless($pair ? in_array($actor->id, [(int) $pair->first_user_id, (int) $pair->second_user_id], true) : $actor->can('manage-portal'), 403, 'Gestor de instalação ou titular da dupla necessário.');
|
||||
if ($write) {
|
||||
abort_unless($this->access->mfa($actor), 403, 'Conclua MFA nesta sessão; configuração de MFA isoladamente não autoriza a operação.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(User $actor): array
|
||||
{
|
||||
$this->authorize($actor);
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
|
||||
$schema = [];
|
||||
foreach (self::TABLES as $table) {
|
||||
$schema[$table] = Schema::hasTable($table);
|
||||
}
|
||||
foreach (['users' => ['portal_access_revision'], 'opportunities' => ['owner_user_id', 'organization_id', 'workflow_context']] as $table => $columns) {
|
||||
foreach ($columns as $column) {
|
||||
$schema[$table.'.'.$column] = Schema::hasColumn($table, $column);
|
||||
}
|
||||
}
|
||||
$all = ! in_array(false, $schema, true);
|
||||
$none = ! in_array(true, $schema, true);
|
||||
$consistent = ($ledger && $all) || (! $ledger && $none);
|
||||
$installed = $ledger && $all;
|
||||
$dependencies = [
|
||||
'management_020000' => DB::table('migrations')->where('migration', '2026_10_09_020000_extend_opportunity_management')->exists() && Schema::hasColumn('opportunities', 'management_draft'),
|
||||
'resources_030000' => DB::table('migrations')->where('migration', '2026_10_09_030000_extend_opportunity_resources')->exists()
|
||||
&& Schema::hasTable('opportunity_documents') && Schema::hasTable('opportunity_images') && Schema::hasTable('opportunity_location_drafts') && Schema::hasColumn('documents', 'storage_encryption'),
|
||||
'existing_identity_organization_audit' => Schema::hasTable('users') && Schema::hasTable('organizations') && Schema::hasTable('memberships') && Schema::hasTable('audit_events'),
|
||||
];
|
||||
$code = $this->fingerprint();
|
||||
$proofPath = config('portal_profiles.proof_directory').'/readiness.json';
|
||||
$proofBytes = is_file($proofPath) ? file_get_contents($proofPath) : false;
|
||||
$proof = is_string($proofBytes) ? json_decode($proofBytes, true) : [];
|
||||
$proof = is_array($proof) ? $proof : [];
|
||||
$fresh = isset($proof['verified_at']) && strtotime($proof['verified_at']) >= time() - 86400;
|
||||
$evidence = $fresh && ($proof['code_hash'] ?? '') === $code && ($proof['target']['database'] ?? '') === $identity->database
|
||||
&& (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false)
|
||||
&& ($proof['compatibility_verified'] ?? false) && ($proof['qa_passed'] ?? false)
|
||||
&& is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['dump_sha256'] ?? null)
|
||||
&& is_file($proof['files_path'] ?? '') && hash_file('sha256', $proof['files_path']) === ($proof['files_sha256'] ?? null);
|
||||
$ready = $consistent && ! in_array(false, $dependencies, true) && $evidence;
|
||||
$pair = $installed ? DB::table('portal_superuser_pair')->where('id', 1)->first() : null;
|
||||
$bootstrap = ['initialized' => $pair !== null, 'holders' => $pair ? [(int) $pair->first_user_id, (int) $pair->second_user_id] : [], 'version' => $pair?->version,
|
||||
'rule' => 'Escolher explicitamente duas contas confirmadas com MFA; um terceiro gestor com MFA aprova. Sem escolha automática, sem alteração de papéis legados.'];
|
||||
$plan = ['target' => $identity, 'code_hash' => $code, 'installed' => $installed, 'consistent' => $consistent, 'ledger' => $ledger, 'schema' => $schema,
|
||||
'dependencies' => $dependencies, 'ready' => $ready, 'backupDate' => $proof['verified_at'] ?? null, 'bootstrap' => $bootstrap,
|
||||
'path_190000_installed' => DB::table('migrations')->where('migration', '2026_10_09_190000_prepare_investor_path')->exists(),
|
||||
'migration' => self::MIGRATION, 'changes' => ['Quatro tabelas, uma coluna em users e três em opportunities; zero contas e zero atribuições automáticas.',
|
||||
'030000 precede esta ação; 190000 continua etapa posterior do percurso, sem dependência DDL desta migração.', 'Nenhuma flag ativada, dado publicado, titular escolhido ou privilégio legado removido.'],
|
||||
'rollback' => 'DDL e ledger são atómicos em PostgreSQL. Se vazio, down deve remover todas as colunas adicionadas; se houver uso/histórico, preservar e restaurar backup verificado, sem rollback destrutivo.'];
|
||||
$plan['hash'] = hash('sha256', json_encode($plan, JSON_THROW_ON_ERROR));
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function apply(User $actor, array $input): array
|
||||
{
|
||||
$this->authorize($actor, true);
|
||||
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
||||
|
||||
return $this->receipt($actor, 'install', $data, function () use ($actor, $data) {
|
||||
$p = $this->preview($actor);
|
||||
abort_unless(hash_equals($p['hash'], $data['hash']), 409, 'Plano alterado; consulte a pré-visualização.');
|
||||
abort_unless($p['consistent'], 409, 'Ledger/schema incoerentes; recuperar antes de instalar.');
|
||||
if ($p['installed']) {
|
||||
return ['installed' => true, 'changed' => false, 'migration' => self::MIGRATION, 'bootstrap_required' => ! $p['bootstrap']['initialized'], 'flags_changed' => false];
|
||||
}
|
||||
abort_unless($p['ready'], 422, 'Dependências e provas atuais de backup/restauro/QA são necessárias.');
|
||||
config(['portal_profiles.authenticated_installation' => true]);
|
||||
try {
|
||||
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'A instalação falhou; a transação é revertida.');
|
||||
} finally {
|
||||
config(['portal_profiles.authenticated_installation' => false]);
|
||||
}
|
||||
$after = $this->preview($actor);
|
||||
abort_unless($after['installed'] && $after['consistent'], 500, 'Verificação pós-instalação falhou; reverter transação.');
|
||||
|
||||
return ['installed' => true, 'changed' => true, 'migration' => self::MIGRATION, 'bootstrap_required' => true, 'flags_changed' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function bootstrap(User $actor, array $input): array
|
||||
{
|
||||
$this->authorize($actor, true);
|
||||
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid',
|
||||
'first_user_id' => 'required|integer|exists:users,id', 'second_user_id' => 'required|integer|different:first_user_id|exists:users,id', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
||||
|
||||
return $this->receipt($actor, 'bootstrap', $data, function () use ($actor, $data) {
|
||||
$p = $this->preview($actor);
|
||||
abort_unless(hash_equals($p['hash'], $data['hash']), 409, 'Plano alterado.');
|
||||
abort_unless($p['installed'] && ! $p['bootstrap']['initialized'], 409, 'Inicialização exige schema instalado e dupla ainda não designada. Use substituição para uma dupla existente.');
|
||||
config(['portal_profiles.bootstrap_authorized' => true]);
|
||||
try {
|
||||
app(PortalSuperusers::class)->initialize(User::query()->whereKey($data['first_user_id'])->firstOrFail(), User::query()->whereKey($data['second_user_id'])->firstOrFail(), $actor);
|
||||
} finally {
|
||||
config(['portal_profiles.bootstrap_authorized' => false]);
|
||||
}
|
||||
|
||||
return ['initialized' => true, 'holders' => [$data['first_user_id'], $data['second_user_id']], 'version' => 1, 'flags_changed' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @param callable():array<string,mixed> $operation
|
||||
* @return array<string,mixed> */
|
||||
private function receipt(User $actor, string $scope, array $data, callable $operation): array
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $scope, $data, $operation) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(101026, 40000)');
|
||||
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
$previous = DB::table('audit_events')->where('source_system', 'portal-profile-installation')->where('action', $scope)->where('actor_user_id', $actor->id)->where('correlation_id', $data['idempotency_key'])->first();
|
||||
if ($previous) {
|
||||
$receipt = json_decode($previous->after_data, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave de repetição reutilizada com outro conteúdo.');
|
||||
|
||||
return $receipt['result'];
|
||||
}
|
||||
$result = $operation();
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-profile-installation', 'entity_type' => 'installation', 'actor_user_id' => $actor->id, 'action' => $scope,
|
||||
'correlation_id' => $data['idempotency_key'], 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'reason' => $data['reason'], 'result' => $result, 'code_hash' => $this->fingerprint()], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PortalSuperusers
|
||||
{
|
||||
public function __construct(private PortalProfileAccess $access, private PortalProfileAdministration $administration) {}
|
||||
|
||||
public function initialize(User $first, User $second, User $approver): void
|
||||
{
|
||||
// Bootstrap requires a separately authorized ceremony; never infer real holders.
|
||||
abort_unless(config('portal_profiles.bootstrap_authorized') === true, 403);
|
||||
abort_unless(count(array_unique([$first->id, $second->id, $approver->id])) === 3, 422);
|
||||
foreach ([$first, $second, $approver] as $user) {
|
||||
abort_unless($user->hasVerifiedEmail() && $user->two_factor_secret && $user->two_factor_confirmed_at, 422, 'Contas confirmadas com MFA necessário.');
|
||||
try {
|
||||
$secret = Crypt::decryptString($user->two_factor_secret);
|
||||
} catch (DecryptException) {
|
||||
abort(422, 'Configuração MFA incompatível com este ambiente. Recuperar a conta pelo procedimento existente antes da inicialização.');
|
||||
}
|
||||
abort_unless(strlen($secret) >= 16 && preg_match('/^[A-Z2-7]+=*$/', $secret), 422, 'Configuração MFA inválida.');
|
||||
abort_if(DB::table('portal_role_assignments')->where('user_id', $user->id)->where('role', 'auditor')->whereNull('revoked_at')
|
||||
->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists(), 422, 'A restrição do Auditor impede a designação de titular nesta cerimónia.');
|
||||
}
|
||||
abort_unless($this->access->mfa($approver), 403);
|
||||
DB::transaction(function () use ($first, $second, $approver) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(101026, 40001)');
|
||||
$existing = DB::table('portal_superuser_pair')->where('id', 1)->first();
|
||||
if ($existing) {
|
||||
abort_unless((int) $existing->first_user_id === $first->id && (int) $existing->second_user_id === $second->id, 409, 'Inicialização já realizada. Use substituição atómica.');
|
||||
|
||||
return;
|
||||
}
|
||||
DB::table('portal_superuser_pair')->insert(['id' => 1, 'first_user_id' => $first->id, 'second_user_id' => $second->id, 'approved_by' => $approver->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->access->audit($approver, 'superuser_pair_initialized', ['holders' => [$first->id, $second->id], 'version' => 1]);
|
||||
});
|
||||
}
|
||||
|
||||
public function requestReplacement(User $actor, int $oldId, int $newId, string $reason): string
|
||||
{
|
||||
$this->administration->administrative($actor);
|
||||
abort_unless(mb_strlen($reason) >= 20 && mb_strlen($reason) <= 1000 && $oldId !== $newId, 422);
|
||||
|
||||
return DB::transaction(function () use ($actor, $oldId, $newId, $reason) {
|
||||
$pair = DB::table('portal_superuser_pair')->where('id', 1)->lockForUpdate()->first();
|
||||
$holders = [(int) $pair->first_user_id, (int) $pair->second_user_id];
|
||||
abort_unless(in_array($oldId, $holders, true) && ! in_array($newId, $holders, true), 422);
|
||||
$candidate = User::findOrFail($newId);
|
||||
abort_unless($candidate->hasVerifiedEmail() && $candidate->two_factor_secret && $candidate->two_factor_confirmed_at, 422);
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('portal_security_approvals')->insert(['id' => $id, 'kind' => 'replacement', 'requested_by' => $actor->id, 'subject_user_id' => $newId,
|
||||
'scope' => json_encode(['old_user_id' => $oldId, 'pair_version' => (int) $pair->version], JSON_THROW_ON_ERROR), 'reason' => $reason,
|
||||
'expires_at' => now()->addMinutes(15), 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->access->audit($actor, 'superuser_replacement_requested', ['approval_id' => $id, 'old_user_id' => $oldId, 'new_user_id' => $newId]);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
public function approve(User $actor, string $id): void
|
||||
{
|
||||
$this->administration->administrative($actor);
|
||||
DB::transaction(function () use ($actor, $id) {
|
||||
$row = DB::table('portal_security_approvals')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($row !== null, 404);
|
||||
abort_if((int) $row->requested_by === $actor->id || (int) $row->subject_user_id === $actor->id, 403, 'Aprovação independente necessária.');
|
||||
abort_unless($row->consumed_at === null && strtotime($row->expires_at) > time() && $row->approved_by === null, 409);
|
||||
DB::table('portal_security_approvals')->where('id', $id)->update(['approved_by' => $actor->id, 'updated_at' => now()]);
|
||||
$this->access->audit($actor, 'security_operation_approved', ['approval_id' => $id, 'kind' => $row->kind]);
|
||||
});
|
||||
}
|
||||
|
||||
public function applyReplacement(User $actor, string $id): void
|
||||
{
|
||||
$this->administration->administrative($actor);
|
||||
DB::transaction(function () use ($actor, $id) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(101026, 40001)');
|
||||
$pair = DB::table('portal_superuser_pair')->where('id', 1)->lockForUpdate()->first();
|
||||
$row = DB::table('portal_security_approvals')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($row && $row->kind === 'replacement' && (int) $row->requested_by === $actor->id, 403);
|
||||
abort_unless($row->approved_by !== null && $row->consumed_at === null && strtotime($row->expires_at) > time(), 409);
|
||||
$scope = json_decode($row->scope, true, 512, JSON_THROW_ON_ERROR);
|
||||
$holders = [(int) $pair->first_user_id, (int) $pair->second_user_id];
|
||||
abort_unless((int) $pair->version === $scope['pair_version'] && in_array((int) $row->approved_by, $holders, true)
|
||||
&& in_array($scope['old_user_id'], $holders, true) && ! in_array((int) $row->subject_user_id, $holders, true), 409, 'Dupla ou aprovação mudou.');
|
||||
$candidate = User::whereKey($row->subject_user_id)->firstOrFail();
|
||||
abort_unless($candidate->hasVerifiedEmail() && $candidate->two_factor_secret && $candidate->two_factor_confirmed_at, 422);
|
||||
$column = (int) $pair->first_user_id === $scope['old_user_id'] ? 'first_user_id' : 'second_user_id';
|
||||
// A single UPDATE preserves two distinct holders at every observable state.
|
||||
DB::table('portal_superuser_pair')->where('id', 1)->update([$column => $candidate->id, 'version' => $pair->version + 1, 'approved_by' => $row->approved_by, 'updated_at' => now()]);
|
||||
DB::table('portal_security_approvals')->where('id', $id)->update(['consumed_at' => now(), 'updated_at' => now()]);
|
||||
$this->access->revokeSessions(User::findOrFail($scope['old_user_id']));
|
||||
$this->access->audit($actor, 'superuser_replaced', ['approval_id' => $id, 'before' => $holders, 'after' => DB::table('portal_superuser_pair')->where('id', 1)->first(), 'alert' => 'Substituição de titular: verificar acessos e recuperação.']);
|
||||
});
|
||||
}
|
||||
|
||||
public function requestElevation(User $actor, string $action, ?int $recordId, string $reason): string
|
||||
{
|
||||
$this->administration->administrative($actor);
|
||||
abort_unless(in_array($action, ['create', 'edit', 'validate', 'publish', 'archive'], true) && mb_strlen($reason) >= 20 && mb_strlen($reason) <= 1000, 422);
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('portal_security_approvals')->insert(['id' => $id, 'kind' => 'elevation', 'requested_by' => $actor->id, 'subject_user_id' => $actor->id,
|
||||
'scope' => json_encode(['action' => $action, 'record_id' => $recordId], JSON_THROW_ON_ERROR), 'reason' => $reason,
|
||||
'expires_at' => now()->addMinutes(15), 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->access->audit($actor, 'elevation_requested', ['approval_id' => $id, 'action' => $action, 'record_id' => $recordId, 'reason' => $reason]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class TerritoryGeometryImport
|
||||
{
|
||||
public function fingerprint(): string
|
||||
{
|
||||
return hash('sha256', implode('', array_map(fn ($f) => hash_file('sha256', $f), [__FILE__, config_path('geometry-import.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), base_path('routes/web.php'), resource_path('js/pages/management/territory-geometry-import.tsx'),
|
||||
return hash('sha256', implode('', array_map(fn ($f) => hash_file('sha256', $f), [__FILE__, config_path('geometry-import.php'), config_path('geometry-correspondences.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), base_path('routes/web.php'), resource_path('js/pages/management/territory-geometry-import.tsx'),
|
||||
base_path('bootstrap/app.php'), app_path('Providers/FortifyServiceProvider.php'), config_path('fortify.php'),
|
||||
resource_path('js/pages/auth/login.tsx'), resource_path('js/pages/auth/two-factor-challenge.tsx')])));
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class TerritoryGeometryImport
|
||||
return;
|
||||
}
|
||||
$file = storage_path('app/private/investmoz-v2/geometry-import-activation-proof.json');
|
||||
$p = is_file($file) ? json_decode(file_get_contents($file), true) : [];
|
||||
$p = is_file($file) ? json_decode($this->readFile($file), true) : [];
|
||||
abort_unless(($p['restore_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['ui_passed'] ?? false)
|
||||
&& ($p['code_hash'] ?? '') === $this->fingerprint() && ($p['target']['database'] ?? '') === $target->database
|
||||
&& (int) ($p['target']['port'] ?? 0) === (int) $target->port && strtotime($p['verified_at'] ?? '') >= time() - 86400
|
||||
@@ -40,6 +40,7 @@ class TerritoryGeometryImport
|
||||
&& ($p['files_verified'] ?? false) && is_file($p['files_path'] ?? '') && hash_file('sha256', $p['files_path']) === ($p['files_sha256'] ?? null), 422, 'Backup/restauro e provas desta revisão e alvo necessários; preparação não autoriza ativação.');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(User $actor, int $level): array
|
||||
{
|
||||
$this->guard($actor);
|
||||
@@ -48,12 +49,15 @@ class TerritoryGeometryImport
|
||||
$file = $directory.'/moz-adm'.$level.'-ine-20190607.geojson';
|
||||
$metadata = $directory.'/moz-adm'.$level.'-ine-metadata.html';
|
||||
abort_unless(is_file($file) && is_file($metadata), 422, 'Exemplar privado indisponível.');
|
||||
$text = file_get_contents($metadata);
|
||||
$text = $this->readFile($metadata);
|
||||
abort_unless(str_contains($text, 'Mozambique INE - Instituto Nacional de Estatistica') && str_contains($text, '2019-06-07'), 422, 'Metadados do exemplar divergentes.');
|
||||
$sourceHash = hash_file('sha256', $file);
|
||||
$source = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
|
||||
$source = json_decode($this->readFile($file), true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(($source['type'] ?? '') === 'FeatureCollection' && count($source['features'] ?? []) === [1 => 11, 2 => 159, 3 => 411][$level] && ! ($source['exceededTransferLimit'] ?? false), 422, 'Exemplar incompleto.');
|
||||
$locations = DB::table('geo_locations')->where('location_type', [1 => 'province', 2 => 'district', 3 => 'admin_post'][$level])->whereNull('deleted_at')->get(['id', 'name', 'parent_id']);
|
||||
$locations = DB::table('geo_locations')->where('location_type', [1 => 'province', 2 => 'district', 3 => 'admin_post'][$level])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'source_system', 'source_code', 'external_id']);
|
||||
$crosswalk = config('geometry-correspondences');
|
||||
$correspondences = $crosswalk['rows'][$level] ?? [];
|
||||
abort_if($correspondences && (($crosswalk['edition'] ?? null) !== '2019-06-07' || ($crosswalk['source_sha256'][$level] ?? null) !== $sourceHash), 422, 'Correspondências não pertencem a este exemplar; nenhuma aproximação aplicada.');
|
||||
$normalize = fn ($n) => strtolower(trim(Str::ascii($n)));
|
||||
$countryNames = array_map($normalize, [$source['features'][0]['properties']['ADM0_PT'], $source['features'][0]['properties']['ADM0_EN']]);
|
||||
$countries = DB::table('geo_locations')->where('location_type', 'country')->whereNull('deleted_at')->get(['id', 'name'])->filter(fn ($c) => in_array($normalize($c->name), $countryNames, true))->values();
|
||||
@@ -66,9 +70,19 @@ class TerritoryGeometryImport
|
||||
$external = '2019-06-07:adm'.$level.':'.$p['ADM'.$level.'_PCODE'];
|
||||
$parent = $level > 1 ? DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('external_id', '2019-06-07:adm'.($level - 1).':'.$p['ADM'.($level - 1).'_PCODE'])->where('properties->source_sha256', $parentSourceHash)->whereNull('deleted_at')->value('geo_location_id') : $countryId;
|
||||
$matches = $locations->filter(fn ($l) => $normalize($l->name) === $normalize($p['ADM'.$level.'_PT']) && $parent && $l->parent_id === $parent)->values();
|
||||
$correspondence = $correspondences[$p['ADM'.$level.'_PCODE']] ?? null;
|
||||
$matchBasis = 'unique_exact_name_and_matched_parent_after_case_and_diacritics';
|
||||
if ($correspondence) {
|
||||
abort_unless($correspondence['source_name'] === $p['ADM'.$level.'_PT'] && $correspondence['parent_source_code'] === $p['ADM'.($level - 1).'_PCODE'], 422, 'Nome/pai do exemplar divergente da correspondência explícita.');
|
||||
$matches = $locations->filter(fn ($l) => $parent && $l->parent_id === $parent
|
||||
&& $normalize($l->name) === $normalize($correspondence['target_name']) && $l->source_code === $correspondence['target_source_code']
|
||||
&& $l->source_system === $crosswalk['catalogue_source_system'] && str_starts_with($l->external_id ?? '', $crosswalk['catalogue_sha256'].':'))->values();
|
||||
$matchBasis = 'explicit_versioned_correspondence';
|
||||
}
|
||||
$row = ['external_id' => $external, 'name' => $p['ADM'.$level.'_PT'], 'parent_name' => $level > 1 ? $p['ADM'.($level - 1).'_PT'] : null,
|
||||
'parent_code' => $level > 1 ? $p['ADM'.($level - 1).'_PCODE'] : null, 'candidates' => $matches->all(), 'location_id' => count($matches) === 1 ? $matches[0]->id : null,
|
||||
'status' => count($matches) === 1 ? 'insert' : (! $parent ? 'parent_unresolved' : 'name_unresolved')];
|
||||
'status' => count($matches) === 1 ? 'insert' : (count($matches) > 1 ? ($correspondence ? 'correspondence_conflict' : 'ambiguous_name') : (! $parent ? 'parent_unresolved' : 'name_unresolved')),
|
||||
'match_basis' => $matchBasis, 'correspondence' => $correspondence, 'correspondence_version' => $correspondence ? $crosswalk['version'] : null];
|
||||
if ($row['location_id']) {
|
||||
$geometry = json_encode($feature['geometry'], JSON_THROW_ON_ERROR);
|
||||
$valid = DB::selectOne('SELECT ST_IsValid(g) AND NOT ST_IsEmpty(g) AND ST_NDims(g)=2 AND GeometryType(g) IN (\'POLYGON\',\'MULTIPOLYGON\') AND ST_XMin(Box3D(g))>=-180 AND ST_XMax(Box3D(g))<=180 AND ST_YMin(Box3D(g))>=-90 AND ST_YMax(Box3D(g))<=90 AS valid FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) g) q', [$geometry]);
|
||||
@@ -91,7 +105,7 @@ class TerritoryGeometryImport
|
||||
$rows[] = $row;
|
||||
}
|
||||
$scanFile = $directory.'/geometry-source-scans.json';
|
||||
$scans = is_file($scanFile) ? json_decode(file_get_contents($scanFile), true) : [];
|
||||
$scans = is_file($scanFile) ? json_decode($this->readFile($scanFile), true) : [];
|
||||
$scan = null;
|
||||
foreach ($scans['files'] ?? [] as $s) {
|
||||
if ($s['file'] === basename($file) && $s['sha256'] === $sourceHash) {
|
||||
@@ -101,6 +115,7 @@ class TerritoryGeometryImport
|
||||
$scanReady = ($scan['result'] ?? '') === 'clean' && ($scan['engine'] ?? '') === 'ClamAV' && strtotime($scans['captured_at'] ?? '') >= time() - config('geometry-import.scan_max_age_seconds');
|
||||
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$plan = ['level' => $level, 'target' => $target, 'edition' => '2019-06-07', 'source_sha256' => $sourceHash, 'metadata_sha256' => hash_file('sha256', $metadata), 'code_hash' => $this->fingerprint(),
|
||||
'correspondence_version' => $crosswalk['version'], 'correspondence_sha256' => hash('sha256', json_encode($crosswalk, JSON_THROW_ON_ERROR)),
|
||||
'expected' => count($rows), 'resolved' => count(array_filter($rows, fn ($r) => in_array($r['status'], ['insert', 'reuse'], true))), 'rows' => $rows, 'scan_ready' => $scanReady, 'scan' => $scan, 'scan_at' => $scans['captured_at'] ?? null, 'published' => false];
|
||||
$plan['pending'] = $plan['expected'] - $plan['resolved'];
|
||||
$plan['hash'] = hash_hmac('sha256', json_encode([$actor->id, $plan], JSON_THROW_ON_ERROR), config('app.key'));
|
||||
@@ -108,6 +123,9 @@ class TerritoryGeometryImport
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function apply(User $actor, array $input): array
|
||||
{
|
||||
$this->guard($actor, true);
|
||||
@@ -118,12 +136,14 @@ class TerritoryGeometryImport
|
||||
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Correspondências, fonte ou estado alterados; repita a pré-visualização.');
|
||||
abort_unless($plan['scan_ready'], 423, 'Scan limpo atual ligado ao original necessário.');
|
||||
abort_if(count(array_filter($plan['rows'], fn ($r) => str_contains($r['status'], 'conflict') || $r['status'] === 'invalid_geometry')) > 0, 409, 'Conflito de identidade/geometria: nenhuma escrita aplicada.');
|
||||
$bytes = file_get_contents(config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-20190607.geojson');
|
||||
$bytes = $this->readFile(config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-20190607.geojson');
|
||||
abort_unless(hash('sha256', $bytes) === $plan['source_sha256'], 409);
|
||||
$source = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
$metadata = config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-metadata.html';
|
||||
$metadataScan = app(MalwareScanner::class)->scan($metadata, filesize($metadata), storage_path('app/private'));
|
||||
abort_unless(($metadataScan['result'] ?? '') === 'clean', 423, 'Scan dos metadados do exemplar pendente ou inconclusivo.');
|
||||
$metadataSize = filesize($metadata);
|
||||
abort_unless(is_int($metadataSize), 422, 'Metadados indisponíveis durante a aplicação.');
|
||||
$metadataScan = app(MalwareScanner::class)->scan($metadata, $metadataSize, storage_path('app/private'));
|
||||
abort_unless($metadataScan['result'] === 'clean', 423, 'Scan dos metadados do exemplar pendente ou inconclusivo.');
|
||||
abort_unless(hash_file('sha256', $metadata) === $plan['metadata_sha256'], 409, 'Metadados alterados durante o scan.');
|
||||
$metadataScan['signatures_at'] = $metadataScan['signatures_at']?->format(DATE_ATOM);
|
||||
$batch = (string) Str::uuid();
|
||||
@@ -138,17 +158,20 @@ class TerritoryGeometryImport
|
||||
}
|
||||
$feature = $source['features'][$i];
|
||||
$id = (string) Str::uuid();
|
||||
$trace = ['authority' => 'Mozambique INE - Instituto Nacional de Estatistica', 'provider' => 'UNOCHA; exemplar hosted by World Bank', 'source_version' => $plan['edition'], 'source_sha256' => $plan['source_sha256'], 'metadata_sha256' => $plan['metadata_sha256'], 'source_url' => 'https://services.arcgis.com/iQ1dY19aHwbSDYIF/arcgis/rest/services/Mozambique/FeatureServer/'.($data['level'] + 2), 'source_properties' => $feature['properties'], 'source_scan' => $plan['scan'], 'batch_id' => $batch, 'purpose' => 'administrative_boundary', 'investment_site' => false, 'legal_currentness' => 'not_determined', 'match_basis' => 'unique_exact_name_and_matched_parent_after_case_and_diacritics'];
|
||||
$trace = ['authority' => 'Mozambique INE - Instituto Nacional de Estatistica', 'provider' => 'UNOCHA; exemplar hosted by World Bank', 'source_version' => $plan['edition'], 'source_sha256' => $plan['source_sha256'], 'metadata_sha256' => $plan['metadata_sha256'], 'source_url' => 'https://services.arcgis.com/iQ1dY19aHwbSDYIF/arcgis/rest/services/Mozambique/FeatureServer/'.($data['level'] + 2), 'source_properties' => $feature['properties'], 'source_scan' => $plan['scan'], 'batch_id' => $batch, 'purpose' => 'administrative_boundary', 'investment_site' => false, 'legal_currentness' => 'not_determined', 'match_basis' => $row['match_basis'], 'correspondence' => $row['correspondence'], 'correspondence_version' => $row['correspondence_version'], 'correspondence_sha256' => $plan['correspondence_sha256']];
|
||||
DB::insert('INSERT INTO geo_geometries(id,source_system,external_id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,?,?,?,ST_SetSRID(ST_GeomFromGeoJSON(?),4326),?::jsonb,?)', [$id, 'ine-cod-ab', $row['external_id'], $row['location_id'], strtolower($feature['geometry']['type']), json_encode($feature['geometry'], JSON_THROW_ON_ERROR), json_encode($trace, JSON_THROW_ON_ERROR), 'internal']);
|
||||
$created[] = ['id' => $id, 'checksum' => $this->snapshotHash($id)];
|
||||
}
|
||||
$result = ['batch_id' => $batch, 'level' => $data['level'], 'preview_hash' => $plan['hash'], 'expected' => $plan['expected'], 'resolved' => $plan['resolved'], 'created' => $created, 'inserted' => count($created), 'reused' => count($reused), 'reused_ids' => $reused, 'pending' => $plan['pending'], 'source_sha256' => $plan['source_sha256'], 'metadata_sha256' => $plan['metadata_sha256'], 'metadata_scan' => $metadataScan, 'published' => false, 'reason' => $data['reason']];
|
||||
$result = ['batch_id' => $batch, 'level' => $data['level'], 'preview_hash' => $plan['hash'], 'expected' => $plan['expected'], 'resolved' => $plan['resolved'], 'created' => $created, 'inserted' => count($created), 'reused' => count($reused), 'reused_ids' => $reused, 'pending' => $plan['pending'], 'source_sha256' => $plan['source_sha256'], 'metadata_sha256' => $plan['metadata_sha256'], 'correspondence_version' => $plan['correspondence_version'], 'correspondence_sha256' => $plan['correspondence_sha256'], 'metadata_scan' => $metadataScan, 'published' => false, 'reason' => $data['reason']];
|
||||
$this->audit($actor, $batch, 'geometry_import_applied', $result);
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function reverse(User $actor, string $batch, array $input): array
|
||||
{
|
||||
$this->guard($actor, true);
|
||||
@@ -156,15 +179,31 @@ class TerritoryGeometryImport
|
||||
|
||||
return $this->receipt($actor, 'geometry-import-reverse', $data, function () use ($actor, $data, $batch) {
|
||||
$event = DB::table('audit_events')->where('source_system', 'geometry-import')->where('entity_id', $batch)->where('action', 'geometry_import_applied')->first();
|
||||
abort_unless($event, 404);
|
||||
abort_unless($event !== null, 404);
|
||||
$old = DB::table('audit_events')->where('source_system', 'geometry-import')->where('entity_id', $batch)->where('action', 'geometry_import_reversed')->first();
|
||||
if ($old) {
|
||||
return json_decode($old->after_data, true);
|
||||
}
|
||||
$manifest = json_decode($event->after_data, true);
|
||||
foreach ($manifest['created'] as $row) {
|
||||
DB::table('geo_geometries')->where('id', $row['id'])->lockForUpdate()->first();
|
||||
$geometry = DB::table('geo_geometries')->where('id', $row['id'])->lockForUpdate()->first();
|
||||
abort_unless($this->snapshotHash($row['id']) === $row['checksum'], 409, 'Geometria alterada ou publicada; reversão recusada.');
|
||||
// Source hierarchy is a real import dependency even though it is not a geometry FK.
|
||||
if (in_array((int) $manifest['level'], [1, 2], true)) {
|
||||
$properties = json_decode($geometry->properties, true, 512, JSON_THROW_ON_ERROR);
|
||||
$parentField = 'ADM'.$manifest['level'].'_PCODE';
|
||||
$code = $properties['source_properties'][$parentField] ?? null;
|
||||
abort_unless($code, 409, 'Correspondência original sem código territorial; reversão exige reconciliação.');
|
||||
$descendants = DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->whereNull('deleted_at')
|
||||
->where('properties->source_version', $properties['source_version'])
|
||||
->where('properties->source_properties->'.$parentField, $code)
|
||||
->where(function ($query) use ($manifest) {
|
||||
foreach (range((int) $manifest['level'] + 1, 3) as $level) {
|
||||
$query->orWhere('external_id', 'like', '2019-06-07:adm'.$level.':%');
|
||||
}
|
||||
});
|
||||
abort_if($descendants->exists(), 409, 'Limites descendentes dependem desta correspondência. Reverta primeiro postos, depois distritos e províncias; preserve os lotes anteriores.');
|
||||
}
|
||||
// Enumerate all real FKs, rather than assuming one consumer table.
|
||||
$references = DB::select("SELECT n.nspname AS schema,t.relname AS table_name,a.attname AS column_name FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid JOIN pg_namespace n ON n.oid=t.relnamespace JOIN pg_attribute a ON a.attrelid=c.conrelid AND a.attnum=c.conkey[1] WHERE c.contype='f' AND c.confrelid='geo_geometries'::regclass");
|
||||
foreach ($references as $ref) {
|
||||
@@ -188,6 +227,10 @@ class TerritoryGeometryImport
|
||||
return hash('sha256', $r->value ?? 'missing');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @param callable():array<string,mixed> $operation
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function receipt(User $actor, string $scope, array $data, callable $operation): array
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $scope, $data, $operation) {
|
||||
@@ -207,8 +250,17 @@ class TerritoryGeometryImport
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function audit(User $actor, string $batch, string $action, array $data): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'geometry-import', 'entity_type' => 'geo_geometries', 'entity_id' => $batch, 'correlation_id' => $batch, 'actor_user_id' => $actor->id, 'action' => $action, 'after_data' => json_encode($data, JSON_THROW_ON_ERROR), 'data_classification' => 'internal']);
|
||||
}
|
||||
|
||||
private function readFile(string $path): string
|
||||
{
|
||||
$contents = file_get_contents($path);
|
||||
abort_unless(is_string($contents), 422, 'Exemplar/prova indisponível durante a leitura; operação recusada.');
|
||||
|
||||
return $contents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,15 @@ class VirtualDeskInstallation
|
||||
database_path('migrations/2026_10_01_000200_create_simulation_execution_core.php'),
|
||||
resource_path('js/components/investment-map-panel.tsx'), resource_path('js/pages/investment/detail.tsx'), resource_path('js/pages/partnerships/catalogue.tsx'),
|
||||
...(glob(resource_path('js/pages/investor-path/*.tsx')) ?: [])];
|
||||
$files = [...$files, app_path('Services/VerifiedDocumentBinary.php'), app_path('Services/TerritoryGeometryImport.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), config_path('geometry-import.php'), resource_path('js/pages/management/territory-geometry-import.tsx'),
|
||||
$files = [...$files, app_path('Services/VerifiedDocumentBinary.php'), app_path('Services/TerritoryGeometryImport.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), config_path('geometry-import.php'), config_path('geometry-correspondences.php'), app_path('Http/Controllers/LandAssistanceController.php'), app_path('Http/Controllers/PartnershipExchangeController.php'), resource_path('js/pages/management/territory-geometry-import.tsx'),
|
||||
base_path('bootstrap/app.php'), app_path('Providers/FortifyServiceProvider.php'), config_path('fortify.php'),
|
||||
resource_path('js/pages/auth/login.tsx'), resource_path('js/pages/auth/two-factor-challenge.tsx')];
|
||||
$files = [...$files, app_path('Services/OpportunityManagement.php'), app_path('Services/OpportunityRealEstateDraft.php'), config_path('portal.php'),
|
||||
resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/components/app-sidebar.tsx'), resource_path('js/types/opportunity-management.ts'), base_path('vite.config.ts')];
|
||||
$files = [...$files, resource_path('js/lib/portal-navigation.ts'), resource_path('js/components/portal-grouped-menu.tsx')];
|
||||
sort($files);
|
||||
|
||||
$files = [...$files, ...PortalProfileAccess::fingerprintFiles()];
|
||||
return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
use App\Http\Middleware\EnsureSimulationRecoveryCookie;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\PortalProfileSession;
|
||||
use App\Http\Middleware\PreserveFormFeedback;
|
||||
use App\Http\Middleware\ProtectProfileOpportunity;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Application;
|
||||
@@ -26,6 +28,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', EnsureSimulationRecoveryCookie::NAME]);
|
||||
|
||||
$middleware->web(append: [
|
||||
PortalProfileSession::class,
|
||||
\App\Http\Middleware\ProtectPrivilegedIdentity::class,
|
||||
\App\Http\Middleware\AuditProfileDecision::class,
|
||||
ProtectProfileOpportunity::class,
|
||||
HandleAppearance::class,
|
||||
HandleInertiaRequests::class,
|
||||
PreserveFormFeedback::class,
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"laravel/fortify": "^1.37.2",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.14"
|
||||
"laravel/wayfinder": "^0.1.14",
|
||||
"opis/json-schema": "2.4.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.24",
|
||||
|
||||
Generated
+191
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d0a5f283f588eb7a0f25e28797e62117",
|
||||
"content-hash": "6a5afd60c9dc34bf3f21ae98a0b16c7b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -3019,6 +3019,196 @@
|
||||
],
|
||||
"time": "2026-02-16T23:10:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "opis/json-schema",
|
||||
"version": "2.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/opis/json-schema.git",
|
||||
"reference": "712827751c62b465daae6e725bf0cf5ffbf965e1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/opis/json-schema/zipball/712827751c62b465daae6e725bf0cf5ffbf965e1",
|
||||
"reference": "712827751c62b465daae6e725bf0cf5ffbf965e1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"opis/string": "^2.0",
|
||||
"opis/uri": "^1.0",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-bcmath": "*",
|
||||
"ext-intl": "*",
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Opis\\JsonSchema\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sorin Sarca",
|
||||
"email": "sarca_sorin@hotmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Marius Sarca",
|
||||
"email": "marius.sarca@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Json Schema Validator for PHP",
|
||||
"homepage": "https://opis.io/json-schema",
|
||||
"keywords": [
|
||||
"json",
|
||||
"json-schema",
|
||||
"schema",
|
||||
"validation",
|
||||
"validator"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/opis/json-schema/issues",
|
||||
"source": "https://github.com/opis/json-schema/tree/2.4.1"
|
||||
},
|
||||
"time": "2024-12-30T20:20:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "opis/string",
|
||||
"version": "2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/opis/string.git",
|
||||
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e",
|
||||
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"ext-json": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Opis\\String\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marius Sarca",
|
||||
"email": "marius.sarca@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Sorin Sarca",
|
||||
"email": "sarca_sorin@hotmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Multibyte strings as objects",
|
||||
"homepage": "https://opis.io/string",
|
||||
"keywords": [
|
||||
"multi-byte",
|
||||
"opis",
|
||||
"string",
|
||||
"string manipulation",
|
||||
"utf-8"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/opis/string/issues",
|
||||
"source": "https://github.com/opis/string/tree/2.1.0"
|
||||
},
|
||||
"time": "2025-10-17T12:38:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "opis/uri",
|
||||
"version": "1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/opis/uri.git",
|
||||
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
|
||||
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"opis/string": "^2.0",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Opis\\Uri\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marius Sarca",
|
||||
"email": "marius.sarca@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Sorin Sarca",
|
||||
"email": "sarca_sorin@hotmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Build, parse and validate URIs and URI-templates",
|
||||
"homepage": "https://opis.io",
|
||||
"keywords": [
|
||||
"URI Template",
|
||||
"parse url",
|
||||
"punycode",
|
||||
"uri",
|
||||
"uri components",
|
||||
"url",
|
||||
"validate uri"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/opis/uri/issues",
|
||||
"source": "https://github.com/opis/uri/tree/1.1.0"
|
||||
},
|
||||
"time": "2021-05-22T15:57:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.1.3",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
// Explicit crosswalk of accepted exemplars, not interchangeable code systems.
|
||||
// Never rename/reparent the catalogue or infer an investment site from these boundaries.
|
||||
return [
|
||||
'version' => 'ine-20190607-workbook-20261010-v2',
|
||||
'edition' => '2019-06-07',
|
||||
'catalogue_source_system' => 'cartografia-workbook',
|
||||
'catalogue_sha256' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff',
|
||||
'source_sha256' => [
|
||||
1 => 'ce5cd5f6090b7ba9ac78f919c9dd149f6c7880ab8275051a3398b1c6593fa989',
|
||||
2 => '16781986ac938bc8e81c9ca01a076b4bd46e333e8877645844d0cc7cd2919707',
|
||||
3 => 'ef11737c1ef68870bfcee67903352b7e554cb40ca39dd85f020c2a13631e1635',
|
||||
],
|
||||
'rows' => [
|
||||
1 => [
|
||||
'MZ05' => ['source_name' => 'Maputo', 'target_name' => 'Maputo Província', 'target_source_code' => '10', 'parent_source_code' => 'MZ',
|
||||
'basis' => 'INE ADM1 MZ05 designa a província Maputo; o exemplar separa Maputo City MZ06. Cadastro cartografia_moz.xlsx: Provincia Maputo Província, código10, sob Moçambique. Códigos10 e MZ05 pertencem a sistemas distintos.'],
|
||||
'MZ06' => ['source_name' => 'Maputo City', 'target_name' => 'Maputo Cidade', 'target_source_code' => '11', 'parent_source_code' => 'MZ',
|
||||
'basis' => 'INE ADM1 MZ06 designa Maputo City, distinta da província MZ05; tradução explícita City/Cidade. Cadastro cartografia_moz.xlsx: Provincia Maputo Cidade, código11, sob Moçambique. Não associa distritos urbanos ao distrito agregado da ediçãoINE.'],
|
||||
],
|
||||
2 => [
|
||||
'MZ1002' => ['source_name' => 'Cahora Bassa', 'target_name' => 'CAHORA-BASSA', 'target_source_code' => '03', 'parent_source_code' => 'MZ10',
|
||||
'basis' => 'Único distrito sob Tete nos dois exemplares; variante espaço/hífen. Mantém códigos próprios de cada fonte, edição INE 2019 e limites do exemplar, sem renomear o cadastro.'],
|
||||
'MZ0817' => ['source_name' => 'Ngauma', 'target_name' => 'N`GAUMA', 'target_source_code' => '14', 'parent_source_code' => 'MZ08',
|
||||
'basis' => 'Único distrito sob Niassa nos dois exemplares; variante de apóstrofo. Exige pai correspondido e código 14 no cadastro; não altera hierarquia.'],
|
||||
'MZ0704' => ['source_name' => 'Ilha De Moçambique', 'target_name' => 'CIDADE ILHA DE MOCAMBIQUE', 'target_source_code' => '04', 'parent_source_code' => 'MZ07',
|
||||
'basis' => 'Único ADM2 Ilha de Moçambique sob Nampula; cadastro qualifica explicitamente a cidade no mesmo nível Distrito, código04. Correspondência da edição INE 2019, não afirma limites atuais.'],
|
||||
'MZ0310' => ['source_name' => 'Maxixe', 'target_name' => 'CIDADE DE MAXIXE', 'target_source_code' => '10', 'parent_source_code' => 'MZ03',
|
||||
'basis' => 'Único ADM2 Maxixe sob Inhambane; cadastro Distrito CIDADE DE MAXIXE código10. Qualificação explícita Cidade, sem unir à área do distrito Inhambane.'],
|
||||
'MZ0720' => ['source_name' => 'Nacala-A-Velha', 'target_name' => 'NACALA-VELHA', 'target_source_code' => '18', 'parent_source_code' => 'MZ07',
|
||||
'basis' => 'Único distrito Nacala Velha sob Nampula; variante preposicional A. Preserva distinção de Nacala-Porto e Nacarôa; código18 apenas do cadastro.'],
|
||||
'MZ0502' => ['source_name' => 'Cidade Da Matola', 'target_name' => 'CIDADE DE MATOLA', 'target_source_code' => '01', 'parent_source_code' => 'MZ05',
|
||||
'basis' => 'Variação explícita da preposição Da/DE em Cidade de Matola. INE ADM2 MZ0502 sob Maputo MZ05; cadastro cartografia_moz.xlsx Distrito CIDADE DE MATOLA, código01 sob Maputo Província. Exige pai previamente correspondido; não altera limites ou hierarquia.'],
|
||||
],
|
||||
3 => [
|
||||
'MZ081201' => ['source_name' => "M'Sawize", 'target_name' => 'M`SAWIZE', 'target_source_code' => '02', 'parent_source_code' => 'MZ0812',
|
||||
'basis' => 'Variante exclusivamente de apóstrofo no mesmo pai ADM2 MZ0812 previamente correspondido; único posto código02. Nenhuma alteração do cadastro.'],
|
||||
'MZ011404' => ['source_name' => "N'Cumpe", 'target_name' => 'NCUMPE', 'target_source_code' => '05', 'parent_source_code' => 'MZ0114',
|
||||
'basis' => 'Variante exclusivamente de apóstrofo no mesmo pai ADM2 MZ0114 previamente correspondido; único posto código05. Nenhuma alteração do cadastro.'],
|
||||
'MZ011204' => ['source_name' => "N'Gapa", 'target_name' => 'N`GAPA', 'target_source_code' => '05', 'parent_source_code' => 'MZ0112',
|
||||
'basis' => 'Variante exclusivamente de apóstrofo no mesmo pai ADM2 MZ0112 previamente correspondido; único posto código05. Nenhuma alteração do cadastro.'],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'profile_access_enabled' => (bool) env('PORTAL_PROFILE_ACCESS_ENABLED', false),
|
||||
// Prepared increment; enable only in QA until the authenticated rollout is approved.
|
||||
'opportunity_dashboard_access_enabled' => env('PORTAL_OPPORTUNITY_DASHBOARD_ACCESS_ENABLED', false),
|
||||
'opportunity_specialized_drafts_enabled' => env('PORTAL_OPPORTUNITY_SPECIALIZED_DRAFTS_ENABLED', false),
|
||||
'operator_name' => env('PORTAL_OPERATOR_NAME'),
|
||||
'support' => [
|
||||
'email' => env('PORTAL_SUPPORT_EMAIL'),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'proof_directory' => storage_path('app/private/investmoz-v2/portal-profile-installation'),
|
||||
// Scoped execution flags are set and reset only by the authenticated services.
|
||||
'authenticated_installation' => false,
|
||||
'bootstrap_authorized' => false,
|
||||
];
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')) {
|
||||
$target = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
|
||||
throw_unless(str_ends_with($target->database, '_test') && (int) $target->port === 55441, RuntimeException::class, 'Isolated QA required');
|
||||
} else {
|
||||
throw_unless(config('portal_profiles.authenticated_installation') === true, RuntimeException::class, 'Authenticated profile installation required');
|
||||
}
|
||||
Schema::table('users', fn (Blueprint $t) => $t->unsignedBigInteger('portal_access_revision')->default(0));
|
||||
Schema::table('opportunities', function (Blueprint $t) {
|
||||
$t->foreignId('owner_user_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete();
|
||||
$t->jsonb('workflow_context')->nullable();
|
||||
});
|
||||
Schema::create('portal_profile_requests', function (Blueprint $t) {
|
||||
$t->uuid('id')->primary();
|
||||
$t->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete();
|
||||
$t->string('representation_type');
|
||||
$t->jsonb('opportunity_types');
|
||||
$t->jsonb('document_ids');
|
||||
$t->text('reason');
|
||||
$t->string('status')->default('pending');
|
||||
$t->unsignedBigInteger('version')->default(1);
|
||||
$t->uuid('creation_key')->unique();
|
||||
$t->foreignId('decided_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$t->text('decision_reason')->nullable();
|
||||
$t->timestampTz('decided_at')->nullable();
|
||||
$t->timestampsTz();
|
||||
});
|
||||
Schema::create('portal_role_assignments', function (Blueprint $t) {
|
||||
$t->uuid('id')->primary();
|
||||
$t->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$t->string('role');
|
||||
$t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete();
|
||||
$t->jsonb('opportunity_types');
|
||||
$t->jsonb('responsibilities');
|
||||
$t->string('policy_version')->default('profiles-v1-20261010');
|
||||
$t->string('source');
|
||||
$t->foreignUuid('request_id')->nullable()->constrained('portal_profile_requests')->restrictOnDelete();
|
||||
$t->foreignId('approved_by')->constrained('users')->restrictOnDelete();
|
||||
$t->timestampTz('starts_at');
|
||||
$t->timestampTz('expires_at')->nullable();
|
||||
$t->timestampTz('revoked_at')->nullable();
|
||||
$t->timestampsTz();
|
||||
$t->index(['user_id', 'role']);
|
||||
});
|
||||
Schema::create('portal_superuser_pair', function (Blueprint $t) {
|
||||
$t->unsignedSmallInteger('id')->primary();
|
||||
$t->foreignId('first_user_id')->constrained('users')->restrictOnDelete();
|
||||
$t->foreignId('second_user_id')->constrained('users')->restrictOnDelete();
|
||||
$t->unsignedBigInteger('version')->default(1);
|
||||
$t->foreignId('approved_by')->constrained('users')->restrictOnDelete();
|
||||
$t->timestampsTz();
|
||||
});
|
||||
Schema::create('portal_security_approvals', function (Blueprint $t) {
|
||||
$t->uuid('id')->primary();
|
||||
$t->string('kind');
|
||||
$t->foreignId('requested_by')->constrained('users')->restrictOnDelete();
|
||||
$t->foreignId('subject_user_id')->constrained('users')->restrictOnDelete();
|
||||
$t->jsonb('scope');
|
||||
$t->text('reason');
|
||||
$t->foreignId('approved_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$t->timestampTz('expires_at');
|
||||
$t->timestampTz('consumed_at')->nullable();
|
||||
$t->timestampsTz();
|
||||
});
|
||||
DB::statement("ALTER TABLE portal_role_assignments ADD CONSTRAINT portal_role_name CHECK (role IN ('promoter','validator','publisher','auditor')), ADD CONSTRAINT portal_role_independent CHECK (approved_by <> user_id), ADD CONSTRAINT portal_role_period CHECK (expires_at IS NULL OR expires_at > starts_at)");
|
||||
DB::statement('ALTER TABLE portal_superuser_pair ADD CONSTRAINT portal_pair_exact CHECK (id=1 AND first_user_id <> second_user_id AND version>0)');
|
||||
DB::statement("ALTER TABLE portal_profile_requests ADD CONSTRAINT portal_request_status CHECK (status IN ('pending','approved','rejected','withdrawn')), ADD CONSTRAINT portal_request_independent CHECK (decided_by IS NULL OR decided_by <> user_id)");
|
||||
DB::statement('ALTER TABLE portal_security_approvals ADD CONSTRAINT portal_security_independent CHECK (approved_by IS NULL OR approved_by <> requested_by)');
|
||||
DB::unprepared('CREATE OR REPLACE FUNCTION guard_portal_superuser_pair() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION \'Superuser pair cannot be removed; use atomic replacement\'; END; $$; CREATE TRIGGER portal_pair_no_delete BEFORE DELETE ON portal_superuser_pair FOR EACH ROW EXECUTE FUNCTION guard_portal_superuser_pair(); CREATE TRIGGER portal_pair_no_truncate BEFORE TRUNCATE ON portal_superuser_pair FOR EACH STATEMENT EXECUTE FUNCTION guard_portal_superuser_pair()');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (['portal_role_assignments', 'portal_profile_requests', 'portal_superuser_pair', 'portal_security_approvals'] as $table) {
|
||||
throw_if(DB::table($table)->exists(), RuntimeException::class, 'Preserve authorization history; disable access and reconcile restore');
|
||||
}
|
||||
throw_if(DB::table('opportunities')->whereNotNull('owner_user_id')->orWhereNotNull('organization_id')->orWhereNotNull('workflow_context')->exists(), RuntimeException::class, 'Preserve scoped opportunities');
|
||||
throw_if(DB::table('users')->where('portal_access_revision', '<>', 0)->exists(), RuntimeException::class, 'Preserve session authorization history');
|
||||
DB::unprepared('DROP TRIGGER portal_pair_no_delete ON portal_superuser_pair; DROP TRIGGER portal_pair_no_truncate ON portal_superuser_pair; DROP FUNCTION guard_portal_superuser_pair()');
|
||||
Schema::dropIfExists('portal_security_approvals');
|
||||
Schema::dropIfExists('portal_superuser_pair');
|
||||
Schema::dropIfExists('portal_role_assignments');
|
||||
Schema::dropIfExists('portal_profile_requests');
|
||||
Schema::table('opportunities', function (Blueprint $t) {
|
||||
$t->dropConstrainedForeignId('owner_user_id');
|
||||
$t->dropConstrainedForeignId('organization_id');
|
||||
$t->dropColumn('workflow_context');
|
||||
});
|
||||
Schema::table('users', fn (Blueprint $t) => $t->dropColumn('portal_access_revision'));
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,163 @@
|
||||
# InvesteMoz — backlog do lote 3
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Estado atual — instalador, documentos e cartografia
|
||||
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T10:32:41+00:00: 030000, 040000 e 09/190000 ausentes; zero geometrias; manifesto preservado. **C37, Promotor QA21 e A/B permanecem visualmente pendentes. Candidata à ativação ainda não fixada.**
|
||||
|
||||
| Requisitos | Código preparado / prova QA | Critério ainda aberto |
|
||||
| --- | --- | --- |
|
||||
| PROFILE-31 / OPS-01 | GET/POST `/management/profile-install`, bootstrap separado; prévia, dependências, hash, MFA, repetição e auditoria. Instalação vazia revertida/reinstalada; falha de auditoria reverte DDL e ledger, permitindo repetir. Uso/histórico impedem remoção. No servidor já utilizado, `changed=false`; nenhum titular ou privilégio alterado. Promotor, Auditor e organização alheia recebem 403. | Instalação/bootstrap visual e titulares reais designados; recuperação simultânea da dupla depende de D-R1. Backup do schema não recupera MFA perdido. |
|
||||
| GEO-02/03 | **15 novas**: cinco distritos e dez postos; **393 reutilizadas**. Total **408**: 11 províncias, 133 distritos e 264 postos. API do mapa recupera os 15 novos limites. 1.990 cadastros e 361 geometrias originais preservados; sem publicação. | **173 lacunas nomeadas**: 26 distritos e 147 postos; ambiguidades e edições distintas. Seletores/mapas por consumidor continuam com aceitação visual pendente. Localidades/parcelas não inferidas. |
|
||||
| OP-02 / documental | DOCX/XLSX: pacote válido, scan ClamD real, acesso de QA21/24/25 e QA23 negado; exclusão lógica, restauro e original idêntico noutra sessão MFA. | Renderização de Office, exportações/PDFs derivados e restantes consumidores continuam pendentes por operação. |
|
||||
| LAND-01 / PART-01 / SCAN-01 | PDF, PNG e JPEG em fundiário e sala de parceria: seis scans reais com hashes iguais. Autor recupera originais; partilha explícita permite nova sessão; revogação retira acesso/listagem, sem apagar original. Gestor não ganha acesso por esse papel. **91 pedidos** no recibo próprio. | Modelo de impressão recuperado, mas PDF gerado no navegador e interface não demonstrados. Estes seis scans não encerram outros tipos, derivadas ou permissões. |
|
||||
| OP-03 / OP-04 | Código da interface e resposta distinguem aprovação interna, disponibilização restrita e catálogo público. Casos #30 restrito, #33 público e #32 aprovado sem publicação recuperados pela API; consulta da política pública efetiva. | Interface; Response integral/§27 e condições sem regra inequívoca continuam parciais. Testes dos seis contratos não encerram a demonstração visual. |
|
||||
| PROFILE-22/28 | [Quatro perfis e SLA com regra, fundamento, efeito e decisões concretas](propostas-perfis-modulares-sla.md). | Propostas **inativas**; quatro categorias continuam com rascunhos recuperáveis, mas submissão/publicação bloqueadas. Nenhum prazo ou responsável real configurado. |
|
||||
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_104956_test:55441` → **295 tabelas com linhas/hashes iguais e 69 ficheiros fisicamente recuperados por hash**; todos os ficheiros referenciados recuperados por hash. Arquivo privado inclui o runtime e a chave QA estável. Readiness só atesta preparação técnica desta revisão. Repetir após fixar a candidata e preparar backup ativo apenas numa futura ativação autorizada.
|
||||
|
||||
**Matriz única:** 3.386 requisitos/linhas e 6.838 ocorrências de origem; todas as fontes, critérios e versões preservados; sem novos fechos globais. Contagens por estado abaixo e na projeção dos pedidos abertos.
|
||||
|
||||
**Próximo lote executável:** completar Response/§27 reutilizando entidades; verificar exportações e PDFs derivados por consumidor; prosseguir correspondências territoriais por nome, pai e edição; completar países/origem, filtros e estatísticas por formulário. Incorporar provas visuais quando retomadas. Decisões D-M1–M4, D-S1/S2, D-SLA e D-R1 estão individualizadas e não bloqueiam o trabalho independente.
|
||||
|
||||
**Histórico preservado:** os blocos seguintes descrevem revisões anteriores. 393 geometrias/188 lacunas eram o estado antes deste lote; “instalador 040000 em falta” deixou de descrever o código QA. Esses estados anteriores não provam esta revisão nem autorizam instalação ativa.
|
||||
## Contagens atuais por requisito
|
||||
|
||||
| Estado | Requisitos / linhas da matriz | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
|parcial|3229|6379|
|
||||
|não implementado|145|446|
|
||||
|dependente de decisão concreta|5|5|
|
||||
|implementado mas não ativado|1|1|
|
||||
|concluído e demonstrado|6|7|
|
||||
|
||||
3.386 requisitos verificáveis e 3.386 linhas consolidadas; 6.838 ocorrências de origem. Sem novo fecho integral; provas QA descritas por critério.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Próximo trabalho executável e decisões separadas — lote semântico atual
|
||||
|
||||
| Prioridade / requisito | O que já tem prova QA | Falta / próxima ação e critério concreto |
|
||||
| --- | --- | --- |
|
||||
| P0 — UI-01/C37 | Diagnóstico/MFA preservados; ensaios HTTP21 não fecham18. | C37 e confirmação visual continuam pendentes conforme pedido. Quando retomados, dashboard→MFA→sessão→refresh→logout→novo login; rastreio sanitizado e primeiro ponto de perda. Nenhum novo teste visual solicitado agora. |
|
||||
| P1 — OP-04 / PROFILE-28/25 | Seis editores/regras no contrato do servidor; dez drafts e doze rejeições por campo recuperadas. | Observar cada categoria na UI21, erro junto ao controlo e nova sessão; reconciliar unidades aplicáveis, entidade/título/cadastro e limites urbanísticos com gates; completar Response/§27 apenas reutilizando entidades. Não regredir source/enums/IDs ou tornar campos incompletos obrigatórios para guardar. |
|
||||
| P1 — OP-03 / PROFILE-11/12/19 | Filas, parecer atribuído, resposta, revisão e coordenação editorial com task/hash/papéis; negações específicas. | Interface por24/25/29, reatribuição e revogação; incidentes, disponibilidade e revisão periódica. Parecer externo requer responsabilidade institucional concreta, não um ator fictício; aprovação interna não substitui decisão externa. |
|
||||
| P1 — GEO-03 / COUNTRY/STATS | Seis contratos ligam localização/documentos por IDs e país MZ; investimento recupera mercadoFR; editores consultam geometrias no nível selecionado. | Verificar visual de todos os consumidores afetados, país/origem→recuperação→filtro→estatística por formulário. Resolver nomes pendentes do pacote:31distritos+157postos; localidades/parcelas não inferidas de limites administrativos. Reutilizar importação autenticada/idempotente; publicação de camada é separada. |
|
||||
| P1 — OP-02 / LIB / PROFILE-23 | Originais por perfil, negação de organização23, cifragem/hash, PNG e duas derivadas ClamD; PDF do ensaio atual separado. | Continuar DOCX/XLSX, partilhas, PDFs/exportações/derivadas sem prova específica; preservar originals/versões. Consistência de erro404 recurso excluído versus423 scan pendente foi corrigida no consumidor de oportunidades. |
|
||||
| P1 — PROFILE-23 | Dois avisos preexistentes de league/commonmark identificados no lote anterior. | Avaliar consumidores e corrigir compatibilidade emQA; não apagar o achado nem atribuir a Opis. |
|
||||
| P2 — PROFILE-31 / OPS-01 | 190000confirmadaQA/ausenteativo;030/040QA;baseBalcãoativa instalada, hashes atualizados. | Construir instalação/bootstrap autenticados040 com duasidentidades reais explicitamente designadas; não mudar titulares nem retirar privilégio ativo automaticamente. Apósvisual, fixar candidata e renovarbackup/restauro dessa revisão; executar apenas migrações faltantes pelo mecanismo adequado. |
|
||||
|
||||
Decisões independentes, sem bloquear este lote:
|
||||
|
||||
1. **Concessão, financiamento, fornecimento e outros:** quais versões imutáveis de perfil modular, campos obrigatórios, documentos e gates são aprovadas para cada categoria? Sem esse contrato, guardar/editar funciona; submissão/revisão/publicação continuam bloqueadas.
|
||||
2. **SLA:** qual política versionada fixa prazo por tipo de tarefa, tolerância, número de ciclos e escalamento? Quem assume formalmente cada competência de coordenação, parecer técnico/jurídico, publicação e recurso? Manter prazo nulo e atribuição explícita até existir decisão; não usar nomes/prazos de QA como norma.
|
||||
3. **Condições sem regra inequívoca:** qual âmbito/citação estabelece a aplicabilidade de unidades/limites urbanísticos e materialidade? Como reconciliar o mínimo de um exercício do schema com entidades sem exercícios existentes? Não substituir valores ausentes por zero nem alterar silenciosamente a versão aceita.
|
||||
|
||||
Não iniciar jornadas educativas novas. Mantêm-se cinco fases/19 etapas e URLs, dossier privado, oportunidade publicada, fontes oficiais aceites e histórico.
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Próximo trabalho executável — incremento de 10/10/2026
|
||||
|
||||
| Prioridade / requisito | Correção concreta seguinte | Critério de fecho e dependência |
|
||||
| --- | --- | --- |
|
||||
| P0 — UI-01/C37; DASH-01/C43 | Incorporar ensaio visual com Promotor 21 e, separadamente, investidor 18; registar falhas com URL, ação, estado e resposta sanitizada. | Login/MFA → dashboard → guardar → logout → nova sessão → recuperar, sem loop, perda ou acesso indevido; não substituível por HTTP. |
|
||||
| P1 — OP-04; PROFILE-28 | Completar editores e validações semânticas §25: grelhas de serviços/ónus, avaliação/documento, ocupação/título/cadastro, comunidades/consulta/benfeitorias, financeiros/beneficiário efetivo, NDA, governação e franquia; completar Response. Reutilizar contrato e entidades atuais. | Rascunho parcial recuperável; cada condição aplicável recusa dados inválidos e aceita dados/evidências completos; interface por categoria. Confirmar correspondências de objetos opacos do schema, sem alterar os significados legados silenciosamente. |
|
||||
| P1 — OP-03; PROFILE-11/12/19/21 | Completar filas/revisões e editoriais, reatribuição, pareceres, incidentes e recurso; manter hash da versão e independência. | Atores distintos e responsáveis explicitamente designados, motivos/evidência e histórico; revogação reavaliada. Ciclo principal já demonstrado por HTTP, não repetir o inventário. |
|
||||
| P1 — COUNTRY/STATS/GEO | Completar cobertura por formulário/consumidor, mapas e nomes cartográficos pendentes. | País/origem persistidos → recuperação → filtros/estatísticas; território cadastrado ≠ geometria ≠ mapa apresentado. 393 limites privados não encerram integração visual; faltam 31 distritos e 157 postos. |
|
||||
| P1 — OP-02/LIB/PROFILE-23 | Verificar tipos/partilhas/PDFs e derivadas ainda sem prova específica e autorizações do original. | ClamD real separado por operação; hashes/originais preservados; acesso permitido/negado e resultado visível. PNG/documentos do ciclo e duas derivadas já demonstrados. |
|
||||
| P1 — PROFILE-23 (achado adicional) | Composer audit apontou dois avisos preexistentes em `league/commonmark`: GHSA-97jj-33gv-5xf9 e GHSA-3q6v-r5mr-hxv8. Identificar consumidores/configuração e preparar correção compatível em QA. | Prova local `workflow-composer-audit.json`; avaliar aplicação dos avisos e validar os consumidores afetados. Não atribuir aos três pacotes Opis nem declarar exploração demonstrada. |
|
||||
| P2 — PROFILE-22/31 | Configuração SLA versionada, instalaçao autenticada de perfis/dupla e preparação de ativação. | Decisões reais explícitas; ensaio backup/restauro da candidata posterior à visual; nenhum prazo ou titular real escolhido por QA. |
|
||||
|
||||
**Decisões concretas:** (1) quais versões imutáveis de perfil, campos, documentos e gates são aprovadas para concessão, financiamento, fornecimento e outros? (2) qual política versionada fixa prazos, tolerância, ciclos e escalamento? (3) quem assume formalmente as responsabilidades institucionais e os exatamente dois superutilizadores ativos? Não bloqueiam as correções independentes, nem foram inferidas dos atores sintéticos.
|
||||
|
||||
| Grupo dos pedidos abertos (projeção da mesma matriz) | Requisitos |
|
||||
| --- | ---: |
|
||||
| Parciais | 3187 |
|
||||
| Não implementados | 145 |
|
||||
| Preparados em QA | 46 |
|
||||
| Instalados com falha de acesso | 2 |
|
||||
|
||||
Lista integral: **http://127.0.0.1:18097/qa-auditoria/pedidos-abertos**, sob MFA de gestor/Auditor/Superutilizador QA. Cada cartão conserva o estado canónico, fontes, lacuna e próxima ação. “Preparado em QA” indica um escopo provado, não conclusão total.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
<!-- qa-profiles-v1-20261010:begin -->
|
||||
## Próximo lote: prioridade e critérios de fecho — 10/10/2026
|
||||
|
||||
A matriz única tem 3.386 requisitos/linhas, 6.838 ocorrências e 3.380 requisitos abertos. As contagens abaixo agrupam o trabalho; mantêm o estado canónico de cada requisito. “Preparado em QA” identifica um escopo demonstrado, sem declarar todo o requisito concluído.
|
||||
|
||||
| Grupo dos abertos | Requisitos |
|
||||
| --- | ---: |
|
||||
| Parciais | 3189 |
|
||||
| Não implementados | 146 |
|
||||
| Preparados em QA | 43 |
|
||||
| Instalados com falha de acesso | 2 |
|
||||
|
||||
Lista integral pesquisável, derivada do mesmo CSV: **http://127.0.0.1:18097/qa-auditoria/pedidos-abertos**. Acesso do gestor QA 17, Auditor 26 ou Superutilizador 27/30 com MFA. Não há permissões administrativas automáticas para o investidor.
|
||||
|
||||
| Prioridade / requisitos | Próxima correção ou ensaio | Critério concreto |
|
||||
| --- | --- | --- |
|
||||
| P0 — AUD-UI-01; PROFILE-16/17/24 | Incorporar prova visual do login, atualização, logout, nova sessão e revogação; continuar diagnóstico sanitizado de C37. | Sessão recuperável e isolada; revogação efetiva; falha controlada sem autenticação indevida. |
|
||||
| P1 — PROFILE-05/06/07/25/30/32; AUD-DASH-01 | Executar o roteiro do dashboard, criação, recuperação, coedição, pedido/decisão e acessos negados; corrigir reproduções. | Resultado visível e persistente; mesmo ID/autor; autorização no servidor. |
|
||||
| P1 — AUD-OP-04; PROFILE-07/28 | Completar contratos Create/Patch/Response, formulário e adaptadores para entidades existentes. | Campos, regras, documentos e vínculos canónicos completos por categoria; o primeiro draft imobiliário de 24 campos não encerra o contrato. |
|
||||
| P1 — AUD-OP-03; PROFILE-08/09/11/12/19/20/21/22 | Submissão real, atribuição independente, pareceres/correções, aprovação/publicação, versão pública, incidentes e recurso. | Ciclo integral com atores distintos e contrato íntegro; nenhuma fixture de estado contada como submissão. |
|
||||
| P1 — AUD-GEO-02/03; AUD-FIELD-*; PROFILE-01/06 | Resolver os 31 distritos e 157 postos nomeados; verificar todos os seletores/mapas e âmbito provincial de autorização. | Cadastro, geometria e apresentação distinguidos por consumidor; selecionar → guardar → reabrir → mapa visível. |
|
||||
| P1 — AUD-COUNTRY-*; AUD-STATS-01 | Completar país/origem, recuperação, filtros e estatísticas por formulário. | Origem persistida e contabilizada; residência, país fiscal e origem dos fundos não substituem origem declarada. |
|
||||
| P1 — AUD-OP-02; AUD-LIB-01/02; AUD-SCAN-01 | Completar operações documentais, PDFs, partilhas e derivadas por tipo/perfil. | Scan próprio, permissões, revogação e recuperação por hash; original preservado. ClamD real já demonstrado não encerra operações restantes. |
|
||||
| P1 — PROFILE-13/14/15/18/23/26/31 | Completar operações críticas, concorrência da dupla, elevação, revalidação, privacidade, governança e instalação autenticada 040000. | Prévia, autorização, aplicação sem duplicação, recuperação e reversão verificadas; instalação separada da disponibilização. |
|
||||
|
||||
**Decisões concretas para futura ativação:** PROFILE-29 — quais funções dos gestores ativos correspondem a Validador, Publicador, Auditor ou administração controlada; quem serão os dois titulares reais e o terceiro aprovador do bootstrap? PROFILE-22 — quais valores, versões e vigência dos SLA, tolerâncias e ciclos por tipo? PROFILE-16 — qual instância fará a recuperação de emergência se ambos perderem todos os meios? As cinco decisões canónicas incluem ainda as quatro pendências anteriores, preservadas por ID. Estas perguntas não impedem desenvolvimento QA independente.
|
||||
|
||||
A edição por outros Promotores autorizados da mesma organização já foi decidida e aplicada em QA. Novas jornadas educativas não foram iniciadas. Workflow e contratos especializados continuam no backlog. [Correspondência e evidência](perfis-v1-reconciliacao.md), [roteiro](roteiro-manual-perfis-v1.md), [ativação](plano-ativacao.md).
|
||||
|
||||
<!-- qa-profiles-v1-20261010:end -->
|
||||
|
||||
> Registos anteriores preservados como histórico. Os estados correntes são os da matriz e do bloco mais recente acima; uma entrega posterior não altera retroativamente a evidência anterior.
|
||||
|
||||
<!-- qa-dashboard-20261010:begin -->
|
||||
## Prioridade corrente: Gestão de Oportunidades C43 —10/10/2026
|
||||
|
||||
| Grupo de trabalho dos abertos | Requisitos | O que falta / próxima ação |
|
||||
| --- | ---: | --- |
|
||||
| Não implementados |145|Workflow integral segregado; rascunhos incompletos procura/capacidade e jornadas educativas ainda por fazer; demais critérios específicos não implementados no relatório por ID. Implementar workflow sobre draft/auditoria após contratos/gates; educação não começa enquanto prioridades abertas.|
|
||||
| Parciais |3183|Contrato imobiliário apenas API; DUAT/transações/campos/regras finais e vínculos; cartografia 31 distritos/157 postos/mapas por consumidor; relações/citações; acervo/PDFs/partilhas/derivadas; parâmetros fiscais; negócios/direitos/mandatos; reconciliação documental/UX e ativação. Inclui 4 decisões com estado original, sem bloquear tarefas QA independentes.|
|
||||
| Preparados emQA |19|Escopos concretos de rascunhos/recursos/maturidade, países/origem/estatísticas, seletor provincial, Balcão/percurso/CRM/orientação/simulador/parceria/fundiário/importação privada com provasQA. Completar critérios restantes na interface e operações/tipos ainda abertos; ativação permanece separada. Não são 19 requisitos globais concluídos.|
|
||||
| Instalados com falha de acesso |2|AUD-DASH-01(C43) e AUD-UI-01(C37/inspeção). Código/backendQA corrigidos nos escopos descritos; identificar sessão reportada e demonstrar dashboard/cliques e loginA/B completos. Confirmar visualmente sem conceder gestão ao investidor.|
|
||||
|
||||
**Lista integral legível:**http://127.0.0.1:18097/qa-auditoria/pedidos-abertos, gestorQA 14/17.3349 cartões, um por ID, com pedido, lacuna, próxima ação, critério, fontes/versões/evidências e filtros de grupo/módulo. Vista derivada da [matriz única](matriz-requisitos.csv), sem edição nem estados paralelos; investidor 403. Os grupos são editoriais, distintos dos estados canónicos: 3199 parciais, 145 não implementados, 4 decisões, 1 implementado não ativado, 5 concluídos de âmbito restrito.
|
||||
|
||||
Decisões humanas preservadas: pares exatos das 6 oportunidades com âncoras/clusters; identidade/termos/contactos do operador; URLs/serviços/responsáveis institucionais; modelo/indicadores/baseline de sucesso. Não reabrir aceitação das fontes oficiais; não inventar pares, parâmetros ou score.
|
||||
|
||||
Ordem executável restante: 1)incorporar ensaio visual C43 e corrigir reprodução; 2)formulário/bindings/condições finais imobiliárias; 3)adaptadoresDUAT/transação; 4)gates e workflow segregado, sem autoaprovação; 5)continuar correspondências/cadastros/mapas, países/origem por formulário e operações documentais independentes. C37 continua pendente, sem repetir login do investidor neste lote; não iniciar jornadas educativas novas. Detalhes/ações por cadaID estão na vista integral.
|
||||
|
||||
Aceitação C43: gestor 17/MFA parte de/dashboard→cliques→rascunho→guarda→outra sessão→recupera→edita→arquiva→restaura; feedback/ID/filtro/histórico visíveis; outra conta 403; dossier privado e público intactos. CritériosAPI satisfeitos não encerram o critério de interface.
|
||||
|
||||
**Histórico preservado:** os blocos anteriores a C43/C44 abaixo conservam as suas fontes e provas. Os seus hashes/ready/contagens não certificam a revisão atual; a primeira adaptação imobiliária não equivale ao schema integral nem ao workflow.
|
||||
<!-- qa-dashboard-20261010:end -->
|
||||
|
||||
<!-- qa-independent-20261010:begin -->
|
||||
## Backlog corrente do lote independente — 10/10/2026
|
||||
|
||||
Não aguardar C37 para executar os itens independentes. C37/P0 conserva a confirmação visual do login e cenáriosA/B; nenhuma nova tentativa solicitada neste lote.
|
||||
|
||||
| Prioridade / requisito | Próxima correção executável e componentes | Dados/dependência concreta | Aceitação |
|
||||
| --- | --- | --- | --- |
|
||||
| P1 GEO-02/03; C38 |Resolver 31 distritos/157 postos com crosswalk versionado por fonte/edição/pai; reutilizar geo_locations/geo_geometries, TerritoryGeometryImport/Preview/Fields/ProvinceSelect. Conferir todos os consumidores na lista existente. |Exemplar/linha que distingue Cidade De Maputo agregado e Mandie 2 linhas; restantes 188 nomes no relatório. Sem polígonos de localidade disponíveis no exemplar retido. Fonte aceite não exige nova certificação. |Prévia mostra origem/pai/candidato único; aplicar/replay0duplicados/GETresultado; mapa e campo guardar/reabrir/limpar descendentes respeitam perfil; sem divulgar limites privados automaticamente. |
|
||||
| P1 COUNTRY01–04/06–07, STATS01 |Verificar os seletores/validações condicionais ainda não demonstrados na interface, por formulário da cobertura; corrigir qualquer lacuna executável API/persistência adicional. Reutilizar249ISO, DeclaredOriginStatistics e payloads existentes. |Backend específico renovado; não inferir origem da residência/fiscal/fundos. Valores antigos sem origem ficam desconhecidos. |POST/GET/nova sessão/filtro/tabela/estado vazio próprios; ZZ422sem escrita, revisão409, outra conta 403/404; conjuntos interests/desk/businesses separados. |
|
||||
| P0/P1 documental LAND01/PART01/LIB01–02/SCAN01; C39/C40 |Prosseguir restantes consumidores/tipos/perfis, renderização PDF/impressão e derivadas próprias; reutilizar VerifiedDocumentBinary, documents, scanner/jobs e auditoria. Implementar aplicação documental ativa autenticada antes de futura importação ativa; CLI permanece QA-only. |PDF/PNG/JPEG/fundiário/parceria e loteQA têm prova real; oportunidades PDF/PNG/3WebP e biblioteca/Balcão conservam provas anteriores. Arranque/reboot/assinaturas ClamD e GD PHPativo são pendências operacionais específicas. |Permissão antes dos bytes; scan do hash de cada original/derivada; original preservado; partilha/revogação recuperadas; incorreto/quarentena recusados; PDF efetivamente visível e impressão correta. Não generalizar prova de um consumidor. |
|
||||
| P1 OP03/04; C08 |Implementar adaptador do schema normativo110defs para OpportunityManagement/management_draft/version; validar Create/Patch por módulo antes de novas transições. Reutilizar OpportunityResources, serviços negócio/fundiário/documentos/territórios e audit_events. Em seguida revisão/devolução/aprovação/publicação com segregação autor/revisor/aprovador e versão aprovada. |Contratos9preparados; UUID de exemplo não renumera bigint. Estado fiscal/jurídico/decisão externa distintos de validação interna. Perfis adicionais exigem contrato aprovado da especificação, não campos inventados. |Rascunho incompleto permanece guardável; revisão precisa indica campo/regra/fonte; transição indevida403/422/409; alteração material invalida apenas aprovação da versão afetada; reviewer não se autoaprova; publicação separada; GET/auditoria/replay preservam IDs/história e permissões. |
|
||||
| P0 visual C37 + I1–I3 |Manter ensaio manualA/B existente em18097 com MFA sintético, sem credenciais na documentação. Incorporar cada resultado e corrigir falhas antes de fixar candidata. |Confirmar conta 18 no MFA e recuperar novo cookie/session sem alterar chave nem conta 13. Não bloqueia tarefas independentes acima. |A/B criação→gravação→nova sessão→aplicabilidade→ferramenta→resultado no dossier→Balcão→acompanhamento; tarefa paralela, ausência de mistura/acesso indevido;19 etapas/5fases mantidas. |
|
||||
| P1 OPS01 |Após critérios visuais e correções, fixar revisão e renovar backup/restauro/compatibilidade; gerar provas verdadeiras para a ordem consolidada. |Ativo fora da autorização; confirmar ledger/contagens atuais por leitura futura, não repetir instalações. |Recursos030000/percurso190000/flag/importação1→2→3/publicação separados; reversão GIS3→2→1 preserva catálogo/originais/receipts e recusa descendentes/uso/publicação. |
|
||||
|
||||
Concluído neste lote apenas o âmbito específico demonstrado por prova:32correspondências, recuperação documental cifrada e controlos do loteQA; nenhum requisito transversal inteiro encerrado. Workflow/contratos preparados documentalmente continuam não implementados funcionalmente. Cinco fases/19 etapas e acesso direto a ferramentas preservados; nenhuma nova jornada educativa. [Matriz única](matriz-requisitos.csv), [evidências por consumidor](cobertura-percursos-i0-i3.md) e [plano](plano-ativacao.md).
|
||||
|
||||
Complemento C41: novo PDF válido de 1página já gravado/escaneado/recuperado no servidor 18097, com5binários anteriores preservados. O placeholder antigo não é prova de renderização; no ensaio visual usar o novo recurso identificado em resources-real-http-qa.json, sem substituir/apagar o antigo. C42 retirou passwords fixas do script e rodou os dois atoresQA auxiliares; manter leitura das credenciais somente no ficheiro privado local, sem enviar na conversa. Não afeta conta 18/MFA/C37.
|
||||
|
||||
**Versões anteriores preservadas:** as secções abaixo documentam os instantes anteriores. 361 e2/39/179 são a baseline 09/10; o estado cartográfico corrente deste lote é393 e0/31/157. Ready/hashes/contagens antigas não certificam esta revisão. C37 mantém a sua confirmação visual pendente, sem bloquear as frentes independentes.
|
||||
<!-- qa-independent-20261010:end -->
|
||||
|
||||
## C37 — causa confirmada; seleção de conta QA corrigida — P0, visual pendente
|
||||
|
||||
O pedido de reabertura referia a conta esperada18. A captura manual21:22–21:24UTC demonstra que foi selecionada a conta13 restaurada. O MFA foi alcançado, mas a autenticação nunca se completou. Causa concreta: credenciais MFA da conta13 cifradas pela chave de origem, incompatíveis com a chave estávelQA; a conta18 sintética não foi desafiada nessa tentativa. Nenhuma chave, MFA ou conta13 foi alterada para contornar o problema.
|
||||
|
||||
@@ -1,5 +1,127 @@
|
||||
# Casos de reprodução e lacunas observáveis
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Casos adicionais deste lote — sem reiniciar o inventário
|
||||
|
||||
| Caso / requisito | Reprodução e resultado esperado | Observado, causa e correção | Prova / estado |
|
||||
| --- | --- | --- | --- |
|
||||
| C59 — PROFILE-31 / OPS-01 | Gestor confirmado com MFA consulta e aplica apenas 040000, após030000; repetir o mesmo pedido. Esperado:prévia, dependências e provas, uma instalação e um recibo, sem contas/grants automáticos. | A migração já tinha guarda, mas faltava rota/serviço/interface autenticados. Reutilizados Fortify, audit_events e cerimónia da dupla. Plano obsoleto/chave alterada409; falta de provas/dependência422; Auditor/Promotor403. | Testes de feature em schema vazio, repetição e bootstrap separados; servidor18097 já instalado:noop, replay idêntico, novaMFA e negações. Visual pendente. |
|
||||
| C60 — PROFILE-31 / recuperação | Em base isolada e schema vazio, down→instalar; depois impedir insert na auditoria e instalar. Esperado:reversão total deDDL/ledger se não houver recibo; retry possível; nenhum drop com uso/histórico. | No desenvolvimento do incremento foi introduzida remoção duplicada da colunausers; o ensaio encontrou Undefined column. Corrigida. Também reforçado down para contexto/organização sem autor e revisões de sessão; falha de auditoria devolve503 e não deixa schema/ledger parcial. | Testes específicos de reinstalação e falha de auditoria; históricos/contextos conservados. Não executado down no servidorQA utilizado, nem no ativo. |
|
||||
| C61 — OP-03 / publicação | Recuperar #30,#33,#32 e comparar estado da preparação, âmbito efetivo e catálogo. Esperado:restrito,público,aprovado sem disponibilização respetivamente. | Rótulo genérico “Publicado”/estado published não explicava o âmbito. Novo label/projeção consulta política efetiva do catálogo; botões distinguem disponibilização restrita de publicação pública. | JSON/Inertia e política pública verificados; nenhuma fuga pública observada. Perceção/ação visual pendente. |
|
||||
| C62 — PROFILE-31 / bootstrap | Propor duas contas distintas e terceiro gestor; candidato com MFA cifrado ilegível ou Auditor. Esperado:recusa controlada e nenhum titular criado. | A cerimónia conferia presença/confirmaçãoMFA, sem comprovar leitura do segredo no ambiente. Agora exige cifragem válida/estruturaMFA e recusa Auditor, mesmo com flags ainda desabilitadas. Sem key:generate/fallback ou remoção deMFA. | Teste422 e dupla permanece vazia; inicialização positiva com identidades sintéticas distintas. Não substitui C37/login visual. |
|
||||
| C63 — ambiente de testes / documental | Executar IndependentDocumentOperationsTest com o PHPCLI padrão. Esperado:GD disponível para gerar as imagens do ensaio. | Dois testes falharam por imagecreatetruecolor inexistente noCLI; o servidorQA já tinhaGD. Runner passa a carregar explicitamente a mesma extensãoGD para o processo de testes. | Regressão posterior27/600 semskips, scans reais e testes finais separados. Não atribuir esta falha ao servidorweb ou declarar GD ativo verificado. |
|
||||
| C64 — PROFILE-31 / recuperação e proteção | Em schema isolado com perfis habilitados, retirar a coluna de revisão e tentar desativar MFA/excluir conta. Esperado:sem alteração de identidade, recuperação e logout acessíveis. | Esquema parcial podia tornar o serviço de perfis indisponível e deixar middleware de proteção passar à frente. Agora recusa escritas com503, preserva MFA/conta e deixa o instalador apresentar a incoerência409; logout permanece permitido. | Teste específico, schema parcial não aplicado ao servidorQA nem ao ativo. Preserva leitura permitida e não cria bypass de recuperação. |
|
||||
|
||||
C55 continua corrigida por operação:PDF eagoraDOCX/XLSX excluídos dão404, scanpendente continua423; restore preserva original. C37/C43/QA21/A-B permanecempendentes. Cada prova mantém API/persistência/interface separadas.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Reprodução e evidências adicionais — mesmo inventário
|
||||
|
||||
| Caso / requisito | Passos e esperado | Observado / causa | Correção e estado |
|
||||
| --- | --- | --- | --- |
|
||||
| C55 — OP-02 | QA21→PDF limpo→exclusão lógica→GETdownload. Esperado: recurso indisponível, sem atribuir falha de scan. Restaurar e recuperar original. | HTTP423/quarentena porque download juntava row inexistente à condição de scan. | Separa ausência/exclusão404 de quarentena423; teste específico e ensaioPDFweb18097. Originais/hash preservados; visualpendente. Prova da falha `opportunity-semantic-documents-http-qa-attempt1.json`. |
|
||||
| C56 — OP-04/PROFILE-25 | Promotor21 abre editor de categoria; campos de núcleo devem ter um único valor/controlo e erros associados. | Revisão do código: formulário legado e contrato canónico duplicavam título/território/campos, com valores independentes. | Contrato canónico único no modo perfil; formulário legado continua na gestão própria. BuildQA/TS e payloadAPI; observação visualpendente. |
|
||||
| C57 — OP-04/PROFILE-28 | Guardar fim anterior ao início/ciclo/serviços incompletos/documento estrangeiro/comunidade sem consulta/financeiros incompletos; recuperar noutra sessão; submeter. | Regras anteriores aceitavam objetos opacos sem semântica suficiente. | 12negativas422 com caminho de campo; rascunhos recuperados; seis cenários completos submetidos/revistos. Não extrapolar a regras jurídicas ainda sem âmbito/gate. |
|
||||
| C58 — OP-03/PROFILE-19/12 | Parecer com destinatário técnico/jurídico; responder outro ator; aprovar pendente. Depois atribuir publicação e tentar outro Publicador. | Fluxo anterior não tinha destinatário/resposta/fila completa nem atribuição editorial. | #30destinatário29/resposta/tarefa;outro403/aprovação422; #33filaeditorial29/outro25publica403/designado29publica. Sem gateautoaprovado/prazofictício; visualpendente. |
|
||||
| 190000 — OPS-01/PROFILE-31 | Comparar ledger/duascolunas/definição QA eativo, distinguir migraçãoBalcão07 depercurso09. | Plano anterior omitia recurso ainda ausenteativo. | 09/190000confirmadaQA e ausenteativo, necessária/nãosubstituída; seqüência autenticada recolocada. Nenhuma execuçãoativa. |
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Reproduções adicionais do incremento — 10/10/2026
|
||||
|
||||
| Caso / requisito | Reprodução e esperado | Falha ou risco encontrado / causa | Correção e evidência separada |
|
||||
| --- | --- | --- | --- |
|
||||
| C44 (complemento) — OP-04/PROFILE-28 | Guardar rascunho especializado parcial → completar → submeter; condições da categoria devem ser verificadas. | Submit permanecia bloqueado pelo contrato não integrado; a extração dos Create não conservava as condições `allOf` do schema original. | Adapter recupera condições, usa validação Draft2020-12 e recursos existentes. Dez rascunhos recuperados; seis submissões aceites/ quatro recusadas por perfil. Semântica integral e visual pendentes. |
|
||||
| C50 — OP-04 | Imobiliário `sale`, `price:null`: guardar rascunho permitido, submeter recusado. | Campo nullable e `required` não garantiam preço positivo para venda. | Regra explícita positive; oportunidade19: draft aceite, submit422, preço corrigido e submit200. |
|
||||
| C51 — PROFILE-25 | Após submissão, continuar pela visualização; edição deve ficar fechada. | Código da ação recarregava `/edit`, que passa corretamente a403 após submissão; o primeiro acesso HTTP Validador a essa rota confirmou a restrição, não uma falha de autorização. | Ações navegam `/view`; edição privada tem link apenas autorizado. API/props/build verificados; transição visível ainda pendente. |
|
||||
| C52 — PROFILE-08/12/25 | Publicada → pedido de alteração → pesquisar título privado/estado → recuperar; ficha pública não pode mudar antes de aprovação. | Consulta/contagens usavam só título e estado públicos, ocultando a posição da proposta privada. | Consulta já autorizada usa estado/título de trabalho, preservando publicação. Teste específico do filtro/contagem e HTTP público19 original até suspensão. Visual pendente. |
|
||||
| C53 — GEO-03/PROFILE-12 | Duas localizações, principal em segundo lugar → aprovar/publicar; território apresentado deve ser o principal. | Revisão de código encontrou uso da primeira posição da lista, que não define a principal. | Filtra `is_primary`; HTTP oportunidade28 apresenta ID/distrito principal correto. Teste específico isolado; mapa visual pendente. |
|
||||
| C54 — PROFILE-19 | Revisão → correção → resubmeter; ou revisão → rejeitar. Esperado: tarefa anterior concluída, nova tarefa sem atribuição presumida. | Código mantinha revisão antiga aberta e podia herdar atribuição numa nova versão. | Conclui tarefas com motivo/histórico; limpa atribuição na submissão. HTTP28: correção/resubmissão/publicação/suspensão, nenhuma tarefa aberta ao final; HTTP29: rejeição fecha revisão. |
|
||||
| C37 / C43 | Login investidor18; dashboard Promotor21 e ciclo de ações. | Não houve demonstração visual atual no navegador. | Mantidos pendentes, roteiro e conta preparados; não fechar por HTTP, props ou total de testes. |
|
||||
|
||||
Rejeições esperadas adicionais: motivo curto422, confirmação de publicação falsa422, hash de prévia errado409, self/multirole403, recurso pelo rejeitor/organização403, publicação fundiária com autorização externa pendente422. Provas privadas específicas preservam sequência/estado, sem segredos.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
<!-- qa-profiles-v1-20261010:begin -->
|
||||
## Reproduções adicionais de perfis — 10/10/2026
|
||||
|
||||
C37 permanece pendente; não houve nova tentativa da conta 18 nem fecho visual. C43/C44 preservados.
|
||||
|
||||
| Caso / requisito | Reprodução | Esperado | Observado e causa | Correção/prova | Pendente |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| C45 — PROFILE-05/06/25, AUD-GEO-03 | Promotor 21: dashboard → Nova → seletor/mapa; comparar chamadas territoriais. | Perfil autorizado consulta, grava e recupera território/geometria. | Consumidor chamava rotas management reservadas a manage-portal. Inspeção de código/rotas; nenhuma captura visual anterior. | Alias autorizados reutilizam serviços: 11 províncias, Cabo Delgado guardada/recuperada, uma área API. profiles-v1-current-version-qa.json. | Desenho/cliques por perfil e restantes consumidores. |
|
||||
| C46 — PROFILE-06/07/10/29 | Gestor sem perfil: API administrativa de contexto da oportunidade privada; território, prévia de origem e subjects. | Allowlist legada não revela/edita o privado v1. | Rotas auxiliares tinham apenas can:manage-portal, sem política do registo. Lacuna identificada em código, sem realizar escrita indevida. | Middleware cobre aliases/payload; subjects/dashboard/catalog usam âmbito. Testes 403/lista vazia. | Outras relações/filas e âmbito provincial. |
|
||||
| C47 — PROFILE-04 | Rejeitar pedido com approved_types=[] e conferências false. | Rejeição fundamentada sem inventar conferência. | Ensaio respondeu 422: approved_types required rejeitava array vazio. | present|array; não-vazio só na aprovação; accepted_if só approved. Teste rejeita sem conceder e preserva conferência false; decisão própria 403. | Decisão na interface. |
|
||||
| C48 — PROFILE-15/16/24/26 | Titular 30: DELETE MFA, DELETE conta, PATCH email. | Manter MFA obrigatório, dupla e histórico. | Regra específica da identidade privilegiada v1 não existia. Lacuna identificada em código; nunca se desativou MFA para ensaio. | ProtectPrivilegedIdentity, 403 e auditoria; recovery Fortify noutra sessão/reuse negado; trigger DELETE testado. | Visual e recuperação institucional de emergência. |
|
||||
| C49 — PROFILE-10/29 | Conta sintética com papéis v1 e allowlist legada: abrir Nova legada, POST draft legado; depois escrita administrativa com Auditor. | Não contornar organização/tipo nem a restrição do Auditor pela acumulação. | Exceção legada podia autorizar escrita mais ampla. Identificada em código, sem usar conta ativa ou criar dados fora do âmbito. | Decisão documentada antes da correção; legacyWriteAllowed usa histórico; 403 na criação e escrita, atalho legado ausente nas props. Gestor sem atribuição v1 preservado. | Interface, conciliação dos gestores reais e consumidores restantes. |
|
||||
|
||||
Submissão real do rascunho 15 → 422 por contrato Create ausente é lacuna funcional mantida, não falha de MFA/autenticação. A fixture de revisão não demonstra submissão integral. Evidências por critério em profiles-v1-final-qa.xml, http-qa, recovery-audit, current-version e preservation; JSON/HTML não substitui interface.
|
||||
|
||||
<!-- qa-profiles-v1-20261010:end -->
|
||||
|
||||
> Registos anteriores preservados como histórico. Os estados correntes são os da matriz e do bloco mais recente acima; uma entrega posterior não altera retroativamente a evidência anterior.
|
||||
|
||||
<!-- qa-dashboard-20261010:begin -->
|
||||
## Casos adicionais C43/C44 —10/10/2026
|
||||
|
||||
**C43 — causas encontradas no código/compilação:**(1)AppSidebar mostrava só o primeiro filho de cada grupo, descartando oportunidades; (2)menu Painel apontava /investidor mesmo para gestor; (3)dashboard de gestão colocava criação no fim como Criar oportunidade, sem rótulo Nova oportunidade. A secção de gestão só é devolvida pelo backend a contas verificadas em portal.managers; o seu ocultamento para investidor é autorização esperada, não motivo para conceder perfil administrativo.
|
||||
|
||||
Correção QA: atalhos Gestão de Oportunidades/Nova oportunidade no início do dashboard, sidebar/menu agrupado; Painel da gestão→/dashboard, dossiers→/investidor. Nova ação archive conserva o rascunho em status archived/deleted_at=null; delete/exclusão lógica mantém o contrato anterior. Restauro volta a draft, ID/histórico intactos, sem publicação. Código usa flags false por omissão; QA usa assets `public/build-qa-opportunities`, versão Inertia desse manifesto. **`public/build` ativo manteve SHA 75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94.** Recursos 030000 não são requisito para navegar/criar rascunho básico.
|
||||
|
||||
**C44 — progresso específico:**primeiro adaptador imobiliário aceita campos fornecidos nas 24 propriedades realEstatePatch, validando tipos/enums/limites/UUID e recusando campos desconhecidos/de resposta. Guarda/merge privadamente no management_draft atual. Ainda faltam formulário especializado, vínculos canónicos, condições finais/Create/Response, DUAT/transação e workflow. Nenhuma coleção/tabela paralela, renumeração ou decisão externa criada. O.source.json conserva o retrato histórico da extração, não certifica o estado runtime atual.
|
||||
|
||||
C43, reprodução de código/assets: seguirPainel do catálogo com gestor→/investidor; na sidebar, grupoGestão→primeiro filho/interests apenas; no /dashboard a criação está no fim e tem label Criar oportunidade. Esperado: gestor chega ao seu dashboard e encontra Gestão/Nova sem URL decorado; investidor conserva área privada. Observado: links omitidos/destino partilhado/nome diferente. Evidência do utilizador ainda sem URL/conta; não se afirma que o seu perfil é o motivo. Também havia somenteExcluir rascunho no rascunho inicial, com delete→deleted_at em vez dearchive→status archived; corrigido separadamente sem alterardelete.
|
||||
|
||||
C44, reprodução anterior por inspeção: categoriareal_estate disponível, mas payloadreal_estate não fazia parte dos campos validados/persistidos. Esperado: guardar dados parciais e recuperar privadamente. Correção limitada: adaptador 24 propriedadesPatch; cinco negativos HTTP reais 422 sem mutação. Não se inventou ensaioHTTP pré-correção nem se declarou o contrato integral concluído.
|
||||
|
||||
- `dashboard-active-readonly.json`: configuração/contas elegíveis/instalação/manifesto ativo, sem boot HTTP/escrita de sessão no ativo.
|
||||
- `dashboard-opportunities-http-qa.json`: links obtidos do dashboard→gestão→create→201→GET→logout/nova sessão→GET→PUT→archive→filtro archived→restore→GET. Oportunidade QA 13, ID estável/quatro eventos, 12 originais e relações/documentos/dossiers preservados; 6 publicadas mantidas; outra conta 403.
|
||||
- `dashboard-manager-mfa-http-qa.json`: gestor 17, MFA em duas sessões→dashboard→gestão→recuperação/reload→logout; 0 alterações de registos. `dashboard-navigation-catalogue-qa.json`: menu Painel da gestão→/dashboard, dossiers privados→/investidor, atalhos únicos e ausência de gestão para investidor.
|
||||
- `real-estate-draft-http-qa.json`: outra oportunidade QA 14, rascunho imobiliário parcial no draft existente→nova sessão→patch merge→cinco 422 sem mutação→outsider 403; schema normativo SHA 9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0.
|
||||
- `dashboard-real-estate-qa.xml`: casos nomeados Dashboard OpportunityAccessTest/OpportunityManagementTest/OpportunityRealEstateDraftTest, 8 ensaios/201 asserções. Typecheck, build QA e PHPStan dirigidos passaram; contagem não substitui os critérios visuais.
|
||||
- `resource-originals-preservation-http-qa.json`: cinco binários anteriores preservados e novo PDF válido recuperado/parseado, 0 novos recursos. Prova ClamD real anterior mantida; não afirma novo scan das derivadas nem renderização.
|
||||
- `dashboard-matrix-reconciliation-qa.json` e `pedidos-abertos-projection.json`: fontes/trechos/IDs/versões preservados, 3354 requisitos/linhas e 5830 ocorrências; 3349 abertos apresentados uma vez cada. Estado AUD-OP-04 passa de não implementado a parcial; nenhum requisito global encerrado.
|
||||
|
||||
**Histórico preservado:** os blocos anteriores a C43/C44 abaixo conservam as suas fontes e provas. Os seus hashes/ready/contagens não certificam a revisão atual; a primeira adaptação imobiliária não equivale ao schema integral nem ao workflow.
|
||||
<!-- qa-dashboard-20261010:end -->
|
||||
|
||||
<!-- qa-independent-20261010:begin -->
|
||||
## Casos adicionais do lote independente — 10/10/2026
|
||||
|
||||
### C38 — correspondências provinciais impediam descendentes; ambiguidade nominal bloqueava lote independente — P1
|
||||
|
||||
Requisitos AUD-GEO-01/02/03. Reproduzir préviaQA com o exemplarINE2019 e cadastro aceites: Maputo/Maputo City não correspondem literalmente a Maputo Província/Maputo Cidade; Cidade Da Matola não corresponde a CIDADE DE MATOLA. Esperado: equivalência explicitamente versionada por exemplar/pai, sem alterar nomes/códigos/IDs/hierarquia. Observado baseline 9/120/232 e2/39/179 pendentes; descendentes sob os pais Maputo ficam não resolvidos. Causa: correspondência apenas nominal exata, sem crosswalk dessas variantes concretas.
|
||||
|
||||
Correção: três entradas explícitas com códigos de ambos os sistemas preservados, hash do exemplar/workbook e pai obrigatórios. Descendentes exatos tornam-se elegíveis; via gestor/MFA/importação18097 foram inseridas 2 províncias+8 distritos; primeira execução ficou interrompida na prévia de postos por Mandie com 2 candidatos do cadastro. A classificação inicial correspondence_conflict bloqueava outros postos inequívocos. Corrigida para ambiguous_name pendente quando são nomes iguais sem crosswalk explícito; conflitos de uma correspondência explícita continuam impeditivos. Continuação reutiliza as 10anteriores e insere 22 postos, sem repetir originais. Mandie corresponde a duas linhas reais do workbook e não foi fundido/escolhido por código presumido.
|
||||
|
||||
Evidência: preservationQA393/32 novas/361 originais/1990catalogueigual; HTTPconsumidores32gestor/0 públicos; sete casos QAincluem edição/pai/conflictos/replay/reversão3→2→1e recusa de pai com descendente ativo sem FK. Prévia e mapas na interface pendentes. Lacunas0/31/157,188 nomes listados; CidadeDeMaputo agregado não é um distrito urbano individual. Ativo não alterado.
|
||||
|
||||
### C39 — fundiário e sala de parceria recusam404 um original cifrado íntegro — P0 de recuperação
|
||||
|
||||
Requisitos AUD-LAND-01/PART-01/SCAN-01. QA de feature: criar caso fundiário/sala próprios; anexar PDF/PNG/JPEG, completar scan real/hash; reutilizar o mesmo documents canónico com storage_encryption Laravel e bytes cifrados privados; pedir GET /investidor/terra-duat/{id}/documentos/{attachment} ou /parcerias/salas/{id}/documentos/{attachment} pelo ator autorizado. Esperado200 com bytes originais, preservando autorização/scan/hash. Observado404 nos dois consumidores, reproduzido em document-independent-before-qa.xml. Causa: hash do ciphertext comparado diretamente ao SHA do original e binário devolvido sem VerifiedDocumentBinary.
|
||||
|
||||
Correção: serviço comum de decifragem/integridade/quarentena; selecionar metadata cifragem sem expor chave; conservar autorização original. Novos uploads registam size_bytes/uploaded_by antes ausentes, leituras fundiárias em audit_events existente. Evidência pós-correção: document-independent-qa.xml, PDF/PNG/JPEG originais reais/ClamD/perfis/partilha/revogação/GET/tamper404. Impressão apenas props200; renderizaçãoPDF/browser pendentes. Nenhum ciphertext/segredo exportado, nenhum original substituído/publicado, nenhuma migration/dado ativo alterado.
|
||||
|
||||
### C40 — --actor não comprova autorização nem autenticação de lote documental — P1
|
||||
|
||||
Requisitos AUD-LIB-02/OPS-01. Identificado por revisão do comando documents:import-batch: --actor antes só exigia IDexistente, sem can:manage-portal nem guarda de alvoQA para --apply. Não houve execução ativa nem alegação de exploração ativa. Esperado: ensaio de escrita isoladoQA com ator autorizado; aplicação ativa somente autenticada pelo mecanismo previsto. Correção: environmenttesting+SQLreal*_test:55441+loopback+URLvazia e User::can('manage-portal') antes de escrita. Ambienteproduction recusado mesmo apontado a uma baseQA; nomear ator não autentica.
|
||||
|
||||
document-batch-independent-qa.xml demonstra recusas sem DB/ficheiros novos, prévia privada/replay/dedup/hash/ClamDreal/perfis e conflito de identidade sem substituição. Aplicação documental ativa autenticada continua pendente; não adaptar CLIpara o ativo. Este limite não impede os ensaiosQA nem consulta/read-only. Nenhuma publicação automática.
|
||||
|
||||
### C41 — PDF sintético com cabeçalho não comprova legibilidade — lacuna de QA
|
||||
|
||||
Requisitos AUD-SCAN-01/OP-02. Revisão do fixture histórico de portal-resources-real-http-qa.py encontrou apenas cabeçalhoPDF e terminador; parser independente recusa-o sem startxref/estrutura. Esse ensaio demonstra upload/scan/hash/download dos bytes, não PDF legível ou renderizado. Não é diagnóstico de um documento oficial nem falha presumida do leitor do portal.
|
||||
|
||||
CorreçãoQA: gerador sintético válido de 1página, árvore de objetos/xref/texto; parser independente confirma original e variante de lote. Novos testes fundiário/parceria/lote reexecutados com esses bytes/ClamDreal. Anexado um novo recurso válido à oportunidade sintéticaQA12 no servidor 18097; original placeholder e PNG/3WebP anteriores preservados por hash, sem repetir instalação nem criar oportunidade/cartografia/jornada duplicada. Novo PDF download/ClamD/403outra conta/logout/nova sessão/recuperação passaram. Evidências document-types-fixture-qa.json/resources-real-http-qa.json/resource-originals-preservation-http-qa.json. Renderização visual PDF/impressão continua pendente.
|
||||
|
||||
### C42 — passwords fixas de atores sintéticos no scriptQA — corrigido, sem exposição ativa
|
||||
|
||||
Durante a leitura de um script legado apareceram passwordsQA fixas. Correção imediata com portal-resource-http-actors-qa.php e guarda real do destino: dois atores sintéticos auxiliares, passwords aleatórias privadas e remember tokens rodados. Diretório de sessõesQA validado; nenhuma sessão desses atores restante para invalidar. MFA mantido byte a byte, conta 18 preservada por hash da linha; nenhuma chave/conta/dado ativo alterado. ScriptHTTP passa a ler credenciais do ficheiro privado local; suporte a MFA normal permanece. Não repetir valores, cookies ou OTP em prova/captura. Evidência sanitizada resource-http-actors-rotation-qa.json; login/logout/nova sessão dos perfis auxiliares demonstrados no servidor pelo lote documental, separados do C37 investidor.
|
||||
<!-- qa-independent-20261010:end -->
|
||||
|
||||
## C37 — causa confirmada; seleção de conta QA corrigida — P0, visual pendente
|
||||
|
||||
O pedido de reabertura referia a conta esperada18. A captura manual21:22–21:24UTC demonstra que foi selecionada a conta13 restaurada. O MFA foi alcançado, mas a autenticação nunca se completou. Causa concreta: credenciais MFA da conta13 cifradas pela chave de origem, incompatíveis com a chave estávelQA; a conta18 sintética não foi desafiada nessa tentativa. Nenhuma chave, MFA ou conta13 foi alterada para contornar o problema.
|
||||
|
||||
@@ -1,5 +1,147 @@
|
||||
# Cobertura por percurso e consumidor — continuação QA
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Resultado atual por categoria — semântica e atribuições em QA, 10/10/2026
|
||||
|
||||
Este bloco é a evidência mais recente desta matriz; as secções anteriores abaixo permanecem como histórico. Estados antigos sobre objetos opacos, coordenação editorial e ausência de 190000 ficam confrontados com os resultados concretos deste bloco. **Código preparado e HTTP/persistência em QA não encerram interface nem funcionalidade ativa.** Não houve escrita na base ativa. C37 e aceitação visual permanecem pendentes.
|
||||
|
||||
Servidor **http://127.0.0.1:18097**, base **desk_restore_20261009_174726_test:55441**, Fortify e MFA normais. **Promotor QA 21** continua a conta de ensaio do dashboard. Não usar Auditor ou Superutilizador em seu lugar.
|
||||
|
||||
| Categoria / ID QA | Rascunho | Edição / recuperação | Submissão | Revisão | Publicação |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Investimento — #30 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Imobiliário — #31 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Fundiário/DUAT — #32 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Bloqueada: autorização externa pendente |
|
||||
| Venda de negócio — #33 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Pública, só teaser; prova editorial posterior |
|
||||
| Parceria de negócio — #34 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Franquia — #35 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Concessão — #36 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Financiamento — #37 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Fornecimento — #38 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Outros — #39 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
|
||||
As dez categorias recuperaram dados noutra sessão autenticada. As seis com contrato definido aceitaram os cenários semânticos documentados; as quatro restantes conservaram rascunhos, sem inventar um perfil modular para as submeter. Fundiário #32 separa aprovação interna da decisão externa. Venda #33 passou depois pelo percurso editorial público, sem divulgar resumo/dados privados.
|
||||
|
||||
**Campos e regras preparados:** cronograma com início/fim/responsável e dependências acíclicas; maturidade com os oito códigos normativos; viabilidade com cenário/indicadores/modelo e estudo, sem score de sucesso; serviços/título/entidade emissora/ocupação/avaliação imobiliários; parcelas com editor de vértices/anéis MultiPolygon, benfeitorias e consulta comunitária; constituição/beneficiário efetivo, exercícios existentes, ativos/passivos, obrigações laborais, licenças, NDA, governação e franquia. O contrato é elaborado no servidor e consumido pelo editor, sem templates JSON divergentes no cliente. Objetos legados conservam chaves anteriores. O schema original de 110 definições mantém SHA `9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0`.
|
||||
|
||||
**Falhas corrigidas com prova:** 12 recusas 422 identificaram o campo e conservaram o rascunho inválido: fim anterior ao início, ciclo, falta de cenário/modelo, serviços incompletos, título de outra oportunidade, comunidades sem consulta, financeiros insuficientes e contingências não declaradas. Erros `canonical.<campo>` são devolvidos pela API e ligados aos controlos; a posição/clareza visual ainda não foi observada. O formulário núcleo duplicado foi retirado do modo perfil; gestão legada mantém o seu acesso próprio.
|
||||
|
||||
**Filas e perfis:** revisão, preparação editorial, recurso e tarefas atribuídas reutilizam `tasks`, com ligação explícita ao bigint da oportunidade. #30 pediu parecer a 29: o destinatário errado recebeu403, a aprovação com parecer pendente422; a resposta foi guardada e concluiu a tarefa, sem aprovar o gate automaticamente. #33: coordenador editorial25 atribuiu a29; tarefa recuperada na fila; outro Publicador403; o designado publicou. Papéis/âmbitos/independência/responsabilidades são verificados no servidor; revogação após aprovação bloqueia publicação no teste específico. Responsabilidades atribuídas neste ensaio são sintéticas, sem competência institucional real inferida. SLA permanece `unconfigured` e `due_at=null`.
|
||||
|
||||
**Consumidores territoriais/documentais:** seis contratos recuperaram país MZ e uma localização com limite disponível; API retornou dois limites de contexto. O editor e os campos `location_id` consultam o território mais profundo e mantêm a parcela separada do limite administrativo. País FR do mercado do investimento foi gravado/recuperado; isto não encerra origem/filtros/estatísticas de todos os formulários. Originais dos seis casos permitiram21/24/25 e negaram organização estrangeira23(403). O download/prévia reutiliza `VerifiedDocumentBinary`, que verifica cifra/hash/scan e controla falhas; não altera os originais. #33 recuperou original da imagem e duas derivadas limpas pelo ClamD real. PDF válido de uma página, parser estrito, upload/scan ClamD real e SHA; download 21/24/25, negação 23(403); exclusão lógica→download404/opção removida; restauração→nova sessão→bytes/hash idênticos. Prova `opportunity-semantic-documents-http-qa.json`.
|
||||
|
||||
**Limites concretos:** correspondência entre titular/documento/cadastro e âmbito urbanístico exige evidência e decisão do gate, sem inferir autenticidade jurídica pela existência de um UUID. Limites urbanísticos e materialidade não são inventados. Unidades aplicáveis, Response integral e relações do §27 ainda precisam de reconciliação. O schema original exige pelo menos um exercício financeiro; casos com zero exercícios devem ter regra expressa reconciliada antes de serem declarados cobertos. A moeda obedece ao padrão do contrato; não existe aqui prova de catálogo completo ISO4217. DOCX/XLSX, partilhas e PDFs/exportações/derivadas sem prova específica continuam abertos. C37, A/B e todo o percurso visual por perfil permanecem pendentes.
|
||||
|
||||
**Preservação e compilação:** 18 conjuntos de linhas anteriores por ID/colunas originais preservados; MFA18 intacto. Manifesto ativo `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` preservado. Manifesto QA `0e610cd650d6b788edfdc9cbf697d125cb9330958914e13a8a8bbd14a781b395`; bundle próprio `build-qa-opportunities`, sem recompilar o ativo. TypeScript, PHPStan e testes específicos passaram; as contagens de testes não são usadas como critério de conclusão.
|
||||
|
||||
Mesma matriz: **3.386 requisitos / 3.386 linhas**, **6.838 ocorrências de origem**. IDs, fontes, critérios, versões divergentes e referências anteriores conservados.
|
||||
|
||||
| Estado canónico | Requisitos | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluÃdo e demonstrado | 6 | 7 |
|
||||
|
||||
Linhas principais: **AUD-OP-02/03/04, AUD-GEO-03, AUD-PROFILE-11/12/19/25/28/31 e AUD-OPS-01**, em [matriz-requisitos.csv](matriz-requisitos.csv). Nenhum desses critérios visuais foi fechado por API.
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Incremento canónico e workflow em QA — 10/10/2026
|
||||
|
||||
Servidor **http://127.0.0.1:18097**; destino guardado **desk_restore_20261009_174726_test:55441**. Zero escritas na base ativa. **C37 e toda a aceitação visual deste incremento permanecem pendentes.** Props da interface, API e persistência foram verificadas; nenhum clique ou renderização no navegador foi observado.
|
||||
|
||||
| Requisito na matriz única | Funciona / prova concreta em QA | Falta para fechar |
|
||||
| --- | --- | --- |
|
||||
| AUD-OP-04; AUD-PROFILE-28 | Dez categorias guardam rascunhos incompletos e recuperam noutra sessão MFA. Seis passam o contrato estrutural: investimento, imobiliário, DUAT, venda, parceria e franquia. Adapter do mesmo schema de 110 definições; condições `allOf` originais recuperadas, IDs existentes mantidos. | Semântica e editores dos objetos incompletos do §25; contrato Response integral; quatro perfis modulares aprovados; demonstração visual por categoria. |
|
||||
| AUD-OP-03; PROFILE-08/11/12 | Promotor 21 criou a oportunidade **18**, submeteu; Validador 24 solicitou correção; 21 corrigiu/resubmeteu; 24 reviu gates e aprovou; Publicador 25 publicou com visibilidade interna. O número 18 identifica a oportunidade, não a conta do investidor C37. | Visual integral, todas as categorias, pareceres/incidentes e coordenação editorial completa. |
|
||||
| PROFILE-09/10/12/21 | Imobiliário **19**: rejeição fundamentada por 24 → recurso reaberto pelo revisor independente 29 → nova aprovação 24 → publicação pública 25 → alteração privada sem alterar a ficha pública → suspensão com GET público 404. Autor Prom+Val+Pub e aprovador Val+Pub receberam 403. | Visual, filas institucionais e competências reais; suspensão/retoma e demais ações editoriais por todos os perfis. |
|
||||
| PROFILE-19/21/22 | Tarefas existentes em `tasks`, vínculo à oportunidade por origem/ID/correlação; atribuição explícita, motivos e decisões recuperáveis. Correção/rejeição concluem revisão; resubmissão não herda atribuição; suspensão conclui tarefas pendentes. Recurso do próprio rejeitor e membro da organização negados. SLA `unconfigured`, responsável e prazo iniciais nulos. | Filas completas, reatribuição/disponibilidade, coordenação editorial e política SLA aprovada. Não foram inventados responsáveis nem prazos. |
|
||||
| COUNTRY-01; GEO-03; OP-02 | Localização MZ/Cabo Delgado/distrito e documentos ligados ao mesmo contrato recuperados nas seis categorias. Oportunidade **28** publicou usando a localização principal mesmo em segundo lugar na lista. Documentos e imagem/duas derivadas verificados com ClamD real e hashes correspondentes. DUAT **20** aprovado internamente não publicou: 422 por autorização externa pendente. | Outros países/formulários, origem/filtros/estatísticas transversais, mapas renderizados, 188 correspondências cartográficas e operações documentais restantes. |
|
||||
| PROFILE-30; DASH-01 | Conta **Promotor QA — conta sintética 21**, MFA normal confirmado, organização e tipos autorizados. Dashboard devolve navegação do Promotor. Permissões temporárias dos testes foram revogadas e uma nova sessão 21 recuperou o dashboard. | Dashboard → formulário → guardar → nova sessão → workflow e resultado visíveis. Auditor ou Superutilizador não substituem este ensaio. |
|
||||
|
||||
Os requisitos acima continuam **parciais**: nenhum passou a concluído pelo incremento. O detalhe está no [relatório do workflow](workflow-oportunidades-qa.md); fontes, versões, critérios e percursos permanecem na [mesma matriz](matriz-requisitos.csv).
|
||||
|
||||
| Estado canónico | Requisitos / linhas da matriz | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 6 | 7 |
|
||||
|
||||
Total: **3.386 requisitos = 3.386 linhas**, **6.838 ocorrências**, **3.380 requisitos abertos**. As referências e versões anteriores foram preservadas.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
<!-- qa-profiles-v1-20261010:begin -->
|
||||
## Cobertura após perfis v1 — 10/10/2026
|
||||
|
||||
I0–I3 permanecem em QA. Cinco fases e 19 etapas preservadas, sem renumeração. C37, A/B e C43 visuais continuam pendentes. A autorização organizacional reutiliza OpportunityManagement, Resources, Fortify e entidades existentes; nenhuma jornada educativa nova foi iniciada.
|
||||
|
||||
- Interface: preparada e compilada, sem observação no navegador. Roteiro manual de perfis inclui acessos permitidos/negados; A/B preservam o roteiro existente.
|
||||
- API: links descobertos no dashboard, rascunho 15 recuperado noutra sessão, coedição 22 e negações por tipo/organização/múltiplos papéis. Seletores autorizados e geometria do Promotor funcionam; submissão integral permanece bloqueada por contrato incompleto.
|
||||
- Persistência: autor, ID, versão e histórico preservados; Cabo Delgado recuperada com o mesmo ID/nome. Pedido de Promotor, comprovativos, auditoria, revogação e dupla demonstrados nos escopos descritos.
|
||||
- Documentos: quatro comprovativos sintéticos com ClamD real e hash recuperável; não generalizar às operações restantes. Originais anteriores preservados em 18 tabelas.
|
||||
- Ativo: exclusivamente READ ONLY, sem instalação/flag de perfis. 020000 instalada; recursos/percurso ainda ausentes. Mesma matriz: 3.386 requisitos, 6.838 ocorrências.
|
||||
|
||||
Coordenadores, pareceristas, suporte e recurso estão explicitamente mapeados na reconciliação. Filas, decisões, SLA e incidentes funcionais continuam parciais ou não implementados no backlog. 393 geometrias privadas QA não encerram a integração visual; permanecem 31 distritos/157 postos nomeados.
|
||||
|
||||
<!-- qa-profiles-v1-20261010:end -->
|
||||
|
||||
> Registos anteriores preservados como histórico. Os estados correntes são os da matriz e do bloco mais recente acima; uma entrega posterior não altera retroativamente a evidência anterior.
|
||||
|
||||
<!-- qa-dashboard-20261010:begin -->
|
||||
## Cobertura adicional: dashboard e draft imobiliário —10/10/2026
|
||||
|
||||
- `dashboard-active-readonly.json`: configuração/contas elegíveis/instalação/manifesto ativo, sem boot HTTP/escrita de sessão no ativo.
|
||||
- `dashboard-opportunities-http-qa.json`: links obtidos do dashboard→gestão→create→201→GET→logout/nova sessão→GET→PUT→archive→filtro archived→restore→GET. Oportunidade QA 13, ID estável/quatro eventos, 12 originais e relações/documentos/dossiers preservados; 6 publicadas mantidas; outra conta 403.
|
||||
- `dashboard-manager-mfa-http-qa.json`: gestor 17, MFA em duas sessões→dashboard→gestão→recuperação/reload→logout; 0 alterações de registos. `dashboard-navigation-catalogue-qa.json`: menu Painel da gestão→/dashboard, dossiers privados→/investidor, atalhos únicos e ausência de gestão para investidor.
|
||||
- `real-estate-draft-http-qa.json`: outra oportunidade QA 14, rascunho imobiliário parcial no draft existente→nova sessão→patch merge→cinco 422 sem mutação→outsider 403; schema normativo SHA 9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0.
|
||||
- `dashboard-real-estate-qa.xml`: casos nomeados Dashboard OpportunityAccessTest/OpportunityManagementTest/OpportunityRealEstateDraftTest, 8 ensaios/201 asserções. Typecheck, build QA e PHPStan dirigidos passaram; contagem não substitui os critérios visuais.
|
||||
- `resource-originals-preservation-http-qa.json`: cinco binários anteriores preservados e novo PDF válido recuperado/parseado, 0 novos recursos. Prova ClamD real anterior mantida; não afirma novo scan das derivadas nem renderização.
|
||||
- `dashboard-matrix-reconciliation-qa.json` e `pedidos-abertos-projection.json`: fontes/trechos/IDs/versões preservados, 3354 requisitos/linhas e 5830 ocorrências; 3349 abertos apresentados uma vez cada. Estado AUD-OP-04 passa de não implementado a parcial; nenhum requisito global encerrado.
|
||||
|
||||
Gestão desde/dashboard: API/destinos reais/gravação/novasessão/recuperação/arquivo/restauro demonstrados; interfacepending. Formulário especializado imobiliário ausente; 24 campos dePatch são adaptaçãoAPI parcial. Nenhuma cobertura cartográfica/países/origem/documental anterior promovida por esses ensaios. Gestor 17/MFA distinto do investidor 18; C37 eA/B visuais permanecem abertos.
|
||||
|
||||
**Histórico preservado:** os blocos anteriores a C43/C44 abaixo conservam as suas fontes e provas. Os seus hashes/ready/contagens não certificam a revisão atual; a primeira adaptação imobiliária não equivale ao schema integral nem ao workflow.
|
||||
<!-- qa-dashboard-20261010:end -->
|
||||
|
||||
<!-- qa-independent-20261010:begin -->
|
||||
## Evidência independente por consumidor — 10/10/2026
|
||||
|
||||
QA web desk_restore_20261009_174726_test:55441 e ensaios separados investmoz_test:55441. C37/login investidor e A/B visuais continuam pendentes. Nenhuma sessão de navegador foi certificada; a nova autenticação HTTP deste lote foi do gestor cartográficoQA, distinta do investidor18.
|
||||
|
||||
**Países — provas renovadas por formulário na tabela existente abaixo:** countries-independent-qa.xml contém os métodos InvestmentWorkspaceTest (perfil/empresa/manifestação/CRM), PortalAuditContinuationTest (manifestação pública/quatro tipos de apoio/catálogo/Balcão territorial), PortalAuditNextLotTest (wizard negócios e fundiário), PortalOriginCompatibilityTest (origem legada) e InvestorGuidanceTest (Balcão). countries-forms-independent-qa.xml contém especificamente BusinessMarketplacePublicTest::test_interest_is_limited_to_published_business_and_deduplicated_idempotently, InvestmentPortfolioTest::test_personal_journey_is_isolated_and_retries_do_not_duplicate_interest_or_event, PortalAuditConsumerLotTest::test_all_three_desk_forms_save_recover_countries_and_support_authorized_corrections e PortalAuditContinuationTest::test_simulator_recovers_iso_countries_in_private_draft_without_reinterpreting_legacy_values. A contagem22inclui outros testes pela palavra original; não representa 22 formulários/requisitos de países. Cada linha abaixo mantém critérios visuais próprios abertos.
|
||||
|
||||
**Cartografia — três estados:** cadastro 1920 referências/1990 geo_locations incluindo70países; geometria administrativa393/581da edição 2019; apresentação renderizada não verificada. geometry-crosswalk-consumers-http-qa.json recupera 32 novos limites por endpoint gestor e 0 públicos, nomes guardados de ambas as províncias Maputo correspondem a um IDúnico. geometry-consumers-path-qa.json recupera393por serviço. ProvinceSelect partilhado liga este IDao preview e conserva texto antigo desconhecido; isso prepara PortalInvestidor/portal-services/AI, sem alegar resultado visual individual. TerritoryFields/territory-preview/MapaBolsa/InvestmentMapPanel existentes reutilizados. Localidade pode ter contexto ancestral, nunca polígono próprio inferido. Correspondências0/31/157 pendentes; todas188nominais no relatório.
|
||||
|
||||
| Consumidor/tipo | Prova de operação/perfil/persistência | Limite da prova |
|
||||
| --- | --- | --- |
|
||||
| Fundiário PDF |Upload privado→quarentena404→ClamDreal/hash→owner200→original canónico cifrado recuperado por bytes; outro/manager404; GETestado; impressão props200; tamper404; leitura auditada.|IndependentDocumentOperationsTest, iterador próprioPDF; não PDFrenderizado/browser18097.|
|
||||
| Fundiário PNG |Mesmo percurso, iteradorPNGreal; bytes/hash/scan próprios e size_bytes/uploaded_by.|Não prova renderização visual.|
|
||||
| Fundiário JPEG |Mesmo percurso, iteradorJPEGreal; bytes/hash/scan próprios e permissões.|Não prova qualquer conversão/derivada não executada.|
|
||||
| Sala parceria PDF |Autor200/candidato404→partilha autoria persistida→candidato200→novo contexto auth/GETlista→revogação404; candidato não partilha403; gestor/estranho403; scan originalreal/tamper404.|Método próprio com iteradorPDF; novo contexto de autenticação em teste, não sessão nova de navegador.|
|
||||
| Sala parceria PNG |Mesmo percurso, iteradorPNGreal, partilha/revogação/eventos próprios, original cifrado preservado.|Interface da sala pendente.|
|
||||
| Sala parceria JPEG |Mesmo percurso, iteradorJPEGreal, original/hash/scan próprios.|Não certifica acordo jurídico externo nem todos os anexos existentes.|
|
||||
| Lote biblioteca PDF |ImportDocumentBatchTest::test_qa_batch_real_scan_preserves_original_replay_identity_conflict_and_profile_guards: actorQAsem permissão/ambiente ativo recusados; bytes duplicados→um original privado; ClamDreal; gestor200/público 404; replay 0 novos; conflito mesma identidade/bytes diferentes não substitui original.|CLIQA, não mecanismo autenticado de aplicação ativa. Dois testes antigos do ficheiro usam fixture/Busfake.|
|
||||
| Oportunidades PDF/PNG/3WebP |resources-real-http-qa.json renovado: novo PDF válido→upload/scan/download/logout/nova sessão/403outra conta. resource-originals-preservation-http-qa.json preserva5binários anteriores e confirma uma página do novo PDF baixado; PNG/3WebP assinados recuperados.|Novo scan real somente do novo PDF; scans das derivadas anteriores conservados. Nenhum ensaio visual.|
|
||||
| Biblioteca/Balcão |document-consumers-real-qa.xml e provas anteriores de bytes/MIME/CSP/scan/perfil conservadas; ensaio de loteQA deste lote separado.|PDFview bytes/CSP não é renderização, nem demonstra validade de todos os fixtures antigos.|
|
||||
|
||||
Fundiário e parceria agora reutilizam VerifiedDocumentBinary, sem relaxar permissões nem duplicar documents. Nenhum original foi convertido em cópia pública; novos uploads registam autor/tamanho. Document-independent-qa.xml e document-batch-independent-qa.xml distinguem scanner real de fixture. Arranque permanente ClamD/assinaturas/GDativo permanecem separados das operações demonstradas.
|
||||
|
||||
**C41 — validade de PDF também é critério próprio:** os novos testes fundiário/parceria/lote utilizam SyntheticDocument::pdf, parseado por ferramenta independente (document-types-fixture-qa.json: uma página/texto). O placeholder PDF histórico09/10 só tinha cabeçalho/terminador: seus scans/hashes são provas de bytes, não de legibilidade. Sem substituí-lo, resources-real-http-qa.json agora demonstra um novo PDF válido no servidor 18097 (upload/ClamD/download/logout/nova sessão/403outra conta). resource-originals-preservation-http-qa.json recupera os 5binários anteriores por hash e parseia o novo PDF baixado. PNG/3WebP assinados recuperados outra vez, mas scans das derivadas são os anteriores, sem novo scan inventado. Renderização do novo PDF continua pendente.
|
||||
|
||||
**C42 — credenciaisQA:** dois atores auxiliares do script antigo tinham passwords fixas que apareceram na leitura do código; foram rodadas apenas na baseQA, remember tokens revogados e valores fixos retirados do script. Nenhuma sessão alvo restante encontrada para apagar. MFA e a linha inteira da conta 18 preservados; chave/configuração ativa não alteradas. resource-http-actors-rotation-qa.json contém somente contagens/flags, sem passwords/cookies/OTP.
|
||||
|
||||
Workflow/contratos:110defs/9 contratos normativos preparados, runtime_validation_implemented=false/workflow_implemented=false/qa_activation=false; não existe percurso completo novo a certificar. Roteiros 19 etapas/5fases conservados. Provas e contagens da matriz em relatorio-executivo.md.
|
||||
|
||||
|
||||
**Versões anteriores preservadas:** as secções abaixo documentam os instantes anteriores. 361 e2/39/179 são a baseline 09/10; o estado cartográfico corrente deste lote é393 e0/31/157. Ready/hashes/contagens antigas não certificam esta revisão. C37 mantém a sua confirmação visual pendente, sem bloquear as frentes independentes.
|
||||
<!-- qa-independent-20261010:end -->
|
||||
|
||||
## C37 — MFA QA recuperado; cenário A visual por retomar
|
||||
|
||||
**C37/P0 — falha bloqueante do cenário A reportada pelo utilizador, corrigida por código/HTTP/persistência QA; retoma visual ainda pendente.** URL: http://127.0.0.1:18097/investimentos. Alvo real confirmado antes de qualquer escrita: `desk_restore_20261009_174726_test:55441`.
|
||||
|
||||
@@ -1,3 +1,446 @@
|
||||
# Correspondências cartográficas — estado atual e histórico
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Correspondências atuais — v2
|
||||
|
||||
Esperado refere o exemplar aceite INE/UNOCHA07/06/2019, não uma certificação da divisão atual. Fonte oficial continua aceite. Nome/pai/edição são problemas de correspondência, não revalidação da credibilidade.
|
||||
|
||||
| Nível | Esperado | Resolvido QA | Pendente | Incremento deste lote |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Províncias |11|11|0|0|
|
||||
| Distritos |159|133|26|5|
|
||||
| Postos administrativos |411|264|147|10|
|
||||
| Total |581|408|173|15|
|
||||
|
||||
Baseline anterior393/188 mantida nohistórico. As antigas2/39/179 eram a situação09/10; não descrevem oestadoatual. Cadastro do seletor1920referências (11+148+437+1324),mais70países;geometria eapresentação funcional têm denominadores próprios. Semgeometriasdelocalidades/parcelas desteexemplar.
|
||||
|
||||
Cinco aliasesdedistritos: **Cahora Bassa→CAHORA-BASSA;Ngauma→N`GAUMA;Ilha De Moçambique→CIDADE ILHA DE MOCAMBIQUE;Maxixe→CIDADE DE MAXIXE;Nacala-A-Velha→NACALA-VELHA**. Postos exatos pelospais:Chitima,Covo,Ilha De Moçambique,Itepela,Lumbo,Massangulo,Songo;aliasesapóstrofo:M'Sawize→M`SAWIZE,N'Cumpe→NCUMPE,N'Gapa→N`GAPA. Fonte,código,pai ecadastro existentes conferidos pela prévia;fundamento ehash emconfig/geometry-correspondences.phpv2 eprovaHTTP. Semrenomeação oualteraçãohierárquica.
|
||||
|
||||
geometry-crosswalk-v2-http-qa.json:15 inseridos / 393 reutilizados,replayigual e15 limites recuperadospelaAPI domapa;sempublicação/sem demonstração visual. geometry-crosswalk-v2-preservation-qa.json:1.990 cadastros/361 originaispreservados e47 incrementais privados. Reversão existente não apaga originais;atualizaçãousada recusada. Mandie continua ambíguo entre2cadastros/códigos sobGuro;CidadeDeMaputoagregado nãoequivalea1distritourbano. Novosdistritos/edições nãoassociadosporproximidade.
|
||||
|
||||
### Distritos — 26 lacunas por nome
|
||||
|
||||
| Código | Nome | Província / pai | Causa |
|
||||
| --- | --- | --- | --- |
|
||||
|MZ0801|Chimbonila|Niassa / Niassa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0206|Chongoene|Gaza / Gaza|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0601|Cidade De Maputo|Maputo City / Maputo City|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1104|Derre|Zambezia / Zambezia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1007|Doa|Tete / Tete|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0703|Erati|Nampula / Nampula|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0804|Ilha Licom|Niassa / Niassa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0805|Ilha Risunodo|Niassa / Niassa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0807|Lago Niassa|Niassa / Niassa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0706|Larde|Nampula / Nampula|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0209|Limpopo|Gaza / Gaza|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0707|Liúpo|Nampula / Nampula|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1109|Luabo|Zambezia / Zambezia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0405|Macate|Manica / Manica|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0211|Mandlakaze|Gaza / Gaza|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0212|Mapai|Gaza / Gaza|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1112|Maquival|Zambezia / Zambezia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1010|Marara|Tete / Tete|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0109|Metuge|Cabo Delgado / Cabo Delgado|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1115|Mocubela|Zambezia / Zambezia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1116|Molumbo|Zambezia / Zambezia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1119|Mulevala|Zambezia / Zambezia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0719|Nacala|Nampula / Nampula|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0722|Rapale|Nampula / Nampula|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ0412|Vanduzi|Manica / Manica|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ1015|Zumbu|Tete / Tete|Nome sem correspondência única sob o pai resolvido|
|
||||
|
||||
### Postos — 147 lacunas por nome
|
||||
|
||||
| Código | Nome | Província / pai | Causa |
|
||||
| --- | --- | --- | --- |
|
||||
|MZ110101|Alto Molocue|Zambezia / Alto Molocue|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ070301|Alua|Nampula / Erati|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ072201|Anchilo|Nampula / Rapale|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ070101|Angoche|Nampula / Angoche|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ111501|Bajone|Zambezia / Mocubela|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ041101|Bazua|Manica / Tambara|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ070103|Boila|Nampula / Angoche|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ090201|Caia|Sofala / Caia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ020801|Caniçado|Gaza / Guija|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ101301|Charre|Tete / Mutarara|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ021101|Chibonzane|Gaza / Mandlakaze|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ020204|Chibuto|Gaza / Chibuto|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ020901|Chicumbane|Gaza / Limpopo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ021102|Chidenguele|Gaza / Mandlakaze|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ020401|Chigubo|Gaza / Chigubo|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080101|Chimbonila|Niassa / Chimbonila|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ110201|Chinde|Zambezia / Chinde|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100201|Chintholo|Tete / Cahora Bassa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100901|Chinthopo|Tete / Magoe|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100301|Chipembere|Tete / Changara|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ101102|Chiputu|Tete / Maravia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ020902|Chissano|Gaza / Limpopo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ040601|Chitobe|Manica / Machaze|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ010301|Chiure|Cabo Delgado / Chiure|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ020501|Chokwe|Gaza / Chokwe|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ020601|Chongoene|Gaza / Chongoene|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ090601|Cidade Da Beira|Sofala / Cidade Da Beira|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ050201|Cidade Da Matola|Maputo / Cidade Da Matola|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ040201|Cidade De Chimoio|Manica / Cidade De Chimoio|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ030101|Cidade De Inhambane|Inhambane / Cidade De Inhambane|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080201|Cidade De Lichinga|Niassa / Cidade De Lichinga|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ060101|Cidade De Maputo|Maputo City / Cidade De Maputo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ070201|Cidade De Nampula|Nampula / Cidade De Nampula|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ010401|Cidade De Pemba|Cabo Delgado / Cidade De Pemba|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100601|Cidade De Tete|Tete / Cidade De Tete|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080301|Cuamba|Niassa / Cuamba|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ110401|Derre|Zambezia / Derre|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ020402|Dindiza|Gaza / Chigubo|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100701|Doa|Tete / Doa|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ090701|Dondo|Sofala / Dondo|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080302|Etarara|Niassa / Cuamba|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ090801|Gorongosa|Sofala / Gorongosa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ040401|Guro|Manica / Guro|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ110601|Gurue|Zambezia / Gurue|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ030401|Homoine|Inhambane / Homoine|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ010501|Ibo|Cabo Delgado / Ibo|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080401|Ilha Licom|Niassa / Ilha Licom|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ080501|Ilha Risunodo|Niassa / Ilha Risunodo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ011202|Imbuo|Cabo Delgado / Mueda|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ030501|Inharrime|Inhambane / Inharrime|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ110802|Inhassunge|Zambezia / Inhassunge|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ071302|Iuluti|Nampula / Mogovolas|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ101201|Kambulatsisi|Tete / Moatize|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ010203|Kuekue|Cabo Delgado / Balama|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080701|Lago Niassa|Niassa / Lago Niassa|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ070501|Lalaua|Nampula / Lalaua|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ070601|Larde|Nampula / Larde|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ080102|Lione|Niassa / Chimbonila|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ070701|Liúpo|Nampula / Liúpo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ110901|Luabo|Zambezia / Luabo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ081901|Lussimbesse|Niassa / Sanga|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ011501|M'Tamba|Cabo Delgado / Nangade|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ021002|Mabalane|Gaza / Mabalane|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ040501|Macate|Manica / Macate|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ020101|Macia|Gaza / Bilene|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ010602|Macomia|Cabo Delgado / Macomia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ021103|Macuacua|Gaza / Mandlakaze|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ112001|Macuze|Zambezia / Namacurra|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ020602|Madzucane|Gaza / Chongoene|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ111101|Maganja|Zambezia / Maganja Da Costa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ020102|Makluane|Gaza / Bilene|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080801|Malanga|Niassa / Majune|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ070802|Malema|Nampula / Malema|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ101104|Malowera|Tete / Maravia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ040402|Mandie|Manica / Guro|Múltiplos cadastros sob o mesmo pai; preservar ambos|
|
||||
|MZ080901|Mandimba|Niassa / Mandimba|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ021104|Mandlakaze|Gaza / Mandlakaze|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ050404|Manhiça|Maputo / Manhiça|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ040802|Manica|Manica / Manica|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ021201|Mapai|Gaza / Mapai|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ111201|Maquival|Zambezia / Maquival|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ101001|Marara|Tete / Marara|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ081002|Marrupa|Niassa / Marrupa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ041201|Matsinho|Manica / Vanduzi|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ081202|Mavago|Niassa / Mavago|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ031001|Maxixe|Inhambane / Maxixe|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ071103|Mazula|Nampula / Memba|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ081402|Mecula|Niassa / Mecula|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080103|Meponda|Niassa / Chimbonila|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ110603|Mepuagiua|Zambezia / Gurue|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ010102|Mesa|Cabo Delgado / Ancuabe|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ080604|Metangula|Niassa / Lago|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ010901|Metuge|Cabo Delgado / Metuge|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ110202|Micaune|Zambezia / Chinde|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ010902|Mieze|Cabo Delgado / Metuge|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ111401|Mocuba|Zambezia / Mocuba|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ111502|Mocubela|Zambezia / Mocubela|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ030502|Mocumbi|Inhambane / Inharrime|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ111601|Molumbo|Zambezia / Molumbo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ071402|Moma|Nampula / Moma|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ071502|Monapo|Nampula / Monapo|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ011103|Montepuez|Cabo Delgado / Montepuez|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ071603|Mossuril|Nampula / Mossuril|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100902|Mphende|Tete / Magoe|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100402|Mualadze|Tete / Chifunde|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ112301|Mualama|Zambezia / Pebane|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ031102|Mucoduene|Inhambane / Morrumbene|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ070602|Mucuali|Nampula / Larde|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ071702|Muculoene|Nampula / Muecate|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ011203|Mueda|Cabo Delgado / Mueda|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ050603|Mugazine|Maputo / Matutuine|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ111901|Mulevala|Zambezia / Mulevala|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ041002|Muoha|Manica / Sussundenga|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ072202|Mutivasse|Nampula / Rapale|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ101501|Muze|Tete / Zumbu|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ071901|Nacala|Nampula / Nacala|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ072002|Nacala-A-Velha|Nampula / Nacala-A-Velha|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ072203|Namaita|Nampula / Rapale|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ111403|Namanjavira|Zambezia / Mocuba|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ070302|Namapa|Nampula / Erati|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ071304|Nametil|Nampula / Mogovolas|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ071201|Namige|Nampula / Mogincual|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ070303|Namiroa|Nampula / Erati|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ011405|Namuno|Cabo Delgado / Namuno|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ071305|Nanhupo Rio|Nampula / Mogovolas|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ050604|Ndelane|Maputo / Matutuine|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ020603|Nguzene|Gaza / Chongoene|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ090802|Nhamadze|Sofala / Gorongosa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ040703|Nhamagua|Manica / Macossa|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ112201|Nicoadala|Zambezia / Nicoadala|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100403|Nsadzu|Tete / Chifunde|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ101401|Ntengo-Wa-Mbalame|Tete / Tsangano|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ011601|Olumbi|Cabo Delgado / Palma|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ050405|Palmeira|Maputo / Manhiça|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ070702|Quinga|Nampula / Liúpo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ072204|Rapale|Nampula / Rapale|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ090103|Sofala|Sofala / Buzi|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ091003|Subwe|Sofala / Maringue|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ100102|Ulongue|Tete / Angonia|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ081904|Unango|Niassa / Sanga|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ041202|Vanduzi|Manica / Vanduzi|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ020302|Vila Eduardo Mondlane|Gaza / Chicualacuala|Nome sem correspondência única sob o pai resolvido|
|
||||
|MZ021105|Xhalala|Gaza / Mandlakaze|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ101502|Zambue|Tete / Zumbu|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ040502|Zembe|Manica / Macate|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ020903|Zongoene|Gaza / Limpopo|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|MZ101503|Zumbu|Tete / Zumbu|Pai ainda sem correspondência; não associar por nome isolado|
|
||||
|
||||
**Visual pendente:**cada seletor/país/origem/mapa por formulário, gravação erecuperação;408 APIs não demonstram render. Todas as fontes, nomes e contagens anteriores seguem abaixo como história.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
10/10/2026. Complemento de AUD-GEO-02/03 na mesma matriz. QA18097/desk_restore_20261009_174726_test:55441. Esperado refere o exemplar INE/UNOCHA de07/06/2019 aceite, não uma divisão administrativa atual certificada.
|
||||
|
||||
| Nível | Esperado | Resolvido QA | Pendente | Incremento sobre09/10 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Províncias | 11 | 11 | 0 | +2 |
|
||||
| Distritos | 159 | 128 | 31 | +8 |
|
||||
| Postos administrativos | 411 | 254 | 157 | +22 |
|
||||
|
||||
**393 resolvidos/188 pendentes, de 581 elementos.** As 32 novas geometrias são 2+8+22; as 361 anteriores e os 1990 registos territoriais foram preservados no retrato. O seletor recupera 1920 referências de quatro níveis:11+148+437+1324; os 70 países do cadastro não integram esse denominador. Sem geometrias de localidades neste exemplar. Cadastro, geometria autorizada e apresentação funcional são critérios independentes.
|
||||
|
||||
## Correspondências explícitas
|
||||
|
||||
| INE | Nome do exemplar | Cadastro/código | ID existente | Fundamento |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| MZ05 | Maputo | Maputo Província/10 | bfde2651-2b46-5d9c-a500-733fa55a8c5c | INE ADM1 MZ05 designa a província Maputo; o exemplar separa Maputo City MZ06. Cadastro cartografia_moz.xlsx: Provincia Maputo Província, código10, sob Moçambique. Códigos10 e MZ05 pertencem a sistemas distintos. |
|
||||
| MZ06 | Maputo City | Maputo Cidade/11 | 4aa31470-997e-5c63-8fe0-ff2f62586f56 | INE ADM1 MZ06 designa Maputo City, distinta da província MZ05; tradução explícita City/Cidade. Cadastro cartografia_moz.xlsx: Provincia Maputo Cidade, código11, sob Moçambique. Não associa distritos urbanos ao distrito agregado da ediçãoINE. |
|
||||
| MZ0502 | Cidade Da Matola | CIDADE DE MATOLA/01 | 9e89eff2-8fe7-5c3f-bedc-29ba03e2db0e | Variação explícita da preposição Da/DE em Cidade de Matola. INE ADM2 MZ0502 sob Maputo MZ05; cadastro cartografia_moz.xlsx Distrito CIDADE DE MATOLA, código01 sob Maputo Província. Exige pai previamente correspondido; não altera limites ou hierarquia. |
|
||||
|
||||
Configuração versionada config/geometry-correspondences.php; fonte/hash/edição/nome/código/pai entram na prévia assinada. CódigosINE e workbook não são convertidos aritmeticamente. Zero renomeações, novos territórios, mudanças de pai ou publicação. Cidade De Maputo/MZ0601 é agregado de2019: não se associa a um dos distritos urbanos cadastrados.
|
||||
|
||||
## Mandie — ambiguidade preservada
|
||||
|
||||
MZ040402/Mandie sob Guro tem candidatos4944a651-23ec-5d51-a5ff-14b0b53ed672 (source_code02) e ea7be18f-2a93-5416-a07a-c207a700e070 (03). Workbook aceite:MANDIE nas linhas11117 e11138, códigos diferentes. Sufixo do P-code não prova equivalência de códigos. Conferir correspondência documental específica; não fundir cadastros. Não bloqueia correspondentes únicos independentes.
|
||||
|
||||
## Províncias — 0 pendentes atuais
|
||||
|
||||
| Código | Nome exato | Pai | Causa |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
## Distritos — 31 pendentes atuais
|
||||
|
||||
| Código | Nome exato | Pai | Causa |
|
||||
| --- | --- | --- | --- |
|
||||
| MZ1002 | Cahora Bassa | Tete | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0801 | Chimbonila | Niassa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0206 | Chongoene | Gaza | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0601 | Cidade De Maputo | Maputo City | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1104 | Derre | Zambezia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1007 | Doa | Tete | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0703 | Erati | Nampula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0704 | Ilha De Moçambique | Nampula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0804 | Ilha Licom | Niassa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0805 | Ilha Risunodo | Niassa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0807 | Lago Niassa | Niassa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0706 | Larde | Nampula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0209 | Limpopo | Gaza | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0707 | Liúpo | Nampula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1109 | Luabo | Zambezia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0405 | Macate | Manica | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0211 | Mandlakaze | Gaza | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0212 | Mapai | Gaza | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1112 | Maquival | Zambezia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1010 | Marara | Tete | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0310 | Maxixe | Inhambane | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0109 | Metuge | Cabo Delgado | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1115 | Mocubela | Zambezia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1116 | Molumbo | Zambezia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1119 | Mulevala | Zambezia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0719 | Nacala | Nampula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0720 | Nacala-A-Velha | Nampula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0817 | Ngauma | Niassa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0722 | Rapale | Nampula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ0412 | Vanduzi | Manica | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ1015 | Zumbu | Tete | Nome sem correspondência única sob o pai resolvido |
|
||||
|
||||
## Postos administrativos — 157 pendentes atuais
|
||||
|
||||
| Código | Nome exato | Pai | Causa |
|
||||
| --- | --- | --- | --- |
|
||||
| MZ110101 | Alto Molocue | Alto Molocue | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070301 | Alua | Erati | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ072201 | Anchilo | Rapale | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ070101 | Angoche | Angoche | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ111501 | Bajone | Mocubela | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ041101 | Bazua | Tambara | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070103 | Boila | Angoche | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ090201 | Caia | Caia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ020801 | Caniçado | Guija | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ101301 | Charre | Mutarara | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ021101 | Chibonzane | Mandlakaze | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ020204 | Chibuto | Chibuto | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ020901 | Chicumbane | Limpopo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ021102 | Chidenguele | Mandlakaze | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ020401 | Chigubo | Chigubo | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ080101 | Chimbonila | Chimbonila | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ110201 | Chinde | Chinde | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100201 | Chintholo | Cahora Bassa | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ100901 | Chinthopo | Magoe | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100301 | Chipembere | Changara | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ101102 | Chiputu | Maravia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ020902 | Chissano | Limpopo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ100202 | Chitima | Cahora Bassa | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ040601 | Chitobe | Machaze | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ010301 | Chiure | Chiure | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ020501 | Chokwe | Chokwe | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ020601 | Chongoene | Chongoene | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ090601 | Cidade Da Beira | Cidade Da Beira | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ050201 | Cidade Da Matola | Cidade Da Matola | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ040201 | Cidade De Chimoio | Cidade De Chimoio | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ030101 | Cidade De Inhambane | Cidade De Inhambane | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ080201 | Cidade De Lichinga | Cidade De Lichinga | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ060101 | Cidade De Maputo | Cidade De Maputo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ070201 | Cidade De Nampula | Cidade De Nampula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ010401 | Cidade De Pemba | Cidade De Pemba | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100601 | Cidade De Tete | Cidade De Tete | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ072001 | Covo | Nacala-A-Velha | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ080301 | Cuamba | Cuamba | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ110401 | Derre | Derre | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ020402 | Dindiza | Chigubo | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100701 | Doa | Doa | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ090701 | Dondo | Dondo | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ080302 | Etarara | Cuamba | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ090801 | Gorongosa | Gorongosa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ040401 | Guro | Guro | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ110601 | Gurue | Gurue | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ030401 | Homoine | Homoine | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ010501 | Ibo | Ibo | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070401 | Ilha De Moçambique | Ilha De Moçambique | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ080401 | Ilha Licom | Ilha Licom | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ080501 | Ilha Risunodo | Ilha Risunodo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ011202 | Imbuo | Mueda | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ030501 | Inharrime | Inharrime | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ110802 | Inhassunge | Inhassunge | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ081701 | Itepela | Ngauma | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ071302 | Iuluti | Mogovolas | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ101201 | Kambulatsisi | Moatize | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ010203 | Kuekue | Balama | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ080701 | Lago Niassa | Lago Niassa | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ070501 | Lalaua | Lalaua | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070601 | Larde | Larde | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ080102 | Lione | Chimbonila | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ070701 | Liúpo | Liúpo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ110901 | Luabo | Luabo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ070402 | Lumbo | Ilha De Moçambique | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ081901 | Lussimbesse | Sanga | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ081201 | M'Sawize | Mavago | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ011501 | M'Tamba | Nangade | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ021002 | Mabalane | Mabalane | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ040501 | Macate | Macate | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ020101 | Macia | Bilene | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ010602 | Macomia | Macomia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ021103 | Macuacua | Mandlakaze | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ112001 | Macuze | Namacurra | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ020602 | Madzucane | Chongoene | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ111101 | Maganja | Maganja Da Costa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ020102 | Makluane | Bilene | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ080801 | Malanga | Majune | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070802 | Malema | Malema | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ101104 | Malowera | Maravia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ040402 | Mandie | Guro | Mais de um cadastro com mesmo nome/pai; ambos preservados |
|
||||
| MZ080901 | Mandimba | Mandimba | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ021104 | Mandlakaze | Mandlakaze | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ050404 | Manhiça | Manhiça | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ040802 | Manica | Manica | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ021201 | Mapai | Mapai | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ111201 | Maquival | Maquival | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ101001 | Marara | Marara | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ081002 | Marrupa | Marrupa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ081702 | Massangulo | Ngauma | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ041201 | Matsinho | Vanduzi | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ081202 | Mavago | Mavago | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ031001 | Maxixe | Maxixe | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ071103 | Mazula | Memba | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ081402 | Mecula | Mecula | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ080103 | Meponda | Chimbonila | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ110603 | Mepuagiua | Gurue | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ010102 | Mesa | Ancuabe | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ080604 | Metangula | Lago | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ010901 | Metuge | Metuge | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ110202 | Micaune | Chinde | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ010902 | Mieze | Metuge | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ111401 | Mocuba | Mocuba | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ111502 | Mocubela | Mocubela | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ030502 | Mocumbi | Inharrime | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ111601 | Molumbo | Molumbo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ071402 | Moma | Moma | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ071502 | Monapo | Monapo | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ011103 | Montepuez | Montepuez | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ071603 | Mossuril | Mossuril | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100902 | Mphende | Magoe | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100402 | Mualadze | Chifunde | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ112301 | Mualama | Pebane | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ031102 | Mucoduene | Morrumbene | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070602 | Mucuali | Larde | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ071702 | Muculoene | Muecate | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ011203 | Mueda | Mueda | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ050603 | Mugazine | Matutuine | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ111901 | Mulevala | Mulevala | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ041002 | Muoha | Sussundenga | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ072202 | Mutivasse | Rapale | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ101501 | Muze | Zumbu | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ011404 | N'Cumpe | Namuno | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ011204 | N'Gapa | Mueda | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ071901 | Nacala | Nacala | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ072002 | Nacala-A-Velha | Nacala-A-Velha | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ072203 | Namaita | Rapale | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ111403 | Namanjavira | Mocuba | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070302 | Namapa | Erati | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ071304 | Nametil | Mogovolas | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ071201 | Namige | Mogincual | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070303 | Namiroa | Erati | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ011405 | Namuno | Namuno | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ071305 | Nanhupo Rio | Mogovolas | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ050604 | Ndelane | Matutuine | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ020603 | Nguzene | Chongoene | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ090802 | Nhamadze | Gorongosa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ040703 | Nhamagua | Macossa | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ112201 | Nicoadala | Nicoadala | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100403 | Nsadzu | Chifunde | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ101401 | Ntengo-Wa-Mbalame | Tsangano | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ011601 | Olumbi | Palma | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ050405 | Palmeira | Manhiça | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ070702 | Quinga | Liúpo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ072204 | Rapale | Rapale | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ090103 | Sofala | Buzi | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100203 | Songo | Cahora Bassa | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ091003 | Subwe | Maringue | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ100102 | Ulongue | Angonia | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ081904 | Unango | Sanga | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ041202 | Vanduzi | Vanduzi | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ020302 | Vila Eduardo Mondlane | Chicualacuala | Nome sem correspondência única sob o pai resolvido |
|
||||
| MZ021105 | Xhalala | Mandlakaze | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ101502 | Zambue | Zumbu | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ040502 | Zembe | Macate | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ020903 | Zongoene | Limpopo | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
| MZ101503 | Zumbu | Zumbu | Pai do exemplar sem correspondência; não associa por nome isolado |
|
||||
|
||||
## Provas e critérios abertos
|
||||
|
||||
- geometry-crosswalk-http-qa.json: aplicação autenticada/MFA, replay e API do mapa para22postos novos. A tentativa anterior aplicou2províncias+8distritos antes de parar na ambiguidadeMandie; recibos/eventos originais estão na prova de preservação. O tratamento foi corrigido para manter ambiguidades pendentes e importar as correspondências únicas.
|
||||
- geometry-crosswalk-preservation-qa.json: alvo guardado,393 internos,32novos,361anteriores/1990cadastros preservados. geometry-consumers-path-qa.json recupera393/393 por serviço READ ONLY e0limites privados públicos. Não é prova de publicação ou desenho.
|
||||
- geometry-crosswalk-consumers-http-qa.json: nova consulta dos 32 limites inseridos, pelo endpoint usado pelo componente, recupera 32 para gestor/MFA e nenhum para público; as duas províncias Maputo têm correspondência única por nome exato. Não aplicou outro lote e não demonstra renderização.
|
||||
- TerritoryFields e ProvinceSelect partilhados usam os mesmos IDs/endpoints/preview. ProvinceSelect exige nome exato/único; conserva texto histórico e explica ausência. TypeScript/build passam; seleção/gravação/recuperação e Leaflet por consumidor exigem interface.
|
||||
- Reversão recusa alteração/publicação/FK e descendentes ativos da edição. Ordem:postos→distritos→províncias, só lotes novos; originais/cadastros/histórico preservados. Identidades revertidas não são ressuscitadas por replay.
|
||||
|
||||
## Histórico09/10 — nomes e contagens anteriores
|
||||
|
||||
# Correspondências cartográficas — lacunas por nome
|
||||
|
||||
Complemento de AUD-GEO-02/03 da mesma matriz. Exemplar INE/UNOCHA 07/06/2019 aceite; esperado significa total deste exemplar, não um total administrativo atual certificado. Não converte limites administrativos em disponibilidade de terra.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Ensaio de Gestão de Oportunidades a partir do dashboard — C43
|
||||
|
||||
**URL inicial exato:** http://127.0.0.1:18097/dashboard. Apenas QA: `desk_restore_20261009_174726_test:55441`. Usar o gestor sintético QA **17**, com o MFA já configurado. O gestor QA14 também está autorizado; QA13/18 são investidores e não recebem gestão. Não reutilizar credenciais da conta ativa13.
|
||||
|
||||
Estado: código, respostas HTTP, persistência e assets QA demonstrados; **cliques e resultado renderizado ainda pendentes**. C37 e cenários A/B do investidor permanecem separados e abertos. Nenhuma credencial, cookie ou código MFA neste roteiro.
|
||||
|
||||
1. Entrar normalmente como gestor QA17 e completar o seu MFA. Abrir o URL inicial. Esperado: “Gestão de Oportunidades” e “Nova oportunidade” no início do painel e na barra lateral; o menu “Painel da gestão” aponta `/dashboard`. “Dossiers e percursos” continua `/investidor`.
|
||||
2. Clicar **Gestão de Oportunidades**. Esperado: URL `/management/opportunities`, lista, pesquisa, filtros e ação “Nova oportunidade”. Não começar por acesso direto à rota para certificar a navegação.
|
||||
3. Clicar **Nova oportunidade**. Esperado: `/management/opportunities/create`, formulário sem registo selecionado. Indicar título sintético QA, razão com pelo menos20caracteres; deixar outros campos finais incompletos.
|
||||
4. Clicar **Guardar rascunho**. Esperado: feedback de gravação; URL `/management/opportunities/{ID}/edit`, referência/ID estáveis, estado rascunho; sem submissão, aprovação ou publicação. Registar ID/referência mostrados.
|
||||
5. Sair e abrir outra sessão do navegador. Entrar com o mesmo gestor e MFA; abrir novamente `/dashboard`, clicar gestão, pesquisar a referência e abrir o rascunho. Esperado: mesmoID e os valores guardados. Uma atualização da página mantém o registo.
|
||||
6. Alterar título, indicar razão e guardar. Voltar à lista, pesquisar e reabrir. Esperado: título atualizado e histórico, preservando ID e relações.
|
||||
7. No editor, indicar razão e confirmar a operação. Clicar **Arquivar**. Esperado: estado arquivado, botão Restaurar, edição bloqueada; filtro Arquivadas encontra o mesmo registo. Arquivar conserva o registo (`deleted_at=null`); **Excluir rascunho** é outra ação.
|
||||
8. Clicar **Restaurar** com razão/confirmação. Esperado: volta a rascunho, mesmoID/título/histórico, novamente editável; não passa a publicado. Atualizar a página e conferir recuperação.
|
||||
9. Sair. Noutra conta investidor QA, abrir `/dashboard`: os dois atalhos administrativos não aparecem. Tentar o URL do registo criado:403, sem gravação. Manter este ensaio independente da confirmação visualC37 da conta18.
|
||||
|
||||
Registar por passo: URL final, perfil/ID da conta, rótulos visíveis, ID/referência da oportunidade, resultado esperado/observado e captura sanitizada. Se recolher rede, somente método/caminho/status/destino; omitir `Cookie`, `X-XSRF-TOKEN` e corpos de autenticação/MFA. Não enviar credenciais.
|
||||
|
||||
Para recuperar o fixture já demonstrado por HTTP, pesquisar **oportunidade QA13**, referência `INV-MZ-GEN-8580797530641128`. É ID de oportunidade, distinto da conta13. O título termina em `edited`, estado draft e quatro eventos. A oportunidade QA14 é outro fixture, do primeiro adaptador imobiliário API; o formulário especializado ainda não está implementado.
|
||||
|
||||
Lista completa derivada da mesma matriz, para gestores QA: http://127.0.0.1:18097/qa-auditoria/pedidos-abertos. Inclui3349pedidos abertos pesquisáveis, fonte/versões, lacuna e próxima ação porID; investidor403. [Matriz única](matriz-requisitos.csv), [relatório](relatorio-executivo.md), [backlog](backlog.md), [ativação/reversão](plano-ativacao.md).
|
||||
@@ -1,5 +1,162 @@
|
||||
# Execução do percurso InvestMoz — I0 e I1–I3 em QA
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Resultado atual por categoria — semântica e atribuições em QA, 10/10/2026
|
||||
|
||||
Este bloco é a evidência mais recente desta matriz; as secções anteriores abaixo permanecem como histórico. Estados antigos sobre objetos opacos, coordenação editorial e ausência de 190000 ficam confrontados com os resultados concretos deste bloco. **Código preparado e HTTP/persistência em QA não encerram interface nem funcionalidade ativa.** Não houve escrita na base ativa. C37 e aceitação visual permanecem pendentes.
|
||||
|
||||
Servidor **http://127.0.0.1:18097**, base **desk_restore_20261009_174726_test:55441**, Fortify e MFA normais. **Promotor QA 21** continua a conta de ensaio do dashboard. Não usar Auditor ou Superutilizador em seu lugar.
|
||||
|
||||
| Categoria / ID QA | Rascunho | Edição / recuperação | Submissão | Revisão | Publicação |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Investimento — #30 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Imobiliário — #31 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Fundiário/DUAT — #32 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Bloqueada: autorização externa pendente |
|
||||
| Venda de negócio — #33 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Pública, só teaser; prova editorial posterior |
|
||||
| Parceria de negócio — #34 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Franquia — #35 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Concessão — #36 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Financiamento — #37 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Fornecimento — #38 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Outros — #39 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
|
||||
As dez categorias recuperaram dados noutra sessão autenticada. As seis com contrato definido aceitaram os cenários semânticos documentados; as quatro restantes conservaram rascunhos, sem inventar um perfil modular para as submeter. Fundiário #32 separa aprovação interna da decisão externa. Venda #33 passou depois pelo percurso editorial público, sem divulgar resumo/dados privados.
|
||||
|
||||
**Campos e regras preparados:** cronograma com início/fim/responsável e dependências acíclicas; maturidade com os oito códigos normativos; viabilidade com cenário/indicadores/modelo e estudo, sem score de sucesso; serviços/título/entidade emissora/ocupação/avaliação imobiliários; parcelas com editor de vértices/anéis MultiPolygon, benfeitorias e consulta comunitária; constituição/beneficiário efetivo, exercícios existentes, ativos/passivos, obrigações laborais, licenças, NDA, governação e franquia. O contrato é elaborado no servidor e consumido pelo editor, sem templates JSON divergentes no cliente. Objetos legados conservam chaves anteriores. O schema original de 110 definições mantém SHA `9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0`.
|
||||
|
||||
**Falhas corrigidas com prova:** 12 recusas 422 identificaram o campo e conservaram o rascunho inválido: fim anterior ao início, ciclo, falta de cenário/modelo, serviços incompletos, título de outra oportunidade, comunidades sem consulta, financeiros insuficientes e contingências não declaradas. Erros `canonical.<campo>` são devolvidos pela API e ligados aos controlos; a posição/clareza visual ainda não foi observada. O formulário núcleo duplicado foi retirado do modo perfil; gestão legada mantém o seu acesso próprio.
|
||||
|
||||
**Filas e perfis:** revisão, preparação editorial, recurso e tarefas atribuídas reutilizam `tasks`, com ligação explícita ao bigint da oportunidade. #30 pediu parecer a 29: o destinatário errado recebeu403, a aprovação com parecer pendente422; a resposta foi guardada e concluiu a tarefa, sem aprovar o gate automaticamente. #33: coordenador editorial25 atribuiu a29; tarefa recuperada na fila; outro Publicador403; o designado publicou. Papéis/âmbitos/independência/responsabilidades são verificados no servidor; revogação após aprovação bloqueia publicação no teste específico. Responsabilidades atribuídas neste ensaio são sintéticas, sem competência institucional real inferida. SLA permanece `unconfigured` e `due_at=null`.
|
||||
|
||||
**Consumidores territoriais/documentais:** seis contratos recuperaram país MZ e uma localização com limite disponível; API retornou dois limites de contexto. O editor e os campos `location_id` consultam o território mais profundo e mantêm a parcela separada do limite administrativo. País FR do mercado do investimento foi gravado/recuperado; isto não encerra origem/filtros/estatísticas de todos os formulários. Originais dos seis casos permitiram21/24/25 e negaram organização estrangeira23(403). O download/prévia reutiliza `VerifiedDocumentBinary`, que verifica cifra/hash/scan e controla falhas; não altera os originais. #33 recuperou original da imagem e duas derivadas limpas pelo ClamD real. PDF válido de uma página, parser estrito, upload/scan ClamD real e SHA; download 21/24/25, negação 23(403); exclusão lógica→download404/opção removida; restauração→nova sessão→bytes/hash idênticos. Prova `opportunity-semantic-documents-http-qa.json`.
|
||||
|
||||
**Limites concretos:** correspondência entre titular/documento/cadastro e âmbito urbanístico exige evidência e decisão do gate, sem inferir autenticidade jurídica pela existência de um UUID. Limites urbanísticos e materialidade não são inventados. Unidades aplicáveis, Response integral e relações do §27 ainda precisam de reconciliação. O schema original exige pelo menos um exercício financeiro; casos com zero exercícios devem ter regra expressa reconciliada antes de serem declarados cobertos. A moeda obedece ao padrão do contrato; não existe aqui prova de catálogo completo ISO4217. DOCX/XLSX, partilhas e PDFs/exportações/derivadas sem prova específica continuam abertos. C37, A/B e todo o percurso visual por perfil permanecem pendentes.
|
||||
|
||||
**Preservação e compilação:** 18 conjuntos de linhas anteriores por ID/colunas originais preservados; MFA18 intacto. Manifesto ativo `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` preservado. Manifesto QA `0e610cd650d6b788edfdc9cbf697d125cb9330958914e13a8a8bbd14a781b395`; bundle próprio `build-qa-opportunities`, sem recompilar o ativo. TypeScript, PHPStan e testes específicos passaram; as contagens de testes não são usadas como critério de conclusão.
|
||||
|
||||
Mesma matriz: **3.386 requisitos / 3.386 linhas**, **6.838 ocorrências de origem**. IDs, fontes, critérios, versões divergentes e referências anteriores conservados.
|
||||
|
||||
| Estado canónico | Requisitos | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluÃdo e demonstrado | 6 | 7 |
|
||||
|
||||
Linhas principais: **AUD-OP-02/03/04, AUD-GEO-03, AUD-PROFILE-11/12/19/25/28/31 e AUD-OPS-01**, em [matriz-requisitos.csv](matriz-requisitos.csv). Nenhum desses critérios visuais foi fechado por API.
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Incremento canónico e workflow em QA — 10/10/2026
|
||||
|
||||
Servidor **http://127.0.0.1:18097**; destino guardado **desk_restore_20261009_174726_test:55441**. Zero escritas na base ativa. **C37 e toda a aceitação visual deste incremento permanecem pendentes.** Props da interface, API e persistência foram verificadas; nenhum clique ou renderização no navegador foi observado.
|
||||
|
||||
| Requisito na matriz única | Funciona / prova concreta em QA | Falta para fechar |
|
||||
| --- | --- | --- |
|
||||
| AUD-OP-04; AUD-PROFILE-28 | Dez categorias guardam rascunhos incompletos e recuperam noutra sessão MFA. Seis passam o contrato estrutural: investimento, imobiliário, DUAT, venda, parceria e franquia. Adapter do mesmo schema de 110 definições; condições `allOf` originais recuperadas, IDs existentes mantidos. | Semântica e editores dos objetos incompletos do §25; contrato Response integral; quatro perfis modulares aprovados; demonstração visual por categoria. |
|
||||
| AUD-OP-03; PROFILE-08/11/12 | Promotor 21 criou a oportunidade **18**, submeteu; Validador 24 solicitou correção; 21 corrigiu/resubmeteu; 24 reviu gates e aprovou; Publicador 25 publicou com visibilidade interna. O número 18 identifica a oportunidade, não a conta do investidor C37. | Visual integral, todas as categorias, pareceres/incidentes e coordenação editorial completa. |
|
||||
| PROFILE-09/10/12/21 | Imobiliário **19**: rejeição fundamentada por 24 → recurso reaberto pelo revisor independente 29 → nova aprovação 24 → publicação pública 25 → alteração privada sem alterar a ficha pública → suspensão com GET público 404. Autor Prom+Val+Pub e aprovador Val+Pub receberam 403. | Visual, filas institucionais e competências reais; suspensão/retoma e demais ações editoriais por todos os perfis. |
|
||||
| PROFILE-19/21/22 | Tarefas existentes em `tasks`, vínculo à oportunidade por origem/ID/correlação; atribuição explícita, motivos e decisões recuperáveis. Correção/rejeição concluem revisão; resubmissão não herda atribuição; suspensão conclui tarefas pendentes. Recurso do próprio rejeitor e membro da organização negados. SLA `unconfigured`, responsável e prazo iniciais nulos. | Filas completas, reatribuição/disponibilidade, coordenação editorial e política SLA aprovada. Não foram inventados responsáveis nem prazos. |
|
||||
| COUNTRY-01; GEO-03; OP-02 | Localização MZ/Cabo Delgado/distrito e documentos ligados ao mesmo contrato recuperados nas seis categorias. Oportunidade **28** publicou usando a localização principal mesmo em segundo lugar na lista. Documentos e imagem/duas derivadas verificados com ClamD real e hashes correspondentes. DUAT **20** aprovado internamente não publicou: 422 por autorização externa pendente. | Outros países/formulários, origem/filtros/estatísticas transversais, mapas renderizados, 188 correspondências cartográficas e operações documentais restantes. |
|
||||
| PROFILE-30; DASH-01 | Conta **Promotor QA — conta sintética 21**, MFA normal confirmado, organização e tipos autorizados. Dashboard devolve navegação do Promotor. Permissões temporárias dos testes foram revogadas e uma nova sessão 21 recuperou o dashboard. | Dashboard → formulário → guardar → nova sessão → workflow e resultado visíveis. Auditor ou Superutilizador não substituem este ensaio. |
|
||||
|
||||
Os requisitos acima continuam **parciais**: nenhum passou a concluído pelo incremento. O detalhe está no [relatório do workflow](workflow-oportunidades-qa.md); fontes, versões, critérios e percursos permanecem na [mesma matriz](matriz-requisitos.csv).
|
||||
|
||||
| Estado canónico | Requisitos / linhas da matriz | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 6 | 7 |
|
||||
|
||||
Total: **3.386 requisitos = 3.386 linhas**, **6.838 ocorrências**, **3.380 requisitos abertos**. As referências e versões anteriores foram preservadas.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
<!-- qa-profiles-v1-20261010:begin -->
|
||||
## Cobertura após perfis v1 — 10/10/2026
|
||||
|
||||
I0–I3 permanecem em QA. Cinco fases e 19 etapas preservadas, sem renumeração. C37, A/B e C43 visuais continuam pendentes. A autorização organizacional reutiliza OpportunityManagement, Resources, Fortify e entidades existentes; nenhuma jornada educativa nova foi iniciada.
|
||||
|
||||
- Interface: preparada e compilada, sem observação no navegador. Roteiro manual de perfis inclui acessos permitidos/negados; A/B preservam o roteiro existente.
|
||||
- API: links descobertos no dashboard, rascunho 15 recuperado noutra sessão, coedição 22 e negações por tipo/organização/múltiplos papéis. Seletores autorizados e geometria do Promotor funcionam; submissão integral permanece bloqueada por contrato incompleto.
|
||||
- Persistência: autor, ID, versão e histórico preservados; Cabo Delgado recuperada com o mesmo ID/nome. Pedido de Promotor, comprovativos, auditoria, revogação e dupla demonstrados nos escopos descritos.
|
||||
- Documentos: quatro comprovativos sintéticos com ClamD real e hash recuperável; não generalizar às operações restantes. Originais anteriores preservados em 18 tabelas.
|
||||
- Ativo: exclusivamente READ ONLY, sem instalação/flag de perfis. 020000 instalada; recursos/percurso ainda ausentes. Mesma matriz: 3.386 requisitos, 6.838 ocorrências.
|
||||
|
||||
Coordenadores, pareceristas, suporte e recurso estão explicitamente mapeados na reconciliação. Filas, decisões, SLA e incidentes funcionais continuam parciais ou não implementados no backlog. 393 geometrias privadas QA não encerram a integração visual; permanecem 31 distritos/157 postos nomeados.
|
||||
|
||||
<!-- qa-profiles-v1-20261010:end -->
|
||||
|
||||
> Registos anteriores preservados como histórico. Os estados correntes são os da matriz e do bloco mais recente acima; uma entrega posterior não altera retroativamente a evidência anterior.
|
||||
|
||||
<!-- qa-dashboard-20261010:begin -->
|
||||
## Continuação independente C43/C44 —10/10/2026
|
||||
|
||||
QA exclusiva: http://127.0.0.1:18097, alvo real `desk_restore_20261009_174726_test:55441`. Ativo consultado por transação SQL READ ONLY; 0 escritas/instalações/publicações ativas. Cinco fases/19 etapas, 5 dossiers/95 etapas e 393 geometrias QA preservados. C37 e aceitação visual permanecem abertos.
|
||||
|
||||
Gestores QA 14/17 têm email verificado+`manage-portal`; gestor 17 possui MFA normal. Investidores QA 13/18 não têm gestão. No ativo, a leitura confirmou contas 2/13 autorizadas, 020000 instalada e 030000 ausente; o dashboard ativo contém título Gestão de Oportunidades e ação antiga Criar oportunidade. Não há hotfile/cache de configuração/cache de rotas a explicar a ausência. URL configurado ativo: http://portal-investidor.test; **URL/conta da sessão reportada ainda desconhecidos**, sem atribuição especulativa de causa a essa conta.
|
||||
|
||||
- `dashboard-active-readonly.json`: configuração/contas elegíveis/instalação/manifesto ativo, sem boot HTTP/escrita de sessão no ativo.
|
||||
- `dashboard-opportunities-http-qa.json`: links obtidos do dashboard→gestão→create→201→GET→logout/nova sessão→GET→PUT→archive→filtro archived→restore→GET. Oportunidade QA 13, ID estável/quatro eventos, 12 originais e relações/documentos/dossiers preservados; 6 publicadas mantidas; outra conta 403.
|
||||
- `dashboard-manager-mfa-http-qa.json`: gestor 17, MFA em duas sessões→dashboard→gestão→recuperação/reload→logout; 0 alterações de registos. `dashboard-navigation-catalogue-qa.json`: menu Painel da gestão→/dashboard, dossiers privados→/investidor, atalhos únicos e ausência de gestão para investidor.
|
||||
- `real-estate-draft-http-qa.json`: outra oportunidade QA 14, rascunho imobiliário parcial no draft existente→nova sessão→patch merge→cinco 422 sem mutação→outsider 403; schema normativo SHA 9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0.
|
||||
- `dashboard-real-estate-qa.xml`: casos nomeados Dashboard OpportunityAccessTest/OpportunityManagementTest/OpportunityRealEstateDraftTest, 8 ensaios/201 asserções. Typecheck, build QA e PHPStan dirigidos passaram; contagem não substitui os critérios visuais.
|
||||
- `resource-originals-preservation-http-qa.json`: cinco binários anteriores preservados e novo PDF válido recuperado/parseado, 0 novos recursos. Prova ClamD real anterior mantida; não afirma novo scan das derivadas nem renderização.
|
||||
- `dashboard-matrix-reconciliation-qa.json` e `pedidos-abertos-projection.json`: fontes/trechos/IDs/versões preservados, 3354 requisitos/linhas e 5830 ocorrências; 3349 abertos apresentados uma vez cada. Estado AUD-OP-04 passa de não implementado a parcial; nenhum requisito global encerrado.
|
||||
|
||||
Revisão atual não candidata; nenhuma aceitação integralI 1–I3 por contagem de testes. Cadastro básico de oportunidades não é dossier privado; operaçãoQA 13 não criou dossier/etapa nem alterou as 6 oportunidades publicadas. Rascunho imobiliárioQA 14 não é decisão externa nem contrato completo.
|
||||
|
||||
**Histórico preservado:** os blocos anteriores a C43/C44 abaixo conservam as suas fontes e provas. Os seus hashes/ready/contagens não certificam a revisão atual; a primeira adaptação imobiliária não equivale ao schema integral nem ao workflow.
|
||||
<!-- qa-dashboard-20261010:end -->
|
||||
|
||||
<!-- qa-independent-20261010:begin -->
|
||||
## Lote independente I0–I3 — 10/10/2026
|
||||
|
||||
QA exclusiva: http://127.0.0.1:18097; alvo confirmado `desk_restore_20261009_174726_test:55441`. Testes de feature usam outra QA, `investmoz_test:55441`, com guarda antes de qualquer escrita. Sem alterações, instalação, importação ou publicação na base ativa. Cinco fases/19 etapas preservadas;5 dossiers/95 etapas na QA web, sem criação de jornadas educativas.
|
||||
|
||||
| Requisito | Entrega e prova | Critérios ainda abertos |
|
||||
| --- | --- | --- |
|
||||
| AUD-GEO-01/02/03; C38 | Correspondências explícitas Maputo Província, Maputo Cidade e Matola; descendentes por nome exato+pai. Ação autenticada/MFA no servidor 18097 insere 32 limites internos:2 províncias+8 distritos+22 postos. 361 originais e 1990 geo_locations preservados. 32/32 novos recuperados via endpoint consumidor; público: 0/32. 393/393 contextos por serviço;1920/1920 referências territoriais por API. ProvinceSelect liga o nome guardado ao preview sem reescrever valores. |31 distritos/157 postos por nome; localidades sem polígonos neste exemplar; integração renderizada por consumidor e ato de divulgação separados. |
|
||||
| AUD-COUNTRY-01–04/06–07, STATS-01 | Provas renovadas por método/formulário: perfil/empresa/manifestação/público/quatro apoios/carteira/interesse público em negócio/wizard/fundiário/três Balcões/simulador/CRM. Gravação→GET→filtro→agregado quando aplicável; residência/constituição/fiscal/fundos distintos da origem declarada; unknown preservado. |Seleção, recibos, variações condicionais, filtros/tabela e nova sessão na interface. Não contar testes documentais selecionados pela palavra original como requisitos de países. |
|
||||
| AUD-LAND-01, PART-01, LIB-01/02, SCAN-01; C39/C40 |404 dos originais cifrados corrigido em fundiário e sala de parceria por VerifiedDocumentBinary comum; novos uploads registam tamanho/autor. PDF/PNG/JPEG com ClamD real, hash, quarentena/perfis/partilha/revogação/adulteração/recuperação. Lote documental QA privado, idempotente, sem substituir identidade conflituosa; actor sem permissão/ambiente ativo recusados. |Renderização PDF/impressão, ações visuais e restantes consumidores/tipos; lote ativo autenticado. Serviço ClamD real demonstrado separado de arranque/manutenção permanente/GD ativo. |
|
||||
| AUD-OP-02/SCAN-01; C41/C42 |PDFs sintéticos apenas com cabeçalho não provavam legibilidade: novos testes usam PDF válido de uma página, parseado independentemente. No servidor 18097, novo PDF anexado à oportunidadeQA12→scan real→download→nova sessão→recuperação; outra conta 403. Cinco binários anteriores preservados por SHA; PNG/3WebP recuperados, sem novo scan das derivadas. Credenciais fixas do script removidas; passwords/remember tokens de dois atoresQA auxiliares rodados; MFA/conta 18 preservados. |PDF renderizado e interface documental pendentes. O placeholder anterior permanece conservado como evidência e não certifica abertura de PDF. |
|
||||
| AUD-OP-03/04 |Schema normativo §26 preparado e rastreável:110 definições,9 contratos Create/Patch/Response, sem renumerar IDs. |Workflow e validação/gates especializados continuam não implementados; nenhum fecho funcional atribuído ao artefacto. |
|
||||
| C37, AUD-UI-01, I1–I3 |Evidências anteriores conservadas, nenhum novo ensaio de login investidor neste lote. |Login/reload/logout/nova sessão e A/B completos na interface continuam pendentes. |
|
||||
|
||||
| Nível INE 2019 | Esperado no exemplar | Resolvido QA | Pendente |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Província |11|11|0|
|
||||
| Distrito |159|128|31|
|
||||
| Posto |411|254|157|
|
||||
| Total |581|393|188|
|
||||
|
||||
Os denominadores da edição 2019 não são os 1920 territórios do cadastro existente. [188 lacunas por nome](correspondencias-cartograficas.md), incluindo Cidade De Maputo agregado e Mandie duplicado no próprio workbook; nenhuma equivalência por código/fuzzy, nem fusão de cadastros. Aceitação das fontes oficiais preservada.
|
||||
|
||||
**Mesma matriz, sem requisitos amplos recém-fechados:**3354 requisitos/3354 linhas canónicas;5830 ocorrências de origem. IDs, fontes, trechos, diferenças de versão, critérios e estados conservados.
|
||||
|
||||
| Estado | Requisitos/linhas | Ocorrências |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3198 | 5380 |
|
||||
| não implementado | 146 | 440 |
|
||||
| dependente de decisão concreta | 4 | 4 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 5 | 5 |
|
||||
|
||||
Evidências privadas: `geometry-crosswalk-http-qa.json` (aplicação posterior22, além das 2+8 anteriores registadas na auditoria), `geometry-crosswalk-preservation-qa.json` (total 32 novas), `geometry-crosswalk-consumers-http-qa.json` (HTTP 32 novas/0 públicas), `geometry-consumers-path-qa.json` (serviço393), `territory-catalogue-coverage.json` (catálogo1920). Não confundir aplicação parcial inicial interrompida por Mandie com duplicação: as 10 já aplicadas foram reutilizadas na continuação; ambiguidade nominal fica pendente e não bloqueia outras correspondências únicas.
|
||||
|
||||
Testes dirigidos: `geometry-crosswalk-qa.xml` (casos de vínculo/pai/exemplar/conflito/replay/reversão), `document-independent-qa.xml` (dois percursos PDF/PNG/JPEG/perfis), `document-batch-independent-qa.xml` (um caso ClamD real; dois de fixture), `countries-independent-qa.xml` e `countries-forms-independent-qa.xml` (métodos identificados na [cobertura](cobertura-percursos-i0-i3.md)). TypeScript/build/PHPStan dos componentes alterados passaram. A guarda de concorrência recusou um arranque paralelo de testes; ficou em `countries-forms-independent-lock-refusal-qa.xml`; o ensaio sequencial posterior passou. Isto é tooling protegido, não falha de formulário.
|
||||
|
||||
Complemento documental: `document-types-fixture-qa.json`, `resource-originals-preservation-http-qa.json`, `resource-http-actors-rotation-qa.json` e nova versão de `resources-real-http-qa.json`; versão anterior arquivada antes da atualização. Credenciais novas somente no ficheiro privado local; nenhum cookie/OTP/body exportado. Não alterar os originais anteriores para tornar o ensaio verde.
|
||||
|
||||
Código preparado e QA API/persistência demonstradas neste âmbito; interface não demonstrada. `candidate_revision_fixed=false`, `integrated_ui_ab_passed=false`, `activation_ready=false`. [Backlog atual](backlog.md) e [ordem de ativação/reversão](plano-ativacao.md). Não há candidata final nem novo backup final de ativação emitido.
|
||||
|
||||
**Versões anteriores preservadas:** as secções abaixo documentam os instantes anteriores. 361 e2/39/179 são a baseline 09/10; o estado cartográfico corrente deste lote é393 e0/31/157. Ready/hashes/contagens antigas não certificam esta revisão. C37 mantém a sua confirmação visual pendente, sem bloquear as frentes independentes.
|
||||
<!-- qa-independent-20261010:end -->
|
||||
|
||||
## C37 — MFA QA recuperado; cenário A visual por retomar
|
||||
|
||||
**C37/P0 — falha bloqueante do cenário A reportada pelo utilizador, corrigida por código/HTTP/persistência QA; retoma visual ainda pendente.** URL: http://127.0.0.1:18097/investimentos. Alvo real confirmado antes de qualquer escrita: `desk_restore_20261009_174726_test:55441`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,523 @@
|
||||
# Perfis e oportunidades — reconciliação da versão 1
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Estado atual — instalador, documentos e cartografia
|
||||
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T10:32:41+00:00: 030000, 040000 e 09/190000 ausentes; zero geometrias; manifesto preservado. **C37, Promotor QA21 e A/B permanecem visualmente pendentes. Candidata à ativação ainda não fixada.**
|
||||
|
||||
| Requisitos | Código preparado / prova QA | Critério ainda aberto |
|
||||
| --- | --- | --- |
|
||||
| PROFILE-31 / OPS-01 | GET/POST `/management/profile-install`, bootstrap separado; prévia, dependências, hash, MFA, repetição e auditoria. Instalação vazia revertida/reinstalada; falha de auditoria reverte DDL e ledger, permitindo repetir. Uso/histórico impedem remoção. No servidor já utilizado, `changed=false`; nenhum titular ou privilégio alterado. Promotor, Auditor e organização alheia recebem 403. | Instalação/bootstrap visual e titulares reais designados; recuperação simultânea da dupla depende de D-R1. Backup do schema não recupera MFA perdido. |
|
||||
| GEO-02/03 | **15 novas**: cinco distritos e dez postos; **393 reutilizadas**. Total **408**: 11 províncias, 133 distritos e 264 postos. API do mapa recupera os 15 novos limites. 1.990 cadastros e 361 geometrias originais preservados; sem publicação. | **173 lacunas nomeadas**: 26 distritos e 147 postos; ambiguidades e edições distintas. Seletores/mapas por consumidor continuam com aceitação visual pendente. Localidades/parcelas não inferidas. |
|
||||
| OP-02 / documental | DOCX/XLSX: pacote válido, scan ClamD real, acesso de QA21/24/25 e QA23 negado; exclusão lógica, restauro e original idêntico noutra sessão MFA. | Renderização de Office, exportações/PDFs derivados e restantes consumidores continuam pendentes por operação. |
|
||||
| LAND-01 / PART-01 / SCAN-01 | PDF, PNG e JPEG em fundiário e sala de parceria: seis scans reais com hashes iguais. Autor recupera originais; partilha explícita permite nova sessão; revogação retira acesso/listagem, sem apagar original. Gestor não ganha acesso por esse papel. **91 pedidos** no recibo próprio. | Modelo de impressão recuperado, mas PDF gerado no navegador e interface não demonstrados. Estes seis scans não encerram outros tipos, derivadas ou permissões. |
|
||||
| OP-03 / OP-04 | Código da interface e resposta distinguem aprovação interna, disponibilização restrita e catálogo público. Casos #30 restrito, #33 público e #32 aprovado sem publicação recuperados pela API; consulta da política pública efetiva. | Interface; Response integral/§27 e condições sem regra inequívoca continuam parciais. Testes dos seis contratos não encerram a demonstração visual. |
|
||||
| PROFILE-22/28 | [Quatro perfis e SLA com regra, fundamento, efeito e decisões concretas](propostas-perfis-modulares-sla.md). | Propostas **inativas**; quatro categorias continuam com rascunhos recuperáveis, mas submissão/publicação bloqueadas. Nenhum prazo ou responsável real configurado. |
|
||||
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_104956_test:55441` → **295 tabelas com linhas/hashes iguais e 69 ficheiros fisicamente recuperados por hash**; todos os ficheiros referenciados recuperados por hash. Arquivo privado inclui o runtime e a chave QA estável. Readiness só atesta preparação técnica desta revisão. Repetir após fixar a candidata e preparar backup ativo apenas numa futura ativação autorizada.
|
||||
|
||||
**Matriz única:** 3.386 requisitos/linhas e 6.838 ocorrências de origem; todas as fontes, critérios e versões preservados; sem novos fechos globais. Contagens por estado abaixo e na projeção dos pedidos abertos.
|
||||
|
||||
**Próximo lote executável:** completar Response/§27 reutilizando entidades; verificar exportações e PDFs derivados por consumidor; prosseguir correspondências territoriais por nome, pai e edição; completar países/origem, filtros e estatísticas por formulário. Incorporar provas visuais quando retomadas. Decisões D-M1–M4, D-S1/S2, D-SLA e D-R1 estão individualizadas e não bloqueiam o trabalho independente.
|
||||
|
||||
**Histórico preservado:** os blocos seguintes descrevem revisões anteriores. 393 geometrias/188 lacunas eram o estado antes deste lote; “instalador 040000 em falta” deixou de descrever o código QA. Esses estados anteriores não provam esta revisão nem autorizam instalação ativa.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Resultado atual por categoria — semântica e atribuições em QA, 10/10/2026
|
||||
|
||||
Este bloco conserva a revisão semântica anterior; o estado atual está no início do documento; as secções anteriores abaixo permanecem como histórico. Estados antigos sobre objetos opacos, coordenação editorial e ausência de 190000 ficam confrontados com os resultados concretos deste bloco. **Código preparado e HTTP/persistência em QA não encerram interface nem funcionalidade ativa.** Não houve escrita na base ativa. C37 e aceitação visual permanecem pendentes.
|
||||
|
||||
Servidor **http://127.0.0.1:18097**, base **desk_restore_20261009_174726_test:55441**, Fortify e MFA normais. **Promotor QA 21** continua a conta de ensaio do dashboard. Não usar Auditor ou Superutilizador em seu lugar.
|
||||
|
||||
| Categoria / ID QA | Rascunho | Edição / recuperação | Submissão | Revisão | Publicação |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Investimento — #30 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Imobiliário — #31 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Fundiário/DUAT — #32 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Bloqueada: autorização externa pendente |
|
||||
| Venda de negócio — #33 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Pública, só teaser; prova editorial posterior |
|
||||
| Parceria de negócio — #34 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Franquia — #35 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Concessão — #36 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Financiamento — #37 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Fornecimento — #38 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Outros — #39 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
|
||||
As dez categorias recuperaram dados noutra sessão autenticada. As seis com contrato definido aceitaram os cenários semânticos documentados; as quatro restantes conservaram rascunhos, sem inventar um perfil modular para as submeter. Fundiário #32 separa aprovação interna da decisão externa. Venda #33 passou depois pelo percurso editorial público, sem divulgar resumo/dados privados.
|
||||
|
||||
**Campos e regras preparados:** cronograma com início/fim/responsável e dependências acíclicas; maturidade com os oito códigos normativos; viabilidade com cenário/indicadores/modelo e estudo, sem score de sucesso; serviços/título/entidade emissora/ocupação/avaliação imobiliários; parcelas com editor de vértices/anéis MultiPolygon, benfeitorias e consulta comunitária; constituição/beneficiário efetivo, exercícios existentes, ativos/passivos, obrigações laborais, licenças, NDA, governação e franquia. O contrato é elaborado no servidor e consumido pelo editor, sem templates JSON divergentes no cliente. Objetos legados conservam chaves anteriores. O schema original de 110 definições mantém SHA `9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0`.
|
||||
|
||||
**Falhas corrigidas com prova:** 12 recusas 422 identificaram o campo e conservaram o rascunho inválido: fim anterior ao início, ciclo, falta de cenário/modelo, serviços incompletos, título de outra oportunidade, comunidades sem consulta, financeiros insuficientes e contingências não declaradas. Erros `canonical.<campo>` são devolvidos pela API e ligados aos controlos; a posição/clareza visual ainda não foi observada. O formulário núcleo duplicado foi retirado do modo perfil; gestão legada mantém o seu acesso próprio.
|
||||
|
||||
**Filas e perfis:** revisão, preparação editorial, recurso e tarefas atribuídas reutilizam `tasks`, com ligação explícita ao bigint da oportunidade. #30 pediu parecer a 29: o destinatário errado recebeu403, a aprovação com parecer pendente422; a resposta foi guardada e concluiu a tarefa, sem aprovar o gate automaticamente. #33: coordenador editorial25 atribuiu a29; tarefa recuperada na fila; outro Publicador403; o designado publicou. Papéis/âmbitos/independência/responsabilidades são verificados no servidor; revogação após aprovação bloqueia publicação no teste específico. Responsabilidades atribuídas neste ensaio são sintéticas, sem competência institucional real inferida. SLA permanece `unconfigured` e `due_at=null`.
|
||||
|
||||
**Consumidores territoriais/documentais:** seis contratos recuperaram país MZ e uma localização com limite disponível; API retornou dois limites de contexto. O editor e os campos `location_id` consultam o território mais profundo e mantêm a parcela separada do limite administrativo. País FR do mercado do investimento foi gravado/recuperado; isto não encerra origem/filtros/estatísticas de todos os formulários. Originais dos seis casos permitiram21/24/25 e negaram organização estrangeira23(403). O download/prévia reutiliza `VerifiedDocumentBinary`, que verifica cifra/hash/scan e controla falhas; não altera os originais. #33 recuperou original da imagem e duas derivadas limpas pelo ClamD real. PDF válido de uma página, parser estrito, upload/scan ClamD real e SHA; download 21/24/25, negação 23(403); exclusão lógica→download404/opção removida; restauração→nova sessão→bytes/hash idênticos. Prova `opportunity-semantic-documents-http-qa.json`.
|
||||
|
||||
**Limites concretos:** correspondência entre titular/documento/cadastro e âmbito urbanístico exige evidência e decisão do gate, sem inferir autenticidade jurídica pela existência de um UUID. Limites urbanísticos e materialidade não são inventados. Unidades aplicáveis, Response integral e relações do §27 ainda precisam de reconciliação. O schema original exige pelo menos um exercício financeiro; casos com zero exercícios devem ter regra expressa reconciliada antes de serem declarados cobertos. A moeda obedece ao padrão do contrato; não existe aqui prova de catálogo completo ISO4217. DOCX/XLSX, partilhas e PDFs/exportações/derivadas sem prova específica continuam abertos. C37, A/B e todo o percurso visual por perfil permanecem pendentes.
|
||||
|
||||
**Preservação e compilação:** 18 conjuntos de linhas anteriores por ID/colunas originais preservados; MFA18 intacto. Manifesto ativo `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` preservado. Manifesto QA `0e610cd650d6b788edfdc9cbf697d125cb9330958914e13a8a8bbd14a781b395`; bundle próprio `build-qa-opportunities`, sem recompilar o ativo. TypeScript, PHPStan e testes específicos passaram; as contagens de testes não são usadas como critério de conclusão.
|
||||
|
||||
Mesma matriz: **3.386 requisitos / 3.386 linhas**, **6.838 ocorrências de origem**. IDs, fontes, critérios, versões divergentes e referências anteriores conservados.
|
||||
|
||||
| Estado canónico | Requisitos | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluÃdo e demonstrado | 6 | 7 |
|
||||
|
||||
Linhas principais: **AUD-OP-02/03/04, AUD-GEO-03, AUD-PROFILE-11/12/19/25/28/31 e AUD-OPS-01**, em [matriz-requisitos.csv](matriz-requisitos.csv). Nenhum desses critérios visuais foi fechado por API.
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Incremento canónico e workflow em QA — 10/10/2026
|
||||
|
||||
Servidor **http://127.0.0.1:18097**; destino guardado **desk_restore_20261009_174726_test:55441**. Zero escritas na base ativa. **C37 e toda a aceitação visual deste incremento permanecem pendentes.** Props da interface, API e persistência foram verificadas; nenhum clique ou renderização no navegador foi observado.
|
||||
|
||||
| Requisito na matriz única | Funciona / prova concreta em QA | Falta para fechar |
|
||||
| --- | --- | --- |
|
||||
| AUD-OP-04; AUD-PROFILE-28 | Dez categorias guardam rascunhos incompletos e recuperam noutra sessão MFA. Seis passam o contrato estrutural: investimento, imobiliário, DUAT, venda, parceria e franquia. Adapter do mesmo schema de 110 definições; condições `allOf` originais recuperadas, IDs existentes mantidos. | Semântica e editores dos objetos incompletos do §25; contrato Response integral; quatro perfis modulares aprovados; demonstração visual por categoria. |
|
||||
| AUD-OP-03; PROFILE-08/11/12 | Promotor 21 criou a oportunidade **18**, submeteu; Validador 24 solicitou correção; 21 corrigiu/resubmeteu; 24 reviu gates e aprovou; Publicador 25 publicou com visibilidade interna. O número 18 identifica a oportunidade, não a conta do investidor C37. | Visual integral, todas as categorias, pareceres/incidentes e coordenação editorial completa. |
|
||||
| PROFILE-09/10/12/21 | Imobiliário **19**: rejeição fundamentada por 24 → recurso reaberto pelo revisor independente 29 → nova aprovação 24 → publicação pública 25 → alteração privada sem alterar a ficha pública → suspensão com GET público 404. Autor Prom+Val+Pub e aprovador Val+Pub receberam 403. | Visual, filas institucionais e competências reais; suspensão/retoma e demais ações editoriais por todos os perfis. |
|
||||
| PROFILE-19/21/22 | Tarefas existentes em `tasks`, vínculo à oportunidade por origem/ID/correlação; atribuição explícita, motivos e decisões recuperáveis. Correção/rejeição concluem revisão; resubmissão não herda atribuição; suspensão conclui tarefas pendentes. Recurso do próprio rejeitor e membro da organização negados. SLA `unconfigured`, responsável e prazo iniciais nulos. | Filas completas, reatribuição/disponibilidade, coordenação editorial e política SLA aprovada. Não foram inventados responsáveis nem prazos. |
|
||||
| COUNTRY-01; GEO-03; OP-02 | Localização MZ/Cabo Delgado/distrito e documentos ligados ao mesmo contrato recuperados nas seis categorias. Oportunidade **28** publicou usando a localização principal mesmo em segundo lugar na lista. Documentos e imagem/duas derivadas verificados com ClamD real e hashes correspondentes. DUAT **20** aprovado internamente não publicou: 422 por autorização externa pendente. | Outros países/formulários, origem/filtros/estatísticas transversais, mapas renderizados, 188 correspondências cartográficas e operações documentais restantes. |
|
||||
| PROFILE-30; DASH-01 | Conta **Promotor QA — conta sintética 21**, MFA normal confirmado, organização e tipos autorizados. Dashboard devolve navegação do Promotor. Permissões temporárias dos testes foram revogadas e uma nova sessão 21 recuperou o dashboard. | Dashboard → formulário → guardar → nova sessão → workflow e resultado visíveis. Auditor ou Superutilizador não substituem este ensaio. |
|
||||
|
||||
Os requisitos acima continuam **parciais**: nenhum passou a concluído pelo incremento. O detalhe está no [relatório do workflow](workflow-oportunidades-qa.md); fontes, versões, critérios e percursos permanecem na [mesma matriz](matriz-requisitos.csv).
|
||||
|
||||
| Estado canónico | Requisitos / linhas da matriz | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 6 | 7 |
|
||||
|
||||
Total: **3.386 requisitos = 3.386 linhas**, **6.838 ocorrências**, **3.380 requisitos abertos**. As referências e versões anteriores foram preservadas.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
<!-- qa-profiles-v1-20261010:begin -->
|
||||
## Perfis v1: resultado do lote em QA — 10/10/2026
|
||||
|
||||
A v1 foi lida integralmente: 530 parágrafos, confrontados com os 471 do exemplar anterior, AGENTS, auditoria, código, schema PostgreSQL e configuração. Fortify, contas, documentos, relações, IDs, classificações, histórico, cinco fases e 19 etapas foram preservados. Keycloak foi avaliado como recomendação; não há necessidade de SSO/federação demonstrada para substituir Fortify neste lote. A aceitação das fontes oficiais permanece intacta.
|
||||
|
||||
**A mesma matriz contém 3.386 requisitos/linhas e 6.838 ocorrências de origem.** Os 3.354 requisitos e 5.830 referências anteriores foram conservados. Acrescentaram-se 32 requisitos verificáveis, os 1.001 parágrafos dos dois exemplares e sete referências diretas da reconciliação. Parágrafos de estrutura e tabelas são referências, não pedidos independentes. Inventário: 591 exemplares, versões e anexos; 202 ficheiros acessíveis nesta varredura, incluindo 12 marcadores de bloqueio Office preservados por bytes/hash.
|
||||
|
||||
| Estado canónico | Requisitos | Ocorrências |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3228 | 6371 |
|
||||
| não implementado | 146 | 454 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 6 | 7 |
|
||||
|
||||
O sexto concluído é apenas **AUD-PROFILE-27**, a avaliação arquitetural de Keycloak. Não encerra autenticação visual nem uma integração externa. **AUD-OP-03 passa a parcial**, pelas regras e transições novas; o workflow integral continua aberto.
|
||||
|
||||
QA: **http://127.0.0.1:18097**, destino efetivamente verificado `desk_restore_20261009_174726_test:55441`, com guarda antes de escrita. Foram preparadas 12 contas sintéticas, com MFA configurado e confirmado normalmente por Fortify, duas organizações e quatro representações. O ensaio comprovou:
|
||||
|
||||
- Dashboard do Promotor → links devolvidos pela interface → criação do rascunho 15 → gravação idempotente → nova sessão MFA → recuperação → edição pelo outro Promotor autorizado da mesma organização. Autor e ID permanecem; outra organização, tipo não autorizado e transferência de organização recebem 403.
|
||||
- Quatro pedidos de Promotor, comprovativos privados com ClamD real e hash recuperado, acompanhamento noutra sessão e aprovação independente. Rejeição pode registar conferências falsas sem inventar validação. Auditor com outro papel continua sem escrita operacional.
|
||||
- Revogação do Validador 24: sessão antiga recebe 401; nova sessão MFA autentica, mas o papel já não autoriza. Inicialização da dupla 27/28 pelo terceiro aprovador 29; substituição atómica 28→30; dupla atual 27/30, terceiro negado, replay negado e sessão anterior invalidada.
|
||||
- Recuperação do titular 30 por código Fortify noutra sessão, sem desativar MFA; código consumido não autentica. Desativação de MFA, eliminação e mudança de email do titular são recusadas. Auditor recupera e exporta metadados filtrados, sem snapshots privados.
|
||||
- Consumidores territoriais do Promotor corrigidos para rotas autorizadas: 11 províncias, **Cabo Delgado** selecionada, guardada e recuperada com o mesmo ID/nome; uma área geométrica devolvida pela API. O desenho no navegador continua por demonstrar.
|
||||
|
||||
**Continuam abertos:** C37, cenários A/B e C43 na interface; contratos especializados completos e vínculos canónicos; submissão/aprovação/publicação positivas, agendamento e versão pública; filas dos coordenadores, pareceres, incidentes de suporte, decisões de recurso, SLA e governança; âmbito provincial de autorização; tokens/dispositivos; privacidade e todas as operações documentais por tipo/perfil. A submissão real do rascunho 15 responde 422 controlado por contrato Create incompleto. Revisão/correção positiva foi ensaiada com uma fixture explicitamente submetida, não com uma submissão real.
|
||||
|
||||
API e persistência estão demonstradas nos escopos acima. Interface preparada e compilada; nenhum navegador automatizável foi inventariado, portanto não há aceitação visual. TypeScript, build QA e PHPStan passaram; 14 testes específicos, com 446 asserções, passaram. A contagem não substitui a prova por requisito.
|
||||
|
||||
Manifesto QA demonstrado: `22947d5c47487137b9e37138f29322d99181c7d0cb0bc655dba67fd2e444ae80`. Manifesto ativo preservado: `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94`. Não há revisão candidata fixada enquanto faltar a aceitação visual.
|
||||
|
||||
Acumulação com a permissão administrativa antiga foi reconciliada antes da alteração: uma conta com atribuição v1 não usa a criação legada para contornar organização/tipo; a revogação não restaura automaticamente privilégio antigo mais amplo. Auditor com permissão legada continua sem escrita administrativa. Testes de compatibilidade comprovam as negações; gestores sem atribuições v1 conservam as funções anteriores. Caso C49 adicionado, sem alteração de privilégios ativos.
|
||||
|
||||
Preservação por ID/colunas originais comprovada em 18 tabelas, incluindo conta 18, documentos e auditoria; manifesto ativo inalterado. Ativo consultado exclusivamente em transação READ ONLY (`investemoz:5432`): perfis 040000 ausentes e flag desativada; 020000 instalada, 030000/percurso ausentes, zero geometrias. **Nenhuma escrita ou alteração de privilégios ativos.** QA mantém 393 geometrias privadas; faltam 31 distritos e 157 postos, já nomeados. Publicação de camadas é separada.
|
||||
|
||||
Evidências sanitizadas: `profiles-v1-http-qa.json`, `profiles-v1-mfa-enrollment-qa.json`, `profiles-v1-recovery-audit-qa.json`, `profiles-v1-current-version-qa.json`, `profiles-v1-preservation-qa.json`, `profiles-v1-reconciliation-proof.json`, `profiles-v1-final-qa.xml` e `profiles-v1-build-qa.log`, em `storage/app/private/investmoz-v2/portal-audit/`. Credenciais, cookies, segredos e códigos não constam dos relatórios.
|
||||
|
||||
[Correspondência por requisito](perfis-v1-reconciliacao.md), [roteiro manual](roteiro-manual-perfis-v1.md), [backlog](backlog.md), [ativação e reversão](plano-ativacao.md). Relatório e backlog também disponíveis no servidor QA, com autenticação, em `/qa-auditoria/relatorio-executivo` e `/qa-auditoria/backlog`.
|
||||
|
||||
|
||||
### Correspondência derivada da mesma matriz
|
||||
|
||||
Esta projeção não é outro inventário editável. Fontes e versões integrais permanecem no CSV.
|
||||
|
||||
**AUD-PROFILE-01 — Seis perfis sobre identidades existentes, com RBAC e ABAC** (parcial).
|
||||
|
||||
Implementação: PortalProfileAccess; users/Fortify, organizations/memberships, quatro atribuições operacionais e dupla controlada; público derivado de conta verificada.
|
||||
|
||||
Prova: Doze atores normais MFA e consultas por perfil no servidor QA; schema ligado às entidades canónicas.
|
||||
|
||||
Lacuna/próxima ação: Âmbito provincial ainda não modelado; migração reconciliada dos gestores legados e aceitação visual por perfil.
|
||||
|
||||
Aceitação: Cada perfil corresponde a uma identidade existente e a regras de servidor; nenhum cadastro de identidades duplicado.
|
||||
|
||||
**AUD-PROFILE-02 — Utilizador público: consulta, favoritos/alertas e pedidos próprios** (parcial).
|
||||
|
||||
Implementação: Capacidades públicas existentes; pedido próprio de Promotor reutiliza conta verificada.
|
||||
|
||||
Prova: Conta20: dashboard→pedido, criação403 e consulta de pedido alheio403; favoritos/alertas não ensaiados neste lote.
|
||||
|
||||
Lacuna/próxima ação: Demonstrar favoritos/alertas e todos os pedidos pessoais na interface; alternativa de confirmação por telefone da v1 ainda não implementada, verificação de email existente preservada.
|
||||
|
||||
Aceitação: Consulta e pedidos próprios funcionam; criação/edição/decisão de oportunidades são negadas ao público.
|
||||
|
||||
**AUD-PROFILE-03 — Solicitar perfil de Promotor com representação, tipos e comprovativos** (parcial).
|
||||
|
||||
Implementação: PortalProfileAdministration::request; documentos canónicos próprios/hash/scan, chave idempotente, âmbito individual ou organização representada.
|
||||
|
||||
Prova: Contas21/22/23/31: quatro pedidos, replay sem duplicação, recuperação noutra sessão, quatro comprovativos sintéticos com ClamD real e recuperação por hash.
|
||||
|
||||
Lacuna/próxima ação: Verificação visual do formulário, submissão de tipos adicionais e regras institucionais de comprovativos por representação.
|
||||
|
||||
Aceitação: Pedido guarda representação/tipos/comprovativos, fica recuperável e não concede o papel por si só.
|
||||
|
||||
**AUD-PROFILE-04 — Decisão independente do pedido de Promotor e tipos aprovados** (parcial).
|
||||
|
||||
Implementação: Fila administrativa, consulta privada dos comprovativos por VerifiedDocumentBinary, versão e atestações auditadas; sem autoaprovação.
|
||||
|
||||
Prova: Super27 consulta originais por hash e aprova quatro pedidos de outros atores; teste nega decisão própria e regista rejeição com verificações falsas sem inventar confirmação.
|
||||
|
||||
Lacuna/próxima ação: Aceitação visual da fila e decisões; não foram validadas identidades reais, apenas documentos sintéticos QA.
|
||||
|
||||
Aceitação: Somente aprovador distinto com MFA confirma identidade/representação/documentos e concede os tipos efetivamente pedidos e aprovados.
|
||||
|
||||
**AUD-PROFILE-05 — Navegação por perfil a partir do dashboard** (parcial).
|
||||
|
||||
Implementação: ProfileNavigation e props de autorização partilhadas; páginas existentes reutilizadas em /minhas-oportunidades.
|
||||
|
||||
Prova: DashboardHTTP21 devolve Minhas oportunidades/Nova oportunidade;20 devolve Solicitar perfil; ensaio segue os URLs devolvidos, sem depender de rota conhecida previamente.
|
||||
|
||||
Lacuna/próxima ação: Cliques, renderização, responsividade e resultado visível no navegador; C37 não fechado.
|
||||
|
||||
Aceitação: Promotor autorizado descobre lista/criação no dashboard; os outros descobrem pedido e acompanhamento sem obter criação.
|
||||
|
||||
**AUD-PROFILE-06 — Edição organizacional por outros Promotores e âmbito pessoal exclusivo** (parcial).
|
||||
|
||||
Implementação: OpportunityManagement/Resources reutilizados; owner_user_id preservado, contributors e membership de escrita em servidor.
|
||||
|
||||
Prova: Autor21 cria oportunidade15; colega22 autorizado edita, recuperação mantémID/autor;23 fora da organização403. Seletores/mapas mudam para endpointsautorizadosv1:11províncias, seleçãoCaboDelgado gravada/recuperada noutra sessão com geometriaAPI. Teste de representação revogada nega escrita.
|
||||
|
||||
Lacuna/próxima ação: Percurso visual e coedição dos recursos por operação; restrições provinciais permanecem separadas.
|
||||
|
||||
Aceitação: Outros Promotores autorizados da organização editam rascunho/correção; pessoal sem organização só pelo titular; histórico preservado.
|
||||
|
||||
**AUD-PROFILE-07 — Autorizações por tipo e representação sem transferência silenciosa** (parcial).
|
||||
|
||||
Implementação: Tipos em atribuições e pedidos; organização imutável na edição, autor/workflow/status proibidos no payload.
|
||||
|
||||
Prova: HTTP22: mudança para tipo não aprovado403 e transferência de organização403; fixture testa isolamento de tipos.
|
||||
|
||||
Lacuna/próxima ação: Demonstrar todos os contratos especializados e seus campos/validadores; seleção do tipo não os encerra.
|
||||
|
||||
Aceitação: Servidor nega qualquer criação/edição fora dos tipos aprovados ou da representação; novos tipos exigem decisão.
|
||||
|
||||
**AUD-PROFILE-08 — Estados, razões, versões e dependências reais do workflow** (parcial).
|
||||
|
||||
Implementação: OpportunityProfileWorkflow reutiliza códigos/management_version/etag/audit_events; revisão, correção, parecer, rejeição/suspensão/arquivo e pedidos segregados.
|
||||
|
||||
Prova: HTTP: pedido de arquivo guarda e mantémdraft, submissão422 com lacuna explícita; fixture submetida inicia revisão, pede correção e recupera edição.
|
||||
|
||||
Lacuna/próxima ação: Submissão completa, aprovação/publicação, incidentes e conservação de versão pública ainda não demonstradas; fixture não é submissão real.
|
||||
|
||||
Aceitação: Transições válidas e fundamentadas preservam versão/história; inválidas são negadas; preparação não se confunde com decisão externa.
|
||||
|
||||
**AUD-PROFILE-09 — Impedir aprovação própria, conflitos de organização e publicação pelo aprovador** (parcial).
|
||||
|
||||
Implementação: Autor, contributors e membership da organização impedem revisão; publicação verifica aprovador da mesma versão; autorizações rechecadas sob lock.
|
||||
|
||||
Prova: Teste autor com Promotor+Validador+Publicador continua403; regras editor/representação verificadas no serviço.
|
||||
|
||||
Lacuna/próxima ação: Publicação real permanece bloqueada pelo contrato; testar ciclo completo e reatribuição independente com versão aprovada real.
|
||||
|
||||
Aceitação: Autor/editor/representante não aprova; aprovador não publica a mesma versão; elevação não remove esses impedimentos.
|
||||
|
||||
**AUD-PROFILE-10 — Acumulação de papéis com negação prevalecente** (parcial).
|
||||
|
||||
Implementação: Auditor nega escrita operacional; Super exige papel operacional mais elevação independente; papéis não se somam para ultrapassar restrições.
|
||||
|
||||
Prova: Conta31 Promotor+Validador+Auditor lê e recebe403 na edição;27 Super sem Promotor/elevação recebe403 na criação. Fixture com papéis v1 e allowlist legada: criação fora do âmbito403, dashboard sem criação legada; Auditor nega escrita administrativa. Revogação/expiração não restaura permissão legada automaticamente.
|
||||
|
||||
Lacuna/próxima ação: Demonstração visual e combinações operacionais adicionais com elevação aprovada.
|
||||
|
||||
Aceitação: A acumulação nunca autoriza ato negado por autoria, organização, tipo, estado, independência ou Auditor.
|
||||
|
||||
**AUD-PROFILE-11 — Validador: revisão atribuída, MFA, correções e decisão** (parcial).
|
||||
|
||||
Implementação: Papel validator com âmbito/tipos e responsabilidades compatíveis; MFA confirmado exigido na atribuição e recente na operação.
|
||||
|
||||
Prova: 24 aprovado inicialmente, aprovação de draft409, revogado depois; fixture independente revisa/corrige e recupera nova versão; semMFA403.
|
||||
|
||||
Lacuna/próxima ação: Fila de atribuição e completude normativa, decisão positiva integral e interface.
|
||||
|
||||
Aceitação: Validador independente e com MFA só decide âmbito/tipo/estado permitidos; justificação e itens de correção persistem.
|
||||
|
||||
**AUD-PROFILE-12 — Publicador: versão aprovada, suspensão/arquivo e segregação** (parcial).
|
||||
|
||||
Implementação: Papel publisher, MFA recente, independência; conteúdo técnico não editável; publicar bloqueado se completude/versão faltam.
|
||||
|
||||
Prova: 25 publica draft409; fixture confirma estado incompatível; todos os atos positivos de publicação integral continuam pendentes.
|
||||
|
||||
Lacuna/próxima ação: Publicação e agendamento, revisão de divulgação, suspensão urgente/retificação e prova da versão pública.
|
||||
|
||||
Aceitação: Publicar somente versão completa aprovada por outro ator; suspender/arquivar com razão sem alterar original nem decisão externa.
|
||||
|
||||
**AUD-PROFILE-13 — Auditor: consulta filtrada/exportação e ausência de escrita** (parcial).
|
||||
|
||||
Implementação: /perfis/auditoria usa audit_events existentes, filtros e JSON minimizado; Auditor conserva impedimento de escrita.
|
||||
|
||||
Prova: 26 recupera substituição filtrada e exporta metadados sem snapshots privados;20 recebe403;31 multirrole edição403.
|
||||
|
||||
Lacuna/próxima ação: Interface, exportação ampliada autorizada por classificação e consulta dos restantes domínios administrativos.
|
||||
|
||||
Aceitação: Filtros retornam só metadados autorizados e exportação rastreável; nenhum botão/API permite escrita operacional ao Auditor.
|
||||
|
||||
**AUD-PROFILE-14 — Superutilizador: administração controlada e elevação temporária** (parcial).
|
||||
|
||||
Implementação: PortalSuperusers/Administration; MFA recente, motivo, aprovação independente e validade15min; aprovador deve continuar na dupla.
|
||||
|
||||
Prova: 27 administra pedidos/papéis, autoaprovação403, criação sem papel403; elevação API preparada, ainda sem ato operacional positivo completo.
|
||||
|
||||
Lacuna/próxima ação: Demonstração da elevação completa, expiração, scope/action exatos e interface de administração.
|
||||
|
||||
Aceitação: Super administra sem operar automaticamente; elevação independente expira e não substitui papel operacional ou segregação.
|
||||
|
||||
**AUD-PROFILE-15 — Exatamente dois Superutilizadores: inicialização e substituição atómica** (parcial).
|
||||
|
||||
Implementação: Dupla singleton ligada a users, CHECK distintos, triggerDELETE/TRUNCATE, versão/lock, aprovação e atualização única.
|
||||
|
||||
Prova: Inicialização27/28 pelo29; terceira atribuição422; substituição28→30 com aprovação28 independente de27, dupla27/30; replay409, sessão28invalidada. TriggerDELETE testado.
|
||||
|
||||
Lacuna/próxima ação: Interface, testes concorrentes em sessões simultâneas e cerimónia ativa com titulares reais ainda não designados.
|
||||
|
||||
Aceitação: Após inicialização existem sempre dois titulares distintos; nem terceiro, remoção isolada, autoaprovação ou replay altera essa cardinalidade.
|
||||
|
||||
**AUD-PROFILE-16 — Recuperar acesso preservando MFA e dupla** (parcial).
|
||||
|
||||
Implementação: Códigos de recuperação Fortify e substituição independente; bloqueio de desativaçãoMFA, mudançaemail/eliminação de titular.
|
||||
|
||||
Prova: 30 recupera sessãoMFA noutra sessão pelo códigoFortify; reutilização422 sem autenticação; DELETE-MFA/conta e PATCHemail403.
|
||||
|
||||
Lacuna/próxima ação: Procedimento institucional quando ambos perdem todos os meios; aceitação visual e guarda local dos códigos sem os transmitir.
|
||||
|
||||
Aceitação: Recuperação normal mantém MFA e dois titulares; código consumido não autentica; emergência não é desativaçãoMFA ou key:generate.
|
||||
|
||||
**AUD-PROFILE-17 — Revogação imediata com histórico e invalidação de sessões/tokens** (parcial).
|
||||
|
||||
Implementação: Atribuição retida, revision de autorização por conta, remember_token revogado; adaptação sessions/personal_access_tokens quando presentes.
|
||||
|
||||
Prova: 24 antigo cookie401; nova sessãoMFA autentica mas acesso de papel403;28 substituído igual. Histórico original preservado.
|
||||
|
||||
Lacuna/próxima ação: Tokens pessoais não instalados/ensaiados no alvo, dispositivos/integradores e interface por consumidor.
|
||||
|
||||
Aceitação: Revogar invalida sessões e credenciais de acesso aplicáveis sem apagar conta, MFA, documentos, autoria ou decisões.
|
||||
|
||||
**AUD-PROFILE-18 — Validade, reavaliação por inatividade e documentação expirada** (parcial).
|
||||
|
||||
Implementação: starts_at/expires_at/revoked_at consultados a cada pedido; atribuições documentadas sem cache de autorização.
|
||||
|
||||
Prova: Fixture de papel expirado não autoriza; MFA antigo não permite decisão.
|
||||
|
||||
Lacuna/próxima ação: Revalidação/inatividade/expiração documental, parâmetros aprovados e avisos; não inventar prazos.
|
||||
|
||||
Aceitação: Atribuição expirada ou representação revogada deixa de autorizar; reavaliação usa prazos/regra versionados aprovados.
|
||||
|
||||
**AUD-PROFILE-19 — Coordenadores de validação/publicação e pareceres técnico-jurídicos** (não implementado).
|
||||
|
||||
Implementação: Responsabilidades mapeadas e compatibilidade por papel validadas; entidades existentes de tarefa/auditoria a reutilizar.
|
||||
|
||||
Prova: Responsabilidade validation_coordinator em Auditor422; papéis/responsabilidades armazenáveis, filas e reatribuições não implementadas.
|
||||
|
||||
Lacuna/próxima ação: Implementar filas/atribuição, conflito, disponibilidade, parecer e escalamento sem novos perfis ou identidades.
|
||||
|
||||
Aceitação: Coordenador autorizado atribui/reencaminha; parecerista responde; impedimentos persistem; ação visível e recuperável.
|
||||
|
||||
**AUD-PROFILE-20 — Suporte técnico, incidentes e preservação da submissão** (não implementado).
|
||||
|
||||
Implementação: Balcão/tarefas/audit_events existentes; suporte mapeado sem acesso documental automático; nenhum gestor fictício.
|
||||
|
||||
Prova: Correspondência documental preparada; protocolo de incidentes da submissão não implementado neste workflow.
|
||||
|
||||
Lacuna/próxima ação: Incidentes recuperáveis, suporte atribuído, falha de submissão/ficheiro sem perda, alertas e estados próprios.
|
||||
|
||||
Aceitação: Falha técnica conserva submissão/versão, abre incidente e encaminha sem dar ao suporte permissão de decisão ou dados indevidos.
|
||||
|
||||
**AUD-PROFILE-21 — Recurso com instância independente e conservação da decisão original** (parcial).
|
||||
|
||||
Implementação: Responsabilidade appeal_reviewer de Validador e pedido de recurso no workflow_context existente.
|
||||
|
||||
Prova: API de pedido prevista apenas rejected; decisão independente e percurso positivo de recurso não demonstrados.
|
||||
|
||||
Lacuna/próxima ação: Fila/revisor distinto do decisor inicial, resposta/motivo/versionamento e recuperação visual.
|
||||
|
||||
Aceitação: Recurso não sobrescreve decisão; revisor distinto e autorizado decide com razão, sem publicar.
|
||||
|
||||
**AUD-PROFILE-22 — Prazos, tolerâncias e ciclos aprovados/configuráveis/versionados** (parcial).
|
||||
|
||||
Implementação: policy_version nas atribuições e workflow, correction_cycles preservados; ausência de parâmetros não encerra processos automaticamente.
|
||||
|
||||
Prova: Versão de autorização profiles-v1-20261010 e correção auditadas; gestão/efeito de parâmetros ainda não implementados.
|
||||
|
||||
Lacuna/próxima ação: Tela/versionamento/governança e adaptação dos processos antigos; valores institucionais aprovados.
|
||||
|
||||
Aceitação: Alterar SLA/limites requer aprovação, vigência e versão; processo antigo conserva regra aplicável e nenhuma configuração inventa prazo.
|
||||
|
||||
**AUD-PROFILE-23 — Auditoria integral, razões, origem e alertas de segurança** (parcial).
|
||||
|
||||
Implementação: audit_events existente com ator/IP/tempo/correlação/política/razão e before-after relevantes; denegação sanitizada sem corpoauth.
|
||||
|
||||
Prova: HTTPs e fixture recuperam decisão/revogação/substituição e alerta administrativo; exportação minimizada não inclui segredos.
|
||||
|
||||
Lacuna/próxima ação: Cobertura dos fluxos ainda ausentes, exportação autorizada auditada, alertas operacionais e interface.
|
||||
|
||||
Aceitação: Cada decisão/crítica/negação guarda contexto rastreável e antes/depois aplicável; leitura/exportação não expõe segredos.
|
||||
|
||||
**AUD-PROFILE-24 — MFA e segurança de sessão/autenticação sem contornar C37** (parcial).
|
||||
|
||||
Implementação: Fortify existente, MFA normal, sessão QA/cookie isolados e epochs; proteção de MFA obrigatório em papéis críticos.
|
||||
|
||||
Prova: Doze contas confirmamMFA pelo servidor18097, nova sessão e recuperação30 comprovadas; C37conta18 não repetido/fechado.
|
||||
|
||||
Lacuna/próxima ação: C37/interface, duração/reautenticação operacional e restantes consumidores/devices.
|
||||
|
||||
Aceitação: Login→MFA→sessão→reload→logout→novo login funciona visualmente sem partilha indevida de cookie ou desativaçãoMFA.
|
||||
|
||||
**AUD-PROFILE-25 — Interface responsiva, gravação segura, erros e acompanhamento** (parcial).
|
||||
|
||||
Implementação: Formulários/feedback existentes reutilizados; novas páginas de pedido/admin/auditoria e contexto organizacional.
|
||||
|
||||
Prova: PropsHTTP/compilação e TypeScript verificáveis; gravação/versionamentoAPI, nenhum navegador automatizável inventariado.
|
||||
|
||||
Lacuna/próxima ação: Demonstração visual/acessibilidade, autosave e erros por campo nos contratos completos.
|
||||
|
||||
Aceitação: Formulários recuperam dados, mostram ações/erros/pendências e não perdem resposta; navegação acessível por perfil.
|
||||
|
||||
**AUD-PROFILE-26 — Privacidade, consentimento, consulta/correção/desativação** (parcial).
|
||||
|
||||
Implementação: users/consents/documentos privados existentes; autorizações por pedido; remoção que destruiria titulares/histórico é negada.
|
||||
|
||||
Prova: Comprovativos privados por hash/autorização e não publicados; proteção de titular403; fluxo geral de desativação não ensaiado.
|
||||
|
||||
Lacuna/próxima ação: Consulta/correção/desativação preservando rastreabilidade e direitos aplicáveis, retenção/documentação por tipo.
|
||||
|
||||
Aceitação: Titular gere dados/consentimento sem perder história ou eliminar dupla; dados privados não passam ao público.
|
||||
|
||||
**AUD-PROFILE-27 — Avaliar Keycloak antes de substituir Fortify** (concluído e demonstrado).
|
||||
|
||||
Implementação: Decisão arquitetural explícita em perfis-v1-reconciliacao.md; Fortify mantido e autorização funcional na aplicação.
|
||||
|
||||
Prova: Código/schema confirmam Fortify e ausência de Keycloak; documentação oficial Keycloak/Laravel confrontada; necessidadeSSO/federação não demonstrada.
|
||||
|
||||
Lacuna/próxima ação: Nenhuma lacuna desta avaliação restrita; eventual adoção futura exige necessidade/IDs/MFA/recuperação/migração aprovados.
|
||||
|
||||
Aceitação: Recomendação avaliada; decisão fundamentada preserva autenticação/IDs; não declara integraçãoKeycloak ativa.
|
||||
|
||||
**AUD-PROFILE-28 — Contratos especializados completos antes de submissão/aprovação/publicação** (parcial).
|
||||
|
||||
Implementação: Schema normativo110defs/novecontratos e primeiro draftimobiliário anteriores; novos gates falham de forma controlada.
|
||||
|
||||
Prova: Submissão realdraft15 responde422 com lacuna do contratoCreate; versões públicas não alteradas.
|
||||
|
||||
Lacuna/próxima ação: Adaptadores canónicos, camposUI/regras/vínculos/evidências especializados e ciclo completo sem fixtureestado.
|
||||
|
||||
Aceitação: Uma oportunidade completa cumpre contrato/tipo/vínculos; submissão/revisão/aprovação/publicação integral por atores distintos é demonstrada.
|
||||
|
||||
**AUD-PROFILE-29 — Reconciliar permissões administrativas legadas sem retirada automática** (dependente de decisão concreta).
|
||||
|
||||
Implementação: manage-portal preservado para registroslegados; novos privados não autorizados pela allowlist; nenhuma conta ativa promovida/revogada.
|
||||
|
||||
Prova: AtivoREADONLY:rolesv1 ausentes/flagfalse; gestores2/13 existentes; fixture nega acesso legado ao novo privado sem perfil.
|
||||
|
||||
Lacuna/próxima ação: Pergunta para futuraativação: que funções dos gestores ativos devem corresponder a Validador, Publicador, Auditor ou administração controlada? Quem serão os dois titulares e o aprovador da cerimónia?
|
||||
|
||||
Aceitação: Correspondência aprovada conserva contas/história e deixa diferenças/permissões explícitas; nenhuma revogação ativa automática.
|
||||
|
||||
**AUD-PROFILE-30 — Contas sintéticas por perfil e demonstração permitida/negada** (parcial).
|
||||
|
||||
Implementação: Doze contas sintéticasQA, duas organizações e quatro representações; contas antigas preservadas.
|
||||
|
||||
Prova: profiles-v1-http-qa.json e recovery-audit: permitido/negado, rolesmúltiplos, troca/revogação/nova sessão sem divulgarcredenciais.
|
||||
|
||||
Lacuna/próxima ação: Demonstração visual por perfil;24 agora revogado e28 substituído, não apresentar esses atores como papéis ainda ativos.
|
||||
|
||||
Aceitação: Cada perfil tem ensaio positivo/negativo e recuperação noutra sessão; revogação/múltiplos papéis não contornam autorização.
|
||||
|
||||
**AUD-PROFILE-31 — Instalação, disponibilização e reversão dos perfis separadas** (parcial).
|
||||
|
||||
Implementação: Migration040000guardada, flagQAtrue/ativofalse, fingerprints das instalações existentes incluem o código de autorização.
|
||||
|
||||
Prova: QA:4tabelas,users1/opportunities3colunas,1função2triggers; ativoREADONLYausente. Baseline18tabelas originais preservadas; assetsativosSHAigual.
|
||||
|
||||
Lacuna/próxima ação: Mecanismo autenticado de instalação040000 e bootstrap ativo auditável, nova prova backup/restauro/compatibilidade/reversão apósvisuais, ativação não autorizada.
|
||||
|
||||
Aceitação: Instalar somente ausentes com prévia/provas/replay; designar dupla de forma controlada; flag não é instalação; rollback conserva dados e nega acesso aos privados.
|
||||
|
||||
**AUD-PROFILE-32 — Arquivo/exclusão/restauro do Promotor versus gestor legado** (parcial).
|
||||
|
||||
Implementação: Decisão antes do código:Promotorv1 apenas solicita; Publicador decidearquivo; lifecyclegestorlegado preservado para antigos, privado não herda.
|
||||
|
||||
Prova: HTTP21pedidoarquivo persiste sem mudar draft; endpointlifecycle privado403; gestorC43 anterior conserva percurso independente.
|
||||
|
||||
Lacuna/próxima ação: Interfacepedido/decisãopublicador com caso válido e critério de conservação; não prometer exclusão/restauro não concedidos pela v1.
|
||||
|
||||
Aceitação: Promotor solicita com razão, sem arquivar/apagar/restaurar; decisor autorizado registraresultado; legado mantém direitos identificados.
|
||||
|
||||
<!-- qa-profiles-v1-20261010:end -->
|
||||
|
||||
> Registos anteriores preservados como histórico. Os estados correntes são os da matriz e do bloco mais recente acima; uma entrega posterior não altera retroativamente a evidência anterior.
|
||||
|
||||
Fonte integral: `Gestao de perfis investeMOZ v1.docx`, versão 1.0, 10/10/2026, 530 parágrafos, SHA256 `f9e0f6280566fed8b52d0b36bb814a82e11976fec9ea9f62d64611ff8c824b38`. Exemplar anterior: `Gestao de perfis investeMOZ.docx`, 471 parágrafos, SHA256 `283a32f4c4b02bdfd9318a9174268224eeefac4349c49e3171545707bd222855`. São especificações funcionais do responsável, não diplomas oficiais. Fontes, versões e divergências ficam na matriz existente; não se cria outra matriz.
|
||||
|
||||
## Decisões documentadas antes da alteração em QA
|
||||
|
||||
1. **Edição pela organização:** o responsável decidiu nesta conversa permitir outros Promotores autorizados da organização. Representação ativa de escrita em `memberships` e autorização para o tipo continuam obrigatórias. A autoria original e todos os editores ficam registados; não se transfere propriedade silenciosamente. Sem organização, o âmbito individual permanece exclusivamente do titular da conta.
|
||||
2. **Arquivo/exclusão:** a v1 §3.2 atribui ao Promotor apenas solicitar suspensão/arquivo. Não concede exclusão nem restauro. O percurso C43 anterior de gestor continua para os registos legados; não se converte esse gestor em Promotor nem em Superutilizador. Os novos registos sujeitos à v1 usam pedidos e decisões separadas.
|
||||
3. **Gestores existentes:** `manage-portal` é atualmente verificação de email mais lista `portal.managers`; não é um catálogo de seis perfis. Preserva-se a lista e o histórico. A lista não permite editar, validar, publicar, arquivar ou administrar perfis v1 por si só. Na futura ativação, a migração das permissões administrativas existentes exige reconciliação explícita, sem retirar privilégios ativos automaticamente.
|
||||
4. **Identidade:** reutilizam-se `users`, Fortify, sessões, `organizations`, `memberships`, `documents` e `audit_events`. Atribuições de papéis e pedidos são dados de autorização ligados a essas entidades, não novas contas nem um fornecedor paralelo de identidade. O perfil público é derivado de conta confirmada. Papéis de âmbito organizacional não equivalem a `memberships.role`.
|
||||
5. **Acumulação:** negação prevalece. Auditor mantém operações de oportunidades somente de leitura; Superutilizador necessita de papel operacional mais elevação temporária independente para operar. Autor, editor ou representante da organização não aprova a oportunidade. Quem aprova uma versão não a publica.
|
||||
6. **Pessoa singular:** âmbito pessoal usa `organization_id=null` mais titular da conta, sem fabricar uma organização. Empresa/entidade/associação/parceiro exigem organização existente e representação de escrita. Aprovação documental não cria automaticamente organizações, vínculos ou autorização de outros tipos.
|
||||
7. **Publicação:** contratos especializados incompletos continuam a impedir submissão/aprovação/publicação. Criar e guardar rascunho é independente. Nenhuma revisão interna constitui licença, DUAT, financiamento ou decisão externa.
|
||||
8. **Superutilizadores:** não se escolhem titulares reais. Inicialização QA usa duas contas sintéticas distintas, verificadas e com MFA confirmado. A dupla é uma única entidade transacional; substituição atualiza os dois titulares numa operação, com versão, MFA recente, consentimento independente, auditoria e invalidação do acesso anterior. Recuperação usa Fortify/MFA/códigos de recuperação existentes ou substituição aprovada; nunca desativa MFA nem usa segredo/chave ativos em QA.
|
||||
|
||||
9. **Acumulação com a allowlist legada:** uma conta que recebe um papel v1 em QA não usa a permissão legada para contornar a organização, o tipo ou o impedimento de escrita do Auditor. Escrita de oportunidades legadas por essa conta exige reconciliação explícita; o percurso v1 usa os seus registos sujeitos à política. Gestores sem papéis v1 conservam as funções anteriores. Nenhuma conta ativa recebe ou perde automaticamente um papel. Auditor com permissão administrativa legada continua sem escrita administrativa; dados pessoais próprios e pedidos pessoais mantêm o tratamento existente.
|
||||
|
||||
## Correspondência dos perfis
|
||||
|
||||
| Perfil v1 | Entidades e controlos reutilizados | Âmbito e regra |
|
||||
| --- | --- | --- |
|
||||
| Utilizador público | `users`, verificação Fortify, favoritos/alertas/pedidos existentes | Dados e pedidos próprios; catálogo público; solicita perfil, sem criar oportunidades |
|
||||
| Promotor | `users`, `memberships`, autorização por tipo, OpportunityManagement/Resources | Organização representada e tipos aprovados; pessoal apenas próprio; guarda e responde a correções |
|
||||
| Validador | Papel atribuído a `users`, oportunidades/auditoria existentes | Âmbito/tipo, MFA, independência do autor/editores/organização; revisão sem publicação |
|
||||
| Publicador | Papel atribuído a `users`, versão aprovada e auditoria | MFA, aprovação independente da versão; não edita conteúdo técnico |
|
||||
| Auditor | Papel atribuído a `users`, `audit_events` e configurações | Leitura e exportação minimizada; negação de escrita operacional prevalece |
|
||||
| Superutilizador | Dupla controlada ligada a `users`, Fortify/MFA, auditoria | Administração de perfis; operação exige elevação independente e papel operacional |
|
||||
|
||||
## Responsabilidades adicionais — não são novos perfis
|
||||
|
||||
| Responsabilidade citada | Correspondência proposta | Limite |
|
||||
| --- | --- | --- |
|
||||
| Coordenador de validação | Validador com responsabilidade `validation_coordinator` explicitamente atribuída | Atribuir/reencaminhar; mesmos impedimentos para decidir |
|
||||
| Responsável técnico/jurídico | Validador com âmbito e responsabilidade técnica/jurídica | Parecer documentado, não decisão externa presumida |
|
||||
| Suporte técnico | Superutilizador com responsabilidade técnica, ou tarefa atribuída a suporte no mecanismo existente | Diagnóstico de incidente; não ganha acesso ao conteúdo ou decisão por receber tarefa |
|
||||
| Gestor operacional/direção/conformidade | Superutilizador administrativo; Auditor para controlo sem escrita | Escalar e auditar; não ultrapassa segregação operacional |
|
||||
| Instância de recurso | Validador com responsabilidade `appeal_reviewer`, distinto de autor, editores e decisor inicial | Decisão de recurso preserva decisão original; não publica |
|
||||
| Coordenador de publicação | Publicador com responsabilidade de coordenação | Reatribuir publicação; não aprova conteúdo |
|
||||
|
||||
Estas correspondências técnicas não nomeiam responsáveis reais. Responsáveis institucionais, SLA, tolerância, limites de ciclos e política documental por tipo precisam de valores aprovados e versionados antes de automatizar escalamentos. Não se inventam prazos. Enquanto ausentes, devem ficar visíveis e sem encerramento automático.
|
||||
|
||||
## Implementação e compatibilidade
|
||||
|
||||
O incremento é condicionado por `portal.profile_access_enabled`, desativado por omissão. QA mantém `manage-portal` para funções legadas e separa os novos controlos. Registos antigos não recebem organização, autor, tipo ou novos estados por inferência. Os códigos existentes `in_review`, `information_pending`, `needs_information`, `approved`, `rejected`, `published`, `suspended` e `archived` são preservados; os rótulos novos não renumeram os roteiros, as cinco fases ou as 19 etapas.
|
||||
|
||||
A criação/edição reutiliza o rascunho existente. A autorização verifica no servidor representação, tipo, estado, validade, revogação, autoria/intervenientes, conflitos e MFA. Os links “Minhas oportunidades”, “Nova oportunidade” e “Solicitar perfil de Promotor” são derivados dessas regras, não autoridade em si. Pedidos ficam recuperáveis pelo titular e revistos por outra pessoa autorizada.
|
||||
|
||||
## Keycloak
|
||||
|
||||
É recomendação arquitetural, não dependência deste lote. A [documentação oficial do Keycloak](https://www.keycloak.org/docs/latest/authorization_services/index.html) descreve políticas RBAC/ABAC; a aplicação continua responsável por aplicar decisões aos seus recursos. Laravel já oferece [policies e gates](https://laravel.com/framework/docs/12.x/authorization). A decisão deste lote é manter Fortify: a necessidade de federação/SSO de vários sistemas não foi demonstrada no ambiente atual. Uma futura troca precisa de prova de necessidade, correspondência estável de IDs, compatibilidade MFA/sessões, operação e recuperação independentes, e migração aprovada. Não se substitui autenticação para resolver regras de organização/tipo/estado.
|
||||
|
||||
## Ativação e reversão
|
||||
|
||||
Este lote não autoriza a base ativa. Antes de ativar: concluir demonstração visual por perfil, reconciliar permissões legadas e titulares reais, fixar revisão, renovar backup/restauro de base e ficheiros, aplicar instalação autenticada apenas se ausente e disponibilizar a flag separadamente. Instalações anteriores de gestão/recursos/percurso não se repetem. Desativar a flag preserva atribuições, pedidos e história; registos novos não podem ficar expostos às rotas legadas. Reversão de schema só antes de dados novos; depois requer restauro reconciliado ou preservação das tabelas. Não revogar privilégios ativos automaticamente.
|
||||
|
||||
Estado inicial deste registo: diagnóstico documental e decisão de compatibilidade; demonstrações do incremento serão acrescentadas após os ensaios. C37 e os percursos visuais A/B permanecem pendentes.
|
||||
@@ -1,5 +1,146 @@
|
||||
# Plano consolidado de ativação — lote 3
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Sequência reconciliada — preparada, não executada no ativo
|
||||
|
||||
**030000 → 040000 → 190000** mantém-se. QA já tem as três; **não repetir instalações QA**. No ativo lido READ ONLY, as três extensões estão ausentes; Balcão07/190000 eorientação08/120000 estão instalados. A extensão **09/190000 não foi substituída**: faltam ledger,duascolunas e definição19etapas. Fonte:dashboard-active-readonly.json eopportunity-semantic-environment-qa.json.
|
||||
|
||||
| Ordem | Ação futura, efeito econtagem prevista | Dependência / reversão |
|
||||
| --- | --- | --- |
|
||||
| 0 | Demonstração visual de C37, QA21 e A/B e correções → fixar candidata, hashes, manifesto e configuração → backup da base + originais + runtime e chave estável → restauro emQA nova, tabelas/bytes/acessos comparados. | QA backup deste lote não é backup ativo nem candidata definitiva. Ativação continua fora da autorização. |
|
||||
| 1 | Disponibilizar código/Composer/build compatíveis, flagsfalse eURLs preservadas. | Não retirar privilégios reais ou migraridentidades automaticamente. Reverter flags/build/código compatível preservando dados novos. |
|
||||
| 2 —030 | GETprévia→POST `/management/opportunity-resources-install`, hash/confirm/MFA/provas atuais, sóse ausente/coerente. **três coleções+metadado cifra;zero documentos importados/publicados.** | Base020/documentos existentes. Não drop apósuso; restaurar/reconciliar backup em vez de apagar originais. |
|
||||
| 3 —040 | GETprévia→POST `/management/profile-install`; **quatro tabelas + uma coluna em users + três em opportunities,zero contas, atribuições ou flags.** BootstrapPOST `/management/profile-install/bootstrap` é **separado**:duas contas explicitamente designadas; um terceiro gestor com MFA aprova. | Instalador exige020+030/schema/organização/auditoria, hash ebackup/restauro atuais. Idempotência eDDL/ledger/auditoria atómicos. Schema parcial bloqueia aplicação; down apenassemqualqueruso/contexto/revisão/histórico. Recuperação deMFA simultânea exigeD-R1,sembootstraprepetido. |
|
||||
| 4 —09/190 | GETprévia→POST `/management/balcao-install`, sómigraçãofaltante: **duas colunas + uma definição publicada de 19 etapas / cinco fases; zero dossiers automáticos.** | BaseBalcão/orientação jáinstaladas: não repetir. SemdepDDLde040,masordem operacional preservada. Template eversões usados imutáveis; reverterdisponibilização,preservar snapshots/roteiros. |
|
||||
| 5 —cartografia | GETprévia→POST `/management/territory-geometry-import`, níveis1→2→3 ehash/idempotência. **408 correspondências candidatas** se cadastro/hash coincidem:11/133/264;ativo0na leitura. **173 pendentes** não importadas. | Fontes/hash/pai/nome/código vinculados;resultado/contagem medida no alvo,semfusões ougeometrias inventadas. Reversão só lotesnovos nãoalterados/usados,postos→distritos→províncias comaudit;seFK/uso,preservar/reconciliar,nãoforçar. |
|
||||
| 6 —disponibilização | Aprovar perfis/reconciliação depermissões reais econfiguração; habilitar navegação/editores/percurso porincremento comprovado. **zero fixtures QA no ativo.** | I1–I3 não concluídos semA/B/interface. Contratos4categorias eSLA sóapósdecisão própria. Importação não ativa cálculo fiscal. |
|
||||
| 7 —publicação | Revisão dedivulgação eação do Publicador por versão; **camadas cartográficas públicas são ação separada** daimportação. | Aprovaçãointerna≠acessorestrito≠catálogopúblico≠decisãoexterna. Desabilitar publicação/flags antes derestauro;preservar originais,consentimentos eauditoria. |
|
||||
|
||||
Sharedcode/fingerprint muda:renovar provas da revisão ealvo;ready=true não instala,não escolhe titulares,não publica enão disponibiliza percurso. Guarda ativa/QA conferida antes decada escrita futura. A prova040 emschema vazio foi isolada; no servidorQA jáusado sóoperação sem alterações, auditada.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Ativação reconciliada: 030000, 040000 e **09/190000** — nenhuma execução no ativo
|
||||
|
||||
A omissão de **2026_10_09_190000_prepare_investor_path** na sequência anterior foi um erro documental; a extensão **continua necessária e não foi substituída**. Não confundir com **2026_10_07_190000_create_internal_virtual_desk**, que já está instalada no ativo.
|
||||
|
||||
| Recurso | QA restaurada:55441 | Ativo investemoz:5432, READ ONLY | Evidência |
|
||||
| --- | --- | --- | --- |
|
||||
| Gestão020000 | Instalada | Instalada | Ledger/schema anteriores preservados |
|
||||
| Balcão07/190000 e orientação08/120000 | Instalados | Ambos no ledger | Leitura efetiva das migrações, sem boot de sessão ativa |
|
||||
| Recursos09/030000 | Instalada | Ausente | Tabelas e ledger; não repetirQA |
|
||||
| Perfis10/040000 | Instalada | Ausente | Tabelas/verificaçãoQA; nunca instalada no ativo neste trabalho |
|
||||
| Percurso09/190000 | **Ledger + duas colunas + definição publicada** | **Ledger ausente; duas colunas ausentes; nenhuma definição** | `dashboard-active-readonly.json`, `opportunity-semantic-environment-qa.json` |
|
||||
|
||||
DefiniçãoQA `61000919-0000-4000-8000-000000000001`, publicada, SHA256 **e0acbc723cd3def7e415b2ef7a0173add08387c3cedabac9bb7b86409e4d25bf**. Duas colunas: `investor_journeys.path_definition_id` e `journey_steps.path_detail`. Mantém cinco fases e rastreabilidade das19etapas; não renumera/reescreve os roteiros existentes. Recurso preparado/definição publicada não demonstra A/B na interface.
|
||||
|
||||
Dependências verificadas no código: **030000** usa oportunidades/documentos existentes e cria três coleções de recursos; **040000** acrescenta quatro tabelas de autorização/auditoria, uma coluna de revisão em users e três colunas em opportunities, preservando identidades/histórico; não usa as colunas de190000. **190000** usa investor_journeys/journey_steps/questionnaire_versions e depende da baseBalcão/orientação incluída no instalador. Não existe dependência DDL entre030/040 e190; disponibilizar o workflow requer020+030+040 e tasks/catalogue existentes; disponibilizar o percurso integrado requer190 e as capacidades instaladas/configuradas. Os fingerprints incluem código comum de perfis: alterar o código invalida provas antigas de readiness, sem converter isso numa dependência de schema.
|
||||
|
||||
| Ordem futura | Ação concreta e efeito / contagem esperada | Dependência e reversão |
|
||||
| --- | --- | --- |
|
||||
| 0 | Incorporar visualC37/Promotor21/A-B; corrigir falhas e fixar revisão/manifesta/configuração. Renovar dumpDB+ficheirosoriginaisprivados+chaveestável e restauroverificado numa novaQA. | Candidata ainda não fixada; provas antigas não são backup dessa revisão. Nunca key:generate indiscriminado. |
|
||||
| 1 | Disponibilizar código/Composer/bundle após aprovação de ativação, flagsfalse. | Nesta análise só buildQA foi atualizado; manifestativo preservado. Reversão para código/build anteriores e flagsfalse; assegurar compatibilidade de leitura. |
|
||||
| 2 | **030000**, se ledger/schema realmente ausentes: préviaGET e aplicaçãoPOST autenticada `/management/opportunity-resources-install` com hash/confirm. | Três coleções resource e metadado de cifra; **zero documentos/dados publicados/importados**. Base020/documentos existentes. Downgrade destructive recusado se originais cifrados usados. |
|
||||
| 3 | **040000**, se ausente: instalar quatro tabelas/colunas através de mecanismo autenticado e bootstrap/recuperação da dupla, ainda por implementar paraativo. | Não substituir por migrateglobal/configmanual para contornar guarda. **Zero contas/grants QA** noativo. Titulares/reconciliação de permissões reais precisam de decisão explícita. Código independente de190. |
|
||||
| 4 | **09/190000**, se ausente: préviaGET e aplicaçãoPOST autenticada `/management/balcao-install`; instalador seleciona só migrações faltantes. | BaseBalcão07/190000 e orientação08/120000 jáconstamdoativo: não repetir. **Duas colunas + uma definição publicada**, se não existir equivalente; **zero dossiers/journeys criados automaticamente**. Versões publicadas imutáveis. |
|
||||
| 5 | Configurar catálogos/responsabilidades reais e instalar apenas contratos modulares aprovados; comparar IDs/citações/versões. | A autoridade sintética QA3e1ff315… e atores21–31 não são dados deativação. Sem alteração automática de valores legados. |
|
||||
| 6 | Importação cartográfica autenticada privada `/management/territory-geometry-import`: prévia/correspondências/hash→aplicação idempotente→auditoria. | QA393=11províncias+128distritos+254postos; ativo0naúltimaleitura. **393 candidatas inequívocas**, sujeitas à prévia da revisão e aos mesmos IDs;188pendentes(31/157), sem criar geometrias falsas. Contagem final deve ser medida no alvo. Reversão por lote/metadados e histórico, sem apagar cadastros. |
|
||||
| 7 | Ativar flags de perfis/oportunidades e percurso separadamente, testar navegação completa e permissões. | 030/040 antesdoscontratos/workflow;190antesdodisponibilizarpercurso; C37/visual/A-B não encerrados. Preparação paralela e acesso direto continuam. |
|
||||
| 8 | **Publicar camadas, oportunidades ou documentos como ações separadas e explícitas**. | Nenhuma instalação/importação publica automaticamente; publicação não ativa cálculo fiscal. Usar decisões/evidências/hash atuais e autorização de divulgação. |
|
||||
|
||||
Reversão: desativarflags/voltarbundle compatível e conservar originais/auditoria. Migrações usadas não devem ser eliminadas por rollback genérico;190recusadownquandohápercurso,030recusacomcifrados,040preservaperfil/oportunidade. Quando necessária reversão de dados, usar o restauroverificado da candidata com reconciliação das escritas posteriores. **ready=true é preparação; não instalação, ativação nem aceitação funcional.**
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Sequência reconciliada de disponibilização futura — não executada
|
||||
|
||||
Leitura ativa de 10/10: PostgreSQL `investemoz:5432`, transação READ ONLY, seis oportunidades publicadas; 020000 instalada, 030000/040000/percurso ausentes, flags desativadas, zero geometrias. Build ativo preservado. A revisão candidata **não está fixada**; `ready=true` e fingerprints anteriores não autorizam nem demonstram instalação.
|
||||
|
||||
| Ordem | Ação e efeito previsto | Dependência / reversão |
|
||||
| --- | --- | --- |
|
||||
| 0 | Incorporar visual C37/C43/A/B e workflow, corrigir falhas; fixar revisão, configuração e manifestos. Renovar backup DB + ficheiros privados/originais + chave estável e provar restauro numa nova QA isolada. | Provas anteriores preservadas, sem as apresentar como restauro desta revisão. Não gerar nova chave. |
|
||||
| 1 | Disponibilizar código e dependências compatíveis, flags desligadas. Composer adiciona `opis/json-schema 2.4.1`, `opis/string 2.1.0`, `opis/uri 1.1.0`; zero atualizações/remoções preexistentes. | Não importar contas, classificações, contactos ou documentos sintéticos QA. Reverter código/assets compatíveis, preservando payloads/histórico. |
|
||||
| 2 | Não repetir 020000. Se 030000 continuar ausente, usar prévia/aplicação autenticada `/management/opportunity-resources-install`: três tabelas, uma coluna documental, uma função e três triggers previstos. | Backup/restauro confirmado; hashes/contagens verificados no estado real; não publica nem habilita o percurso. |
|
||||
| 3 | Preparar e testar o mecanismo autenticado 040000: quatro tabelas, uma coluna de utilizador, três de oportunidade, uma função e dois triggers previstos; dupla inicial/substituição independente. | Aplicação atual demonstrada exclusivamente QA. Instalação/bootstrap ativo autenticado ainda pendente; titulares reais não definidos. Não remover privilégios ativos automaticamente. |
|
||||
| 4 | Configurar referências/contactos/hierarquia, perfis/tipos/representação, responsabilidades e MFA; quatro contratos modulares apenas após aprovação da versão. | Reutilizar dados mestres reais. As referências sintéticas de ensaio não resolvem cadastros ativos vazios. SLA permanece indeterminado até política aprovada. |
|
||||
| 5 | Importação cartográfica autenticada, privada e idempotente; se ativo permanecer zero e correspondências iguais, até **393 = 11 províncias + 128 distritos + 254 postos**. Esperados 581 = 11/159/411; pendentes 188 = 0/31/157, nomes no relatório de correspondências. | Prévia → correspondências inequívocas → scan/hash → aplicar → auditoria; nenhuma localização/conta/perímetro de projeto inventado. Reversão pelo lote preserva origem/uso/histórico. **Publicação de camadas é ação separada.** |
|
||||
| 6 | Disponibilizar flags/assets/navegação aprovados e o percurso I1–I3 pela instalação existente, sem repetir ações já presentes. Publicar cada oportunidade separadamente, com aprovação/hash/prévia/âmbito. | Cinco fases/19 etapas preservadas, dossier privado separado. Voltar flags a false limita disponibilização; manter guardas de confidencialidade para novos dados. Não executar down destrutivo após uso. |
|
||||
|
||||
Após uso, reverter por configuração compatível, suspensão/arquivo autorizado ou compensação auditada; não eliminar decisões, documentos, consentimentos, relações ou originais. Restauro global exige reconciliar alterações posteriores ao backup. Este plano não contém aprovação de ativação nem declaração de instalação ativa.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
<!-- qa-profiles-v1-20261010:begin -->
|
||||
## Sequência consolidada: instalações, disponibilização e reversão — 10/10/2026
|
||||
|
||||
Preparação exclusivamente em QA. Nenhuma ativação autorizada e nenhuma revisão candidata fixada. As provas `ready` anteriores são históricas: os fingerprints incluem agora as regras de perfis, portanto não comprovam esta revisão nem instalação funcional.
|
||||
|
||||
1. Incorporar os ensaios visuais C37/C43/A/B, perfis e consumidores; corrigir falhas; fixar revisão, manifestos e configuração. Reconciliar permissões legadas e titulares reais. Completar os contratos antes de disponibilizar submissão/aprovação/publicação.
|
||||
2. Renovar backup da base, originais, ficheiros e configuração/chaves após fixar a revisão. Restaurar numa nova QA guardada e comparar IDs, relações, hashes, schema, compatibilidade e reversão. Usar as contagens reais do momento. A cópia de 09/10 não é apresentada como backup renovado desta candidata.
|
||||
3. **Gestão 020000:** já instalada no ativo; não repetir. Atalhos C43 e primeiro rascunho imobiliário não exigem essa instalação. Disponibilização dos atalhos por `PORTAL_OPPORTUNITY_DASHBOARD_ACCESS_ENABLED`, separada do schema; preservar gestores verificados e funções legadas reconciliadas.
|
||||
4. **Recursos 030000:** se ausente, prévia e aplicação autenticadas por GET/POST `/management/opportunity-resources-install`, com provas atuais. Esperado: três tabelas de associação, uma coluna em documents, uma função e três triggers; zero originais/recursos criados automaticamente. ClamD/GD/WebP são dependências das operações binárias afetadas. Instalação não confirma scans, divulgação ou publicação.
|
||||
5. **Perfis 040000:** falta concluir o mecanismo autenticado de instalação e o bootstrap ativo em QA. A migration guardada está preparada, sem autorização para execução ativa indiscriminada. Esperado: quatro tabelas de autorização ligadas às entidades existentes; uma coluna em users e três em opportunities; uma função e dois triggers. **Zero contas, atribuições ou titulares reais criados automaticamente.** Depende da gestão 020000; recursos 030000 são necessários para os respetivos consumidores. A cerimónia designa dois titulares reais com MFA e terceiro aprovador independente, sem converter a allowlist antiga. `PORTAL_PROFILE_ACCESS_ENABLED` é disponibilização separada. QA já instalada: não repetir migration ou bootstrap; dupla sintética atual 27/30.
|
||||
6. **Balcão e percurso:** instalar apenas dependências ausentes pelo mecanismo autenticado existente; 190000 prepara o percurso e múltiplos investimentos. Preservar cinco fases e 19 etapas. Instalação não disponibiliza nem conclui I1–I3; flags, assets, rotas e cenários A/B visuais têm provas próprias. Não depende de CRM externo.
|
||||
7. **Importação cartográfica:** prévia, correspondências, hash e aplicação autenticada privada/idempotente/auditável, usando territórios existentes. QA: 393 de 581 geometrias esperadas — províncias 11/11, distritos 128/159, postos 254/411; pendentes 0/31/157, com os 188 nomes em correspondencias-cartograficas.md. A prévia ativa calcula a interseção inequívoca do alvo, sem copiar contagens QA cegamente. Publicação das camadas é outra ação. Importação não ativa cálculos fiscais; fonte oficial aceite não requer nova validação de credibilidade.
|
||||
8. Somente após futura autorização ativa, verificar código/compilação/cache/cookies/guards, contagens antes/depois, auditoria, originais, versão pública e recuperação dos percursos no alvo aprovado.
|
||||
|
||||
**Reversão:** desativar flags específicas e interromper novas operações, mantendo código de autorização compatível com os privados. Não regressar a código que os exponha. 040000 recusa `down` se há dados/pedidos/dupla/histórico; depois de uso, preservar tabelas ou reconciliar restauro numa janela autorizada. Revogações e substituições permanecem rastreáveis. Não restaurar um backup antigo apagando dados posteriores. 030000 só reverte antes de uso; percurso e importação usam as reversões existentes, preservando relações/originais. Reversão da publicação de camadas é independente da importação.
|
||||
|
||||
Fingerprints preparados, sem fixação de candidata:
|
||||
|
||||
- profiles: `5ad5c2bb4989f7d5356115b4c2adaa20ddf1def87694a3291205114e1e677ae3`
|
||||
- management: `0aa282e5cc8d9cde72e326b1cebd6814966097026c2cadbeca23ced8fdb2518a`
|
||||
- resources: `3fc520b0fbb7f0cc290906657b40fe288765b6de5c0015f72de9a3a94b9c413f`
|
||||
- desk_path: `e0012bf8592ca42548174bd809277f425e4b0329d3a39ce069e55c9369208b50`
|
||||
- geometry: `55ab4fcd52d8fea939fe877f0f765e53734ed6bc2e70222cabcfc3dc8bb1dc65`
|
||||
|
||||
Manifesto QA `22947d5c47487137b9e37138f29322d99181c7d0cb0bc655dba67fd2e444ae80`; manifesto ativo `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` preservado. Provas específicas precisam corresponder à revisão final e ao alvo real.
|
||||
|
||||
<!-- qa-profiles-v1-20261010:end -->
|
||||
|
||||
> Registos anteriores preservados como histórico. Os estados correntes são os da matriz e do bloco mais recente acima; uma entrega posterior não altera retroativamente a evidência anterior.
|
||||
|
||||
<!-- qa-dashboard-20261010:begin -->
|
||||
## Sequência adicional C43/C44 —10/10/2026
|
||||
|
||||
Preparação apenas; nenhuma ativação autorizada/executada. Fingerprints correntes: percurso `580e6619937e7acd382415fa446524dd0b5de5162aea3ff51115cdbbf822f67f`, recursos `bfb2eab205de521e746e86097684bc5f93883d7553f23a7bf96ec7c481869cf1`, cartografia `8e042c65d94b810aa03e9b2a9fe5f1bc10269bc1d1631d006fa1058dc2fc46e8`. Provasready anteriores ficam históricas; nenhuma candidata final.
|
||||
|
||||
1. Concluir C43/visuais C37/A-B e consumidores; registar reprodução/correção, fixar revisão+manifestos. Renovar backupDB/ficheiros/chaves/originais e restauro numa novaQA guardada, conferindoIDs/relações/hashes/schema. Não forçar contagens históricas 288 tabelas/5 ficheiros.
|
||||
2. Disponibilizar código eassets ativos dessa revisão aprovada, configuração reconciliada e cache conferida. Atalhos/arquivo usam `PORTAL_OPPORTUNITY_DASHBOARD_ACCESS_ENABLED`; estáfalse no ativo e só true naQA. **Nenhuma migration nova para C43 ou primeiro draft imobiliário.**020000 já instalada: não repetir. Antes de habilitar, conferir gestor(email verificado+allowlist), rotas e manifesto realmente servido. Rollback: flag false e código/assets compatíveis; preservar drafts/estadoarchived/auditoria.
|
||||
3. Recursos: gestor autenticado GET/POST `/management/opportunity-resources-install`somente se 030000 ausente.3 tabelas de associação+1 coluna documents+função/3 triggers; 0 recursos/originais criados automaticamente. ClamD/GD exigidos pelas operações binárias afetadas, sem bloquear rascunho/metadados. Down somente vazio; após uso retirar disponibilidade sem eliminar originais/dados.
|
||||
4. Percurso: GET/POST `/management/balcao-install`somente para migrationspendentes 190000; 2 colunas+1 definição imutável, 0 conversões/dossiers/etapas. Disponibilização `INVESTOR_PATH_ENABLED`separada, depois deaceitação/instalação; reversão porflagpreserva 19 etapas/5 fases, IDs/resultados/documentos/consentimentos.
|
||||
5. Cartografia: gestor/MFA: prévia/crosswalk/hash→aplicar níveis 1→2→3 sem duplicar. Seativo continuar 0 ecorrespondênciasiguais, previsão 393 limitesinternos=11 províncias+128 distritos+254 postos; 0 territórios novos, 31/157 pendentes. Publicação dascamadasseparada. Reversão 3→2→1 somente novas intactas/não usadas, nem publicadas; guardar originais/hash/crosswalk/receipts e relações.
|
||||
6. Contratos especializados: `PORTAL_OPPORTUNITY_SPECIALIZED_DRAFTS_ENABLED`ficafalse no ativo. **Não habilitar com este loteAPI parcial.**Completar formulário/bindings/condições finais/DUAT/transação/workflow e provas por versão/perfil; depois planopróprio na mesma auditoria. Desligarflag conservaJSON privado; rollback de código deve manter chave/payloads sem descartá-los. Instalar recursos ou importarcartografia não valida/submete/aprova/publica contrato nem ativa cálculo fiscal.
|
||||
|
||||
Plano detalhado anterior conservado abaixo; aplicação documental ativa autenticada, scan próprio dasderivadas e operação permanenteClamD continuam dependências específicas.
|
||||
|
||||
**Histórico preservado:** os blocos anteriores a C43/C44 abaixo conservam as suas fontes e provas. Os seus hashes/ready/contagens não certificam a revisão atual; a primeira adaptação imobiliária não equivale ao schema integral nem ao workflow.
|
||||
<!-- qa-dashboard-20261010:end -->
|
||||
|
||||
<!-- qa-independent-20261010:begin -->
|
||||
## Sequência corrente reconciliada — 10/10/2026
|
||||
|
||||
Plano preparado para futura autorização; nenhuma ação ativa executada. Revisão não candidata: percurso `daace78d4e0999984dab2561d60778878eb901cd88adeaf539a1a674f88c6767`, cartografia `8e042c65d94b810aa03e9b2a9fe5f1bc10269bc1d1631d006fa1058dc2fc46e8`, recursos `3b576d542bb742afb753958d53647421e9a26d77ee680273c9e85d8a1fad285e`. `ready=true` antigo representa outra preparação e não instalação/conclusão.
|
||||
|
||||
| Ordem | Ação futura | Dependência/efeito previsto | Reversão |
|
||||
| --- | --- | --- | --- |
|
||||
|0|Concluir critérios visuaisA/B, C37 e consumidores; corrigir falhas e fixar commit/assets/fingerprints.|Provas de API/persistência não substituem interface. Não há candidata final agora.|Corrigir/revalidar só critérios afetados; preservar provas anteriores.|
|
||||
|1|Renovar backup de DB+ficheiros privados/chaves/originais/metadados e restaurar em nova QA loopback*_test:55441.|Guarda antes da escrita; comparar schema/IDs/relações/hashes/classificações/RLS/ficheiros. 288 tabelas/5 ficheiros são histórico09/10, não contagem a forçar. Provas de reversão/compatibilidade da candidata.|Nunca sobrescrever escritas posteriores sem reconciliação. Restauro ativo permanece fora desta autorização.|
|
||||
|2|Disponibilizar código/assets/configuração mantendo INVESTOR_PATH_ENABLED=false e TERRITORY_GEOMETRY_IMPORT_ENABLED=false.|Correções países/documentos/seletores não criam nem reclassificam dados por deploy. ClamD/GD somente nas operações binárias afetadas.|Rollback de código compatível preservando payloads novos/originais. CLI documents:import-batch --apply continua recusado no ativo.|
|
||||
|3|Gestor ativo autorizado: GET/POST /management/opportunity-resources-install **somente se030000ausente**.|Prévia/backup/hash/autorização atuais;3 tabelas de associação+1coluna documents+função/3triggers;0 documentos/recursos/geometrias criados automaticamente. Não repetir instalações básicas.|Down somente vazio; após uso preservar dados/originais e desligar disponibilização em vez de apagar.|
|
||||
|4|GET/POST /management/balcao-install somente para migrations pendentes, prevista190000.|2 colunas+1 definição imutável;0 conversões/dossiers/etapas automáticas. Prova A/Bvisual atual exigida. 19 etapas/5fases e roteiros legados preservados.|Down recusa após uso; desabilitar flag mantendo definição/dossiers/história.|
|
||||
|5|Habilitar INVESTOR_PATH_ENABLED em ação separada, após schema e aceitação.|Mostra percurso;19 etapas somente ao criar dossier novo; ferramentas diretas/URLs/IDs permanecem.|Flagfalse, sem apagar dossier/resultados/documentos/tarefas/consentimentos.|
|
||||
|6|Preparar prova específica cartográfica vinculada ao fingerprint/alvo/backuprestaurado/UI; habilitar rota separadamente; gestor/MFA GET /management/territory-geometry-import?level=1 → POST, depois2, depois3.|Cada prévia deve conservar exemplar/hash/scan/crosswalk/pai/conflitos. **Se ativo continuar0 e mesmas correspondências únicas:393=11+128+254 novas internas**,0 geo_locations/locais/perímetros de investimento. 31/157 pendentes de fora. QA 393inclui32novas e361preservadas; replay 0 novas. Scan vencido exige renovar prova por bytes, sem reabrir aceitação oficial.|POST /management/territory-geometry-import/{batch}/reverse com MFA/razão/idempotência; **postos→distritos→províncias**. Recusa descendentes ativos da edição mesmo sem FK, uso/alteração/publicação; só exclusão lógica de novos intactos não usados. Preservar reutilizados/catalogue/hash/histórico.|
|
||||
|7|Divulgar camadas por consumidor/âmbito autorizado em ação separada.|Importador não publica. Limites administrativos não são DUAT/perímetro do investimento; não transformar internal em public para passar ensaio. Mecanismo de divulgação de limites administrativos continua a concretizar conforme política existente.|Retirar camada/autorização sem eliminar geometrias/originais/relações.|
|
||||
|
||||
Aplicação documental em lote no ativo exige mecanismo autenticado próprio, ainda pendente; o comando existente serve prévia e ensaio isoladoQA. Não adicionar esse lote à sequência ativa como se nomear --actor autenticasse. Schema/workflow especializado preparado não integra estas migrations nem é ativado por elas; sua futura revisão deve ter provas e plano próprios no mesmo backlog.
|
||||
|
||||
ClamD real e tipos/derivadas já demonstrados mantêm suas provas; arranque após reboot, assinaturas e PHPativo/GD são critérios específicos em aberto. Importação/publicação não ativa nenhum cálculo fiscal. [Relatório](relatorio-executivo.md) / [backlog](backlog.md).
|
||||
|
||||
**Versões anteriores preservadas:** as secções abaixo documentam os instantes anteriores. 361 e2/39/179 são a baseline 09/10; o estado cartográfico corrente deste lote é393 e0/31/157. Ready/hashes/contagens antigas não certificam esta revisão. C37 mantém a sua confirmação visual pendente, sem bloquear as frentes independentes.
|
||||
<!-- qa-independent-20261010:end -->
|
||||
|
||||
## C37 — causa confirmada; seleção de conta QA corrigida — P0, visual pendente
|
||||
|
||||
O pedido de reabertura referia a conta esperada18. A captura manual21:22–21:24UTC demonstra que foi selecionada a conta13 restaurada. O MFA foi alcançado, mas a autenticação nunca se completou. Causa concreta: credenciais MFA da conta13 cifradas pela chave de origem, incompatíveis com a chave estávelQA; a conta18 sintética não foi desafiada nessa tentativa. Nenhuma chave, MFA ou conta13 foi alterada para contornar o problema.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Perfis modulares e SLA — propostas para decisão
|
||||
|
||||
10/10/2026. Complemento da matriz existente, sobretudo AUD-OP-04, PROFILE-22/28/31. **Nenhuma proposta abaixo está aprovada, instalada como perfil ativo ou aplicada aos prazos.** Não designa pessoas reais. A criação e recuperação dos quatro rascunhos já existe; submissão e publicação continuam bloqueadas até aprovação do perfil e implementação demonstrada do adaptador correspondente.
|
||||
|
||||
## Fundamento e compatibilidade
|
||||
|
||||
A Especificação do Formulário de Cadastro de Oportunidades, exemplar SHA256 `9f173ed3024d2beddef7888b692d8b81fc5e4700249955f2bae9bdd2eb2be504`, §25.4, parágrafos extraídos 2101–2105, exige perfil aprovado e imutável por versão submetida, regras declarativas, documentos mínimos e gates aprovados pelo administrador funcional e governance owner. **Não define os campos destas quatro categorias.** Os campos propostos são uma decisão de produto, não uma transcrição de obrigações legais. Os parágrafos 123 e 1801 admitem gestão de SLA/escalamento sem aprovação automática, mas não fixam prazos. O documento de perfis v1 fornece Promotor, Validador, Publicador, Auditor e dupla de superutilizadores; coordenação, parecer e recurso correspondem a responsabilidades desses papéis, já modeladas nas atribuições.
|
||||
|
||||
O schema canónico original de 110 definições conserva o seu hash e significado. Reutilizar `module_profile_version`, campos comuns financeiros, países, mercados, riscos, relações, localizações, documentos, evidências e imagens; preservar `owner_user_id`, organização, IDs e revisões atuais. Para atributos sem campo canónico, propor uma extensão versionada do contrato/adaptador, com correspondência explícita às coleções existentes; **não inserir chaves não permitidas no schema atual nem criar tabelas paralelas**. O repositório já usa PostgreSQL/PostGIS e Fortify; estas propostas não dependem de MySQL, Keycloak ou CRM externo.
|
||||
|
||||
Rascunho aceita campos incompletos; valida formato dos dados efetivamente preenchidos, preserva valores e aponta erros junto ao campo. Submissão congela perfil, hash dos dados/recursos e documentos aplicáveis. Aprovação e publicação revalidam autorizações, gates e materialidade. Uma mudança do perfil cria outra versão; não muda retroativamente um processo submetido. Seleção territorial não prova titularidade nem geometria de parcela; valor ausente não vira zero.
|
||||
|
||||
## Quatro propostas de contrato
|
||||
|
||||
| Perfil proposto, ainda inativo | Regra concreta de submissão | Documentos e gates propostos | Fundamento e efeito |
|
||||
| --- | --- | --- | --- |
|
||||
| `concession-qa-proposal-v1` | Identificar objeto, entidade concedente, representante, regime/procedimento declarado, âmbito territorial ou digital, duração positiva com unidade, modelo de remuneração/obrigações e condições de participação. Se houver terra, usar módulo territorial e indicação das autorizações externas; se houver construção, declarar licenças aplicáveis. Datas de início/fim coerentes; não apresentar concessão ainda pretendida como direito atribuído. | Documento do objeto/procedimento e prova de representação; quando existentes, ato/contrato e título territorial. Gates `technical_review`, `legal_territorial_review`; decisão externa exigida somente quando a matriz de aplicabilidade do perfil a identificar. | §25.4 + campos comuns de promotor, localização, licenças, evidências e milestones. Se a decisão externa não existe, guardar preparação e marcar pendente; nunca fabricar autoridade, prazo ou direito. Publicar chamada/preparação só se o perfil aprovado distinguir claramente esse objeto de uma concessão atribuída. |
|
||||
| `financing-qa-proposal-v1` | Identificar projeto/beneficiário e representação; instrumento pretendido, moeda, montante positivo, investimento total, financiamento assegurado, necessidade remanescente, finalidade, calendário de desembolso e cenário financeiro. Soma das rubricas = total; assegurado ≤ total; pedido ≤ necessidade. Se dívida: prazo positivo, periodicidade e condições indicativas; se capital: percentagem e direitos propostos; garantias só quando oferecidas/exigidas. | Estudo/plano financeiro e evidência do financiamento declarado assegurado; documentos da entidade e representação. Gates `technical_review`, `financial_review`, `legal_tax_due_diligence`. | Reutiliza investimento/financiamento, cenários, entidades e documentos existentes. Não calcula aprovação, elegibilidade regulatória ou score de sucesso; financiamento pretendido permanece distinto do assegurado. Condições e percentagens não são inventadas para campos vazios. |
|
||||
| `supply-qa-proposal-v1` | Identificar comprador/requisitante, objeto e especificação, quantidade positiva, unidade, destino, calendário e critérios de aceitação. Preço total ou método de cotação explicitamente declarado; se valores preenchidos, moeda única e quantidade × preço reconciliada, distinguindo impostos declarados. Capacidade, licenças ou certificações só são obrigatórias quando o próprio perfil/procedimento as exigir. | Especificação/termos de referência e representação; licenças/certificados aplicáveis e prova de capacidade declarada. Gates `technical_review`, `legal_territorial_review`; financeiro quando pagamento antecipado/garantia for condição declarada. | Reutiliza produtos/inputs, mercados, país, milestones, contratos/licenças e evidências. Pedido de fornecimento não equivale a contrato celebrado. Não presume regime de contratação pública ou taxas fiscais pela categoria. |
|
||||
| `other-qa-proposal-v1` | Exigir descrição do subtipo, finalidade, destinatários, resultado esperado, dados/documentos aplicáveis e indicação dos riscos. Promotor não escolhe livremente requisitos/gates para dispensar controlos: coordenação funcional aprova um subtipo e versão antes da submissão. Bloquear uso de “outros” para contornar concessão, terra, financiamento ou negócios existentes. | Documento que define objeto e representação; mínimos e gates declarados no subtipo aprovado. Base `technical_review` + `legal_territorial_review`, dispensas só fundamentadas no perfil aprovado. | §25.4 exige perfil ativo, não uma categoria sem regras. O rascunho livre é recuperável; a publicação aguarda classificação funcional explícita, sem criar um cadastro duplicado. |
|
||||
|
||||
Regras comuns propostas: referências documentais pertencem à mesma oportunidade, são recuperáveis e respeitam perfil/organização; ClamD e integridade por hash são necessários para disponibilizar binários. “Documento existente mas decisão externa pendente” difere de “documento obrigatório ausente”. Uma fonte oficial identificada continua aceite, sem segunda validação de autenticidade. A aplicação do seu âmbito e período continua rastreável.
|
||||
|
||||
## Correspondência de responsabilidades com papéis existentes
|
||||
|
||||
| Responsabilidade | Correspondência existente / proposta | Limite |
|
||||
| --- | --- | --- |
|
||||
| Coordenação da validação | `validator` + `validation_coordinator`, já permitido nas atribuições | Atribui revisão; não aprova o próprio processo nem substitui pareceres obrigatórios. |
|
||||
| Revisão técnica e jurídica | `validator` + `technical_reviewer` / `legal_reviewer`, já permitidos | Cada tarefa requer atribuição, competência e independência; acumular papéis não dispensa impedimentos. |
|
||||
| Coordenação editorial | `publisher` + `publication_coordinator`, já permitido | Atribui publicação a Publicador distinto e verifica âmbito; não transforma aprovação interna em decisão externa. |
|
||||
| Instância de recurso interno | `validator` + `appeal_reviewer`, já permitido | Exige revisor independente das intervenções anteriores; não é instância de recurso legal externo. |
|
||||
| Administrador funcional e governance owner | **Proposta D-M1:** os dois titulares distintos da dupla existente aprovam conjuntamente conteúdo e versão, com MFA e registo dos dois atos | Esta cerimónia de aprovação de perfil ainda não existe. Não criar outro papel ou escolher titulares reais; decisão funcional e disponibilização pública permanecem separadas. |
|
||||
| Suporte operacional | Não existe responsabilidade `support` no contrato de atribuições atual. **Proposta:** coordenação da validação assume triagem; apoio do Balcão usa as permissões operacionais já existentes | Suporte não ganha revisão, aprovação, publicação ou acesso universal a documentos. Uma extensão de responsabilidades precisa de decisão explícita e teste. |
|
||||
| Auditoria | `auditor`, já existente | Consulta/audita; não instala, aprova ou publica pela acumulação de papéis. |
|
||||
|
||||
## Decisões a apreciar, sem bloquear trabalho independente
|
||||
|
||||
| Decisão | Regra proposta | Efeito se aprovada / alternativa |
|
||||
| --- | --- | --- |
|
||||
| D-M1 — Aprovar conteúdo das quatro versões e a correspondência de responsabilidades? | Aprovar a tabela como primeira versão; propor os dois titulares distintos da dupla existente como aprovadores conjuntos, nas funções de administração funcional e governance owner, com MFA, hash e data de cada ato. | Permite implementar a cerimónia de aprovação e ativação versionada, editores e gates. Até lá, os quatro rascunhos continuam recuperáveis e bloqueados para submissão. Não designa pessoas reais nem publica automaticamente. |
|
||||
| D-M2 — Concessão antes de decisão externa? | Admitir uma ficha pública de chamada/intenção quando autorizada, rotulada como preparação; uma ficha de direito atribuído exige documento da decisão competente. | Evita confundir promoção com concessão legal. Se só se admitir direito já atribuído, gate externo permanece bloqueante para publicação. |
|
||||
| D-M3 — Financiamento e fornecimento com preço/condições indicativos? | Admitir indicadores identificados como propostos, com fonte/cenário/data; não marcar contrato, financiamento assegurado ou compromisso. | Mantém preparação paralela; requer revisão editorial clara. Se se exigirem termos vinculativos, a publicação aguarda esses documentos. |
|
||||
| D-M4 — “Outros” genérico? | Exigir subtipo aprovado; nenhum Promotor pode dispensar gates. | Reduz contorno de categorias. Coordenação classifica a preparação sem interromper ferramentas e tarefas independentes. |
|
||||
| D-S1 — Entidade sem exercícios completos? | Nova versão explícita admite entidade recém-criada com declaração de inexistência de histórico e cenário/projeções separados; nunca demonstrações fictícias. | Reconciliar mínimo de um exercício do schema atual com regra dos períodos existentes; requer novo contrato/adaptador e teste, não alteração silenciosa do original. |
|
||||
| D-S2 — Unidades/urbanismo/materialidade? | Unidades/rooms/floors obrigatórios apenas nos tipos do perfil aprovado; comparar limites urbanísticos apenas com limite declarado e documento/âmbito aplicável. Mudança de título, preço, objeto, direitos, localização, perfil ou evidência relevante exige nova revisão. | Não presume limite oficial ou unidade ausente. A configuração exata por tipo e as tolerâncias de materialidade permanecem uma decisão expressa. |
|
||||
| D-R1 — Perda simultânea dos dois titulares? | Preparar cerimónia de recuperação por duas pessoas independentes formalmente designadas, com identidade/MFA recuperados por procedimento existente, motivo, alerta e substituição atómica auditada. Sem designação, não existe bypass de gestor, bootstrap repetido ou terceiro superutilizador. | A substituição normal da dupla já existe. A recuperação simultânea exige política e operadores designados antes de ser habilitada; backup do schema não resolve perda de MFA. |
|
||||
|
||||
## SLA operacional proposto — não é prazo legal
|
||||
|
||||
Versão proposta `opportunity-sla-proposal-20261010-v1`. **Nenhuma data `due_at` foi preenchida por esta proposta.** Dias úteis são uma configuração a aprovar, com fuso `Africa/Maputo`, calendário e feriados explicitamente registados; não inventar calendário oficial. Relógios por tarefa, não um prazo único que bloqueia todo o percurso.
|
||||
|
||||
| Tarefa | Prazo proposto | Responsável funcional | Início, pausa e escalamento propostos |
|
||||
| --- | ---: | --- | --- |
|
||||
| Triagem e atribuição | 2 dias úteis | Validador com responsabilidade de coordenação | Começa na submissão válida. Escalar à coordenação suplente formalmente designada ao vencer; não escolher automaticamente um Validador. |
|
||||
| Revisão técnica | 10 dias úteis | Validador técnico atribuído e independente | Começa na atribuição; pode correr em paralelo com jurídico. Pedido fundamentado de informação pausa somente a tarefa afetada. |
|
||||
| Revisão jurídico/territorial | 10 dias úteis | Validador com responsabilidade jurídica/territorial | Mesma regra; decisão externa tem estado e relógio separados, sem prazo externo inventado. |
|
||||
| Parecer interno específico | 5 dias úteis | Destinatário explicitamente designado por competência | Começa quando o pedido e evidências estão acessíveis. Parecer não aprova automaticamente o gate. Escalar à coordenação da revisão ao vencer. |
|
||||
| Correção pelo Promotor | 15 dias úteis | Promotor autor ou coeditor autorizado da organização | Começa na devolução com lista concreta. Ao vencer, lembrar e permitir pedido de extensão fundamentado; sem rejeição ou arquivo automático. |
|
||||
| Revisão de correção | 5 dias úteis | Validador responsável pela revisão afetada | Começa na ressubmissão; gates não afetados podem ser reutilizados só com identidade/hash e regra material demonstrados. |
|
||||
| Conferência editorial e publicação | 3 dias úteis | Coordenação editorial atribui; Publicador distinto executa | Começa após aprovação interna e evidências de divulgação. Impedimento documental suspende esta tarefa; não altera aprovação para decisão externa. |
|
||||
| Admissibilidade e decisão de recurso interno | 2 + 10 dias úteis | Coordenação de recurso + Validador independente dos autores/revisores/decisores prévios | Relógios separados; motivo e evidências preservados. Sem recurso decidido pelo próprio autor/decisor, mesmo com múltiplos papéis. |
|
||||
| Incidente / suspensão de acesso | 1 dia útil para triagem | Coordenação e suporte com responsabilidade expressa; decisão por papel autorizado | Triagem não dá a suporte poder de aprovação. Risco concreto pode justificar suspensão auditada; alerta e próxima ação visíveis. |
|
||||
|
||||
Proposta de alerta: um lembrete ao atingir 80% do prazo e escalamento ao vencer; tolerância operacional de 1 dia útil apenas para alerta, sem alterar vencimento nem aprovação. **Sem limite automático de ciclos**: ao terceiro ciclo, revisão pela coordenação da qualidade das exigências, com continuação fundamentada. Ausência de resposta não significa aprovação, rejeição ou arquivamento. Responsabilidade suplente não equivale a pessoa fictícia.
|
||||
|
||||
**D-SLA:** aprovar/rever os prazos da tabela, relógios/pausas, lembrete, tolerância, tratamento do terceiro ciclo, calendário e responsabilidades/suplentes. Efeito: preencher `tasks.due_at` somente para novas tarefas ou reprogramação explicitamente auditada; manter tarefas históricas e prazos acordados. Não depende de envio externo de email nem de integração para mostrar fila, vencimento e próxima ação no portal.
|
||||
|
||||
## Publicação: significado a usar na interface e relatórios
|
||||
|
||||
1. **Aprovação interna:** revisão da versão pelo Validador; não dá visibilidade pública e não substitui decisão de entidade externa.
|
||||
2. **Disponibilização com acesso restrito** (antigo rótulo “publicação interna”): ação independente do Publicador, com versão congelada; apenas perfis e organização autorizados podem recuperar a ficha. Não significa acesso por todas as contas autenticadas.
|
||||
3. **Publicação no catálogo público:** âmbito `public` aprovado e ação independente do Publicador; projeção pública permitida, preservando documentos, contactos, avaliações e dossier privados. Suspensão/arquivo e classificação de exemplo continuam a impedir a exibição conforme as políticas existentes.
|
||||
|
||||
O estado técnico legado `published` é preservado; o texto da interface também apresenta o âmbito efetivo. O âmbito solicitado pelo Promotor difere da disponibilização efetiva. Uma alteração privada em preparação não é apresentada como nova versão pública concluída.
|
||||
|
||||
## Implementação incremental e aceitação
|
||||
|
||||
Após decisão D-M1–M4: registar versões imutáveis pelo serviço de versões existente, declarar bindings e gates no adaptador sem cadastros paralelos; editores por categoria; testes de campos parciais/422/novas sessões; Promotor → correção → aprovação independente → disponibilização por âmbito; organização alheia e múltiplos papéis negados. Cada nova versão mantém migração/adaptador compatível e reversão por desativação de disponibilização, preservando o snapshot submetido. D-S1/S2 e SLA são decisões independentes.
|
||||
|
||||
Instalação de recursos/perfis/percurso permanece **030000 → 040000 → 190000**. Instalar schema não aprova este documento, não atribui perfis, não inicia SLA e não publica geometrias. C37, dashboard Promotor QA21 e cenários A/B exigem interface; nenhuma contagem global de testes os encerra.
|
||||
@@ -1,5 +1,239 @@
|
||||
# InvesteMoz — relatório executivo do lote 3
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Estado atual — instalador, documentos e cartografia
|
||||
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T10:32:41+00:00: 030000, 040000 e 09/190000 ausentes; zero geometrias; manifesto preservado. **C37, Promotor QA21 e A/B permanecem visualmente pendentes. Candidata à ativação ainda não fixada.**
|
||||
|
||||
| Requisitos | Código preparado / prova QA | Critério ainda aberto |
|
||||
| --- | --- | --- |
|
||||
| PROFILE-31 / OPS-01 | GET/POST `/management/profile-install`, bootstrap separado; prévia, dependências, hash, MFA, repetição e auditoria. Instalação vazia revertida/reinstalada; falha de auditoria reverte DDL e ledger, permitindo repetir. Uso/histórico impedem remoção. No servidor já utilizado, `changed=false`; nenhum titular ou privilégio alterado. Promotor, Auditor e organização alheia recebem 403. | Instalação/bootstrap visual e titulares reais designados; recuperação simultânea da dupla depende de D-R1. Backup do schema não recupera MFA perdido. |
|
||||
| GEO-02/03 | **15 novas**: cinco distritos e dez postos; **393 reutilizadas**. Total **408**: 11 províncias, 133 distritos e 264 postos. API do mapa recupera os 15 novos limites. 1.990 cadastros e 361 geometrias originais preservados; sem publicação. | **173 lacunas nomeadas**: 26 distritos e 147 postos; ambiguidades e edições distintas. Seletores/mapas por consumidor continuam com aceitação visual pendente. Localidades/parcelas não inferidas. |
|
||||
| OP-02 / documental | DOCX/XLSX: pacote válido, scan ClamD real, acesso de QA21/24/25 e QA23 negado; exclusão lógica, restauro e original idêntico noutra sessão MFA. | Renderização de Office, exportações/PDFs derivados e restantes consumidores continuam pendentes por operação. |
|
||||
| LAND-01 / PART-01 / SCAN-01 | PDF, PNG e JPEG em fundiário e sala de parceria: seis scans reais com hashes iguais. Autor recupera originais; partilha explícita permite nova sessão; revogação retira acesso/listagem, sem apagar original. Gestor não ganha acesso por esse papel. **91 pedidos** no recibo próprio. | Modelo de impressão recuperado, mas PDF gerado no navegador e interface não demonstrados. Estes seis scans não encerram outros tipos, derivadas ou permissões. |
|
||||
| OP-03 / OP-04 | Código da interface e resposta distinguem aprovação interna, disponibilização restrita e catálogo público. Casos #30 restrito, #33 público e #32 aprovado sem publicação recuperados pela API; consulta da política pública efetiva. | Interface; Response integral/§27 e condições sem regra inequívoca continuam parciais. Testes dos seis contratos não encerram a demonstração visual. |
|
||||
| PROFILE-22/28 | [Quatro perfis e SLA com regra, fundamento, efeito e decisões concretas](propostas-perfis-modulares-sla.md). | Propostas **inativas**; quatro categorias continuam com rascunhos recuperáveis, mas submissão/publicação bloqueadas. Nenhum prazo ou responsável real configurado. |
|
||||
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_104956_test:55441` → **295 tabelas com linhas/hashes iguais e 69 ficheiros fisicamente recuperados por hash**; todos os ficheiros referenciados recuperados por hash. Arquivo privado inclui o runtime e a chave QA estável. Readiness só atesta preparação técnica desta revisão. Repetir após fixar a candidata e preparar backup ativo apenas numa futura ativação autorizada.
|
||||
|
||||
**Matriz única:** 3.386 requisitos/linhas e 6.838 ocorrências de origem; todas as fontes, critérios e versões preservados; sem novos fechos globais. Contagens por estado abaixo e na projeção dos pedidos abertos.
|
||||
|
||||
**Próximo lote executável:** completar Response/§27 reutilizando entidades; verificar exportações e PDFs derivados por consumidor; prosseguir correspondências territoriais por nome, pai e edição; completar países/origem, filtros e estatísticas por formulário. Incorporar provas visuais quando retomadas. Decisões D-M1–M4, D-S1/S2, D-SLA e D-R1 estão individualizadas e não bloqueiam o trabalho independente.
|
||||
|
||||
**Histórico preservado:** os blocos seguintes descrevem revisões anteriores. 393 geometrias/188 lacunas eram o estado antes deste lote; “instalador 040000 em falta” deixou de descrever o código QA. Esses estados anteriores não provam esta revisão nem autorizam instalação ativa.
|
||||
## Contagens atuais por requisito
|
||||
|
||||
| Estado | Requisitos / linhas da matriz | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
|parcial|3229|6379|
|
||||
|não implementado|145|446|
|
||||
|dependente de decisão concreta|5|5|
|
||||
|implementado mas não ativado|1|1|
|
||||
|concluído e demonstrado|6|7|
|
||||
|
||||
3.386 requisitos verificáveis e 3.386 linhas consolidadas; 6.838 ocorrências de origem. Sem novo fecho integral; provas QA descritas por critério.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Resultado atual por categoria — semântica e atribuições em QA, 10/10/2026
|
||||
|
||||
Este bloco conserva a revisão semântica anterior; o estado atual está no início do documento; as secções anteriores abaixo permanecem como histórico. Estados antigos sobre objetos opacos, coordenação editorial e ausência de 190000 ficam confrontados com os resultados concretos deste bloco. **Código preparado e HTTP/persistência em QA não encerram interface nem funcionalidade ativa.** Não houve escrita na base ativa. C37 e aceitação visual permanecem pendentes.
|
||||
|
||||
Servidor **http://127.0.0.1:18097**, base **desk_restore_20261009_174726_test:55441**, Fortify e MFA normais. **Promotor QA 21** continua a conta de ensaio do dashboard. Não usar Auditor ou Superutilizador em seu lugar.
|
||||
|
||||
| Categoria / ID QA | Rascunho | Edição / recuperação | Submissão | Revisão | Publicação |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Investimento — #30 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Imobiliário — #31 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Fundiário/DUAT — #32 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Bloqueada: autorização externa pendente |
|
||||
| Venda de negócio — #33 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Pública, só teaser; prova editorial posterior |
|
||||
| Parceria de negócio — #34 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Franquia — #35 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Concessão — #36 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Financiamento — #37 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Fornecimento — #38 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Outros — #39 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
|
||||
As dez categorias recuperaram dados noutra sessão autenticada. As seis com contrato definido aceitaram os cenários semânticos documentados; as quatro restantes conservaram rascunhos, sem inventar um perfil modular para as submeter. Fundiário #32 separa aprovação interna da decisão externa. Venda #33 passou depois pelo percurso editorial público, sem divulgar resumo/dados privados.
|
||||
|
||||
**Campos e regras preparados:** cronograma com início/fim/responsável e dependências acíclicas; maturidade com os oito códigos normativos; viabilidade com cenário/indicadores/modelo e estudo, sem score de sucesso; serviços/título/entidade emissora/ocupação/avaliação imobiliários; parcelas com editor de vértices/anéis MultiPolygon, benfeitorias e consulta comunitária; constituição/beneficiário efetivo, exercícios existentes, ativos/passivos, obrigações laborais, licenças, NDA, governação e franquia. O contrato é elaborado no servidor e consumido pelo editor, sem templates JSON divergentes no cliente. Objetos legados conservam chaves anteriores. O schema original de 110 definições mantém SHA `9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0`.
|
||||
|
||||
**Falhas corrigidas com prova:** 12 recusas 422 identificaram o campo e conservaram o rascunho inválido: fim anterior ao início, ciclo, falta de cenário/modelo, serviços incompletos, título de outra oportunidade, comunidades sem consulta, financeiros insuficientes e contingências não declaradas. Erros `canonical.<campo>` são devolvidos pela API e ligados aos controlos; a posição/clareza visual ainda não foi observada. O formulário núcleo duplicado foi retirado do modo perfil; gestão legada mantém o seu acesso próprio.
|
||||
|
||||
**Filas e perfis:** revisão, preparação editorial, recurso e tarefas atribuídas reutilizam `tasks`, com ligação explícita ao bigint da oportunidade. #30 pediu parecer a 29: o destinatário errado recebeu403, a aprovação com parecer pendente422; a resposta foi guardada e concluiu a tarefa, sem aprovar o gate automaticamente. #33: coordenador editorial25 atribuiu a29; tarefa recuperada na fila; outro Publicador403; o designado publicou. Papéis/âmbitos/independência/responsabilidades são verificados no servidor; revogação após aprovação bloqueia publicação no teste específico. Responsabilidades atribuídas neste ensaio são sintéticas, sem competência institucional real inferida. SLA permanece `unconfigured` e `due_at=null`.
|
||||
|
||||
**Consumidores territoriais/documentais:** seis contratos recuperaram país MZ e uma localização com limite disponível; API retornou dois limites de contexto. O editor e os campos `location_id` consultam o território mais profundo e mantêm a parcela separada do limite administrativo. País FR do mercado do investimento foi gravado/recuperado; isto não encerra origem/filtros/estatísticas de todos os formulários. Originais dos seis casos permitiram21/24/25 e negaram organização estrangeira23(403). O download/prévia reutiliza `VerifiedDocumentBinary`, que verifica cifra/hash/scan e controla falhas; não altera os originais. #33 recuperou original da imagem e duas derivadas limpas pelo ClamD real. PDF válido de uma página, parser estrito, upload/scan ClamD real e SHA; download 21/24/25, negação 23(403); exclusão lógica→download404/opção removida; restauração→nova sessão→bytes/hash idênticos. Prova `opportunity-semantic-documents-http-qa.json`.
|
||||
|
||||
**Limites concretos:** correspondência entre titular/documento/cadastro e âmbito urbanístico exige evidência e decisão do gate, sem inferir autenticidade jurídica pela existência de um UUID. Limites urbanísticos e materialidade não são inventados. Unidades aplicáveis, Response integral e relações do §27 ainda precisam de reconciliação. O schema original exige pelo menos um exercício financeiro; casos com zero exercícios devem ter regra expressa reconciliada antes de serem declarados cobertos. A moeda obedece ao padrão do contrato; não existe aqui prova de catálogo completo ISO4217. DOCX/XLSX, partilhas e PDFs/exportações/derivadas sem prova específica continuam abertos. C37, A/B e todo o percurso visual por perfil permanecem pendentes.
|
||||
|
||||
**Preservação e compilação:** 18 conjuntos de linhas anteriores por ID/colunas originais preservados; MFA18 intacto. Manifesto ativo `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` preservado. Manifesto QA `0e610cd650d6b788edfdc9cbf697d125cb9330958914e13a8a8bbd14a781b395`; bundle próprio `build-qa-opportunities`, sem recompilar o ativo. TypeScript, PHPStan e testes específicos passaram; as contagens de testes não são usadas como critério de conclusão.
|
||||
|
||||
Mesma matriz: **3.386 requisitos / 3.386 linhas**, **6.838 ocorrências de origem**. IDs, fontes, critérios, versões divergentes e referências anteriores conservados.
|
||||
|
||||
| Estado canónico | Requisitos | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluÃdo e demonstrado | 6 | 7 |
|
||||
|
||||
Linhas principais: **AUD-OP-02/03/04, AUD-GEO-03, AUD-PROFILE-11/12/19/25/28/31 e AUD-OPS-01**, em [matriz-requisitos.csv](matriz-requisitos.csv). Nenhum desses critérios visuais foi fechado por API.
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Incremento canónico e workflow em QA — 10/10/2026
|
||||
|
||||
Servidor **http://127.0.0.1:18097**; destino guardado **desk_restore_20261009_174726_test:55441**. Zero escritas na base ativa. **C37 e toda a aceitação visual deste incremento permanecem pendentes.** Props da interface, API e persistência foram verificadas; nenhum clique ou renderização no navegador foi observado.
|
||||
|
||||
| Requisito na matriz única | Funciona / prova concreta em QA | Falta para fechar |
|
||||
| --- | --- | --- |
|
||||
| AUD-OP-04; AUD-PROFILE-28 | Dez categorias guardam rascunhos incompletos e recuperam noutra sessão MFA. Seis passam o contrato estrutural: investimento, imobiliário, DUAT, venda, parceria e franquia. Adapter do mesmo schema de 110 definições; condições `allOf` originais recuperadas, IDs existentes mantidos. | Semântica e editores dos objetos incompletos do §25; contrato Response integral; quatro perfis modulares aprovados; demonstração visual por categoria. |
|
||||
| AUD-OP-03; PROFILE-08/11/12 | Promotor 21 criou a oportunidade **18**, submeteu; Validador 24 solicitou correção; 21 corrigiu/resubmeteu; 24 reviu gates e aprovou; Publicador 25 publicou com visibilidade interna. O número 18 identifica a oportunidade, não a conta do investidor C37. | Visual integral, todas as categorias, pareceres/incidentes e coordenação editorial completa. |
|
||||
| PROFILE-09/10/12/21 | Imobiliário **19**: rejeição fundamentada por 24 → recurso reaberto pelo revisor independente 29 → nova aprovação 24 → publicação pública 25 → alteração privada sem alterar a ficha pública → suspensão com GET público 404. Autor Prom+Val+Pub e aprovador Val+Pub receberam 403. | Visual, filas institucionais e competências reais; suspensão/retoma e demais ações editoriais por todos os perfis. |
|
||||
| PROFILE-19/21/22 | Tarefas existentes em `tasks`, vínculo à oportunidade por origem/ID/correlação; atribuição explícita, motivos e decisões recuperáveis. Correção/rejeição concluem revisão; resubmissão não herda atribuição; suspensão conclui tarefas pendentes. Recurso do próprio rejeitor e membro da organização negados. SLA `unconfigured`, responsável e prazo iniciais nulos. | Filas completas, reatribuição/disponibilidade, coordenação editorial e política SLA aprovada. Não foram inventados responsáveis nem prazos. |
|
||||
| COUNTRY-01; GEO-03; OP-02 | Localização MZ/Cabo Delgado/distrito e documentos ligados ao mesmo contrato recuperados nas seis categorias. Oportunidade **28** publicou usando a localização principal mesmo em segundo lugar na lista. Documentos e imagem/duas derivadas verificados com ClamD real e hashes correspondentes. DUAT **20** aprovado internamente não publicou: 422 por autorização externa pendente. | Outros países/formulários, origem/filtros/estatísticas transversais, mapas renderizados, 188 correspondências cartográficas e operações documentais restantes. |
|
||||
| PROFILE-30; DASH-01 | Conta **Promotor QA — conta sintética 21**, MFA normal confirmado, organização e tipos autorizados. Dashboard devolve navegação do Promotor. Permissões temporárias dos testes foram revogadas e uma nova sessão 21 recuperou o dashboard. | Dashboard → formulário → guardar → nova sessão → workflow e resultado visíveis. Auditor ou Superutilizador não substituem este ensaio. |
|
||||
|
||||
Os requisitos acima continuam **parciais**: nenhum passou a concluído pelo incremento. O detalhe está no [relatório do workflow](workflow-oportunidades-qa.md); fontes, versões, critérios e percursos permanecem na [mesma matriz](matriz-requisitos.csv).
|
||||
|
||||
| Estado canónico | Requisitos / linhas da matriz | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 6 | 7 |
|
||||
|
||||
Total: **3.386 requisitos = 3.386 linhas**, **6.838 ocorrências**, **3.380 requisitos abertos**. As referências e versões anteriores foram preservadas.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
<!-- qa-profiles-v1-20261010:begin -->
|
||||
## Perfis v1: resultado do lote em QA — 10/10/2026
|
||||
|
||||
A v1 foi lida integralmente: 530 parágrafos, confrontados com os 471 do exemplar anterior, AGENTS, auditoria, código, schema PostgreSQL e configuração. Fortify, contas, documentos, relações, IDs, classificações, histórico, cinco fases e 19 etapas foram preservados. Keycloak foi avaliado como recomendação; não há necessidade de SSO/federação demonstrada para substituir Fortify neste lote. A aceitação das fontes oficiais permanece intacta.
|
||||
|
||||
**A mesma matriz contém 3.386 requisitos/linhas e 6.838 ocorrências de origem.** Os 3.354 requisitos e 5.830 referências anteriores foram conservados. Acrescentaram-se 32 requisitos verificáveis, os 1.001 parágrafos dos dois exemplares e sete referências diretas da reconciliação. Parágrafos de estrutura e tabelas são referências, não pedidos independentes. Inventário: 591 exemplares, versões e anexos; 202 ficheiros acessíveis nesta varredura, incluindo 12 marcadores de bloqueio Office preservados por bytes/hash.
|
||||
|
||||
| Estado canónico | Requisitos | Ocorrências |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3228 | 6371 |
|
||||
| não implementado | 146 | 454 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 6 | 7 |
|
||||
|
||||
O sexto concluído é apenas **AUD-PROFILE-27**, a avaliação arquitetural de Keycloak. Não encerra autenticação visual nem uma integração externa. **AUD-OP-03 passa a parcial**, pelas regras e transições novas; o workflow integral continua aberto.
|
||||
|
||||
QA: **http://127.0.0.1:18097**, destino efetivamente verificado `desk_restore_20261009_174726_test:55441`, com guarda antes de escrita. Foram preparadas 12 contas sintéticas, com MFA configurado e confirmado normalmente por Fortify, duas organizações e quatro representações. O ensaio comprovou:
|
||||
|
||||
- Dashboard do Promotor → links devolvidos pela interface → criação do rascunho 15 → gravação idempotente → nova sessão MFA → recuperação → edição pelo outro Promotor autorizado da mesma organização. Autor e ID permanecem; outra organização, tipo não autorizado e transferência de organização recebem 403.
|
||||
- Quatro pedidos de Promotor, comprovativos privados com ClamD real e hash recuperado, acompanhamento noutra sessão e aprovação independente. Rejeição pode registar conferências falsas sem inventar validação. Auditor com outro papel continua sem escrita operacional.
|
||||
- Revogação do Validador 24: sessão antiga recebe 401; nova sessão MFA autentica, mas o papel já não autoriza. Inicialização da dupla 27/28 pelo terceiro aprovador 29; substituição atómica 28→30; dupla atual 27/30, terceiro negado, replay negado e sessão anterior invalidada.
|
||||
- Recuperação do titular 30 por código Fortify noutra sessão, sem desativar MFA; código consumido não autentica. Desativação de MFA, eliminação e mudança de email do titular são recusadas. Auditor recupera e exporta metadados filtrados, sem snapshots privados.
|
||||
- Consumidores territoriais do Promotor corrigidos para rotas autorizadas: 11 províncias, **Cabo Delgado** selecionada, guardada e recuperada com o mesmo ID/nome; uma área geométrica devolvida pela API. O desenho no navegador continua por demonstrar.
|
||||
|
||||
**Continuam abertos:** C37, cenários A/B e C43 na interface; contratos especializados completos e vínculos canónicos; submissão/aprovação/publicação positivas, agendamento e versão pública; filas dos coordenadores, pareceres, incidentes de suporte, decisões de recurso, SLA e governança; âmbito provincial de autorização; tokens/dispositivos; privacidade e todas as operações documentais por tipo/perfil. A submissão real do rascunho 15 responde 422 controlado por contrato Create incompleto. Revisão/correção positiva foi ensaiada com uma fixture explicitamente submetida, não com uma submissão real.
|
||||
|
||||
API e persistência estão demonstradas nos escopos acima. Interface preparada e compilada; nenhum navegador automatizável foi inventariado, portanto não há aceitação visual. TypeScript, build QA e PHPStan passaram; 14 testes específicos, com 446 asserções, passaram. A contagem não substitui a prova por requisito.
|
||||
|
||||
Manifesto QA demonstrado: `22947d5c47487137b9e37138f29322d99181c7d0cb0bc655dba67fd2e444ae80`. Manifesto ativo preservado: `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94`. Não há revisão candidata fixada enquanto faltar a aceitação visual.
|
||||
|
||||
Acumulação com a permissão administrativa antiga foi reconciliada antes da alteração: uma conta com atribuição v1 não usa a criação legada para contornar organização/tipo; a revogação não restaura automaticamente privilégio antigo mais amplo. Auditor com permissão legada continua sem escrita administrativa. Testes de compatibilidade comprovam as negações; gestores sem atribuições v1 conservam as funções anteriores. Caso C49 adicionado, sem alteração de privilégios ativos.
|
||||
|
||||
Preservação por ID/colunas originais comprovada em 18 tabelas, incluindo conta 18, documentos e auditoria; manifesto ativo inalterado. Ativo consultado exclusivamente em transação READ ONLY (`investemoz:5432`): perfis 040000 ausentes e flag desativada; 020000 instalada, 030000/percurso ausentes, zero geometrias. **Nenhuma escrita ou alteração de privilégios ativos.** QA mantém 393 geometrias privadas; faltam 31 distritos e 157 postos, já nomeados. Publicação de camadas é separada.
|
||||
|
||||
Evidências sanitizadas: `profiles-v1-http-qa.json`, `profiles-v1-mfa-enrollment-qa.json`, `profiles-v1-recovery-audit-qa.json`, `profiles-v1-current-version-qa.json`, `profiles-v1-preservation-qa.json`, `profiles-v1-reconciliation-proof.json`, `profiles-v1-final-qa.xml` e `profiles-v1-build-qa.log`, em `storage/app/private/investmoz-v2/portal-audit/`. Credenciais, cookies, segredos e códigos não constam dos relatórios.
|
||||
|
||||
[Correspondência por requisito](perfis-v1-reconciliacao.md), [roteiro manual](roteiro-manual-perfis-v1.md), [backlog](backlog.md), [ativação e reversão](plano-ativacao.md). Relatório e backlog também disponíveis no servidor QA, com autenticação, em `/qa-auditoria/relatorio-executivo` e `/qa-auditoria/backlog`.
|
||||
|
||||
<!-- qa-profiles-v1-20261010:end -->
|
||||
|
||||
> Registos anteriores preservados como histórico. Os estados correntes são os da matriz e do bloco mais recente acima; uma entrega posterior não altera retroativamente a evidência anterior.
|
||||
|
||||
<!-- qa-dashboard-20261010:begin -->
|
||||
## Acesso à Gestão de Oportunidades — lote C43/C44, 10/10/2026
|
||||
|
||||
QA exclusiva: http://127.0.0.1:18097, alvo real `desk_restore_20261009_174726_test:55441`. Ativo consultado por transação SQL READ ONLY; 0 escritas/instalações/publicações ativas. Cinco fases/19 etapas, 5 dossiers/95 etapas e 393 geometrias QA preservados. C37 e aceitação visual permanecem abertos.
|
||||
|
||||
Gestores QA 14/17 têm email verificado+`manage-portal`; gestor 17 possui MFA normal. Investidores QA 13/18 não têm gestão. No ativo, a leitura confirmou contas 2/13 autorizadas, 020000 instalada e 030000 ausente; o dashboard ativo contém título Gestão de Oportunidades e ação antiga Criar oportunidade. Não há hotfile/cache de configuração/cache de rotas a explicar a ausência. URL configurado ativo: http://portal-investidor.test; **URL/conta da sessão reportada ainda desconhecidos**, sem atribuição especulativa de causa a essa conta.
|
||||
|
||||
**C43 — causas encontradas no código/compilação:**(1)AppSidebar mostrava só o primeiro filho de cada grupo, descartando oportunidades; (2)menu Painel apontava /investidor mesmo para gestor; (3)dashboard de gestão colocava criação no fim como Criar oportunidade, sem rótulo Nova oportunidade. A secção de gestão só é devolvida pelo backend a contas verificadas em portal.managers; o seu ocultamento para investidor é autorização esperada, não motivo para conceder perfil administrativo.
|
||||
|
||||
Correção QA: atalhos Gestão de Oportunidades/Nova oportunidade no início do dashboard, sidebar/menu agrupado; Painel da gestão→/dashboard, dossiers→/investidor. Nova ação archive conserva o rascunho em status archived/deleted_at=null; delete/exclusão lógica mantém o contrato anterior. Restauro volta a draft, ID/histórico intactos, sem publicação. Código usa flags false por omissão; QA usa assets `public/build-qa-opportunities`, versão Inertia desse manifesto. **`public/build` ativo manteve SHA 75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94.** Recursos 030000 não são requisito para navegar/criar rascunho básico.
|
||||
|
||||
**C44 — progresso específico:**primeiro adaptador imobiliário aceita campos fornecidos nas 24 propriedades realEstatePatch, validando tipos/enums/limites/UUID e recusando campos desconhecidos/de resposta. Guarda/merge privadamente no management_draft atual. Ainda faltam formulário especializado, vínculos canónicos, condições finais/Create/Response, DUAT/transação e workflow. Nenhuma coleção/tabela paralela, renumeração ou decisão externa criada. O.source.json conserva o retrato histórico da extração, não certifica o estado runtime atual.
|
||||
|
||||
- `dashboard-active-readonly.json`: configuração/contas elegíveis/instalação/manifesto ativo, sem boot HTTP/escrita de sessão no ativo.
|
||||
- `dashboard-opportunities-http-qa.json`: links obtidos do dashboard→gestão→create→201→GET→logout/nova sessão→GET→PUT→archive→filtro archived→restore→GET. Oportunidade QA 13, ID estável/quatro eventos, 12 originais e relações/documentos/dossiers preservados; 6 publicadas mantidas; outra conta 403.
|
||||
- `dashboard-manager-mfa-http-qa.json`: gestor 17, MFA em duas sessões→dashboard→gestão→recuperação/reload→logout; 0 alterações de registos. `dashboard-navigation-catalogue-qa.json`: menu Painel da gestão→/dashboard, dossiers privados→/investidor, atalhos únicos e ausência de gestão para investidor.
|
||||
- `real-estate-draft-http-qa.json`: outra oportunidade QA 14, rascunho imobiliário parcial no draft existente→nova sessão→patch merge→cinco 422 sem mutação→outsider 403; schema normativo SHA 9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0.
|
||||
- `dashboard-real-estate-qa.xml`: casos nomeados Dashboard OpportunityAccessTest/OpportunityManagementTest/OpportunityRealEstateDraftTest, 8 ensaios/201 asserções. Typecheck, build QA e PHPStan dirigidos passaram; contagem não substitui os critérios visuais.
|
||||
- `resource-originals-preservation-http-qa.json`: cinco binários anteriores preservados e novo PDF válido recuperado/parseado, 0 novos recursos. Prova ClamD real anterior mantida; não afirma novo scan das derivadas nem renderização.
|
||||
- `dashboard-matrix-reconciliation-qa.json` e `pedidos-abertos-projection.json`: fontes/trechos/IDs/versões preservados, 3354 requisitos/linhas e 5830 ocorrências; 3349 abertos apresentados uma vez cada. Estado AUD-OP-04 passa de não implementado a parcial; nenhum requisito global encerrado.
|
||||
|
||||
| Grupo de trabalho dos abertos | Requisitos | O que falta / próxima ação |
|
||||
| --- | ---: | --- |
|
||||
| Não implementados |145|Workflow integral segregado; rascunhos incompletos procura/capacidade e jornadas educativas ainda por fazer; demais critérios específicos não implementados no relatório por ID. Implementar workflow sobre draft/auditoria após contratos/gates; educação não começa enquanto prioridades abertas.|
|
||||
| Parciais |3183|Contrato imobiliário apenas API; DUAT/transações/campos/regras finais e vínculos; cartografia 31 distritos/157 postos/mapas por consumidor; relações/citações; acervo/PDFs/partilhas/derivadas; parâmetros fiscais; negócios/direitos/mandatos; reconciliação documental/UX e ativação. Inclui 4 decisões com estado original, sem bloquear tarefas QA independentes.|
|
||||
| Preparados emQA |19|Escopos concretos de rascunhos/recursos/maturidade, países/origem/estatísticas, seletor provincial, Balcão/percurso/CRM/orientação/simulador/parceria/fundiário/importação privada com provasQA. Completar critérios restantes na interface e operações/tipos ainda abertos; ativação permanece separada. Não são 19 requisitos globais concluídos.|
|
||||
| Instalados com falha de acesso |2|AUD-DASH-01(C43) e AUD-UI-01(C37/inspeção). Código/backendQA corrigidos nos escopos descritos; identificar sessão reportada e demonstrar dashboard/cliques e loginA/B completos. Confirmar visualmente sem conceder gestão ao investidor.|
|
||||
|
||||
**Lista integral legível:**http://127.0.0.1:18097/qa-auditoria/pedidos-abertos, gestorQA 14/17.3349 cartões, um por ID, com pedido, lacuna, próxima ação, critério, fontes/versões/evidências e filtros de grupo/módulo. Vista derivada da [matriz única](matriz-requisitos.csv), sem edição nem estados paralelos; investidor 403. Os grupos são editoriais, distintos dos estados canónicos: 3199 parciais, 145 não implementados, 4 decisões, 1 implementado não ativado, 5 concluídos de âmbito restrito.
|
||||
|
||||
Decisões humanas preservadas: pares exatos das 6 oportunidades com âncoras/clusters; identidade/termos/contactos do operador; URLs/serviços/responsáveis institucionais; modelo/indicadores/baseline de sucesso. Não reabrir aceitação das fontes oficiais; não inventar pares, parâmetros ou score.
|
||||
|
||||
Visual pendente:[roteiro curto](demonstracao-dashboard-oportunidades.md). API/persistência/assets são provas separadas de interface; nenhum clique automatizado/cenárioA/B visual foi alegado.
|
||||
|
||||
**Histórico preservado:** os blocos anteriores a C43/C44 abaixo conservam as suas fontes e provas. Os seus hashes/ready/contagens não certificam a revisão atual; a primeira adaptação imobiliária não equivale ao schema integral nem ao workflow.
|
||||
<!-- qa-dashboard-20261010:end -->
|
||||
|
||||
<!-- qa-independent-20261010:begin -->
|
||||
## Lote independente I0–I3 — 10/10/2026
|
||||
|
||||
QA exclusiva: http://127.0.0.1:18097; alvo confirmado `desk_restore_20261009_174726_test:55441`. Testes de feature usam outra QA, `investmoz_test:55441`, com guarda antes de qualquer escrita. Sem alterações, instalação, importação ou publicação na base ativa. Cinco fases/19 etapas preservadas;5 dossiers/95 etapas na QA web, sem criação de jornadas educativas.
|
||||
|
||||
| Requisito | Entrega e prova | Critérios ainda abertos |
|
||||
| --- | --- | --- |
|
||||
| AUD-GEO-01/02/03; C38 | Correspondências explícitas Maputo Província, Maputo Cidade e Matola; descendentes por nome exato+pai. Ação autenticada/MFA no servidor 18097 insere 32 limites internos:2 províncias+8 distritos+22 postos. 361 originais e 1990 geo_locations preservados. 32/32 novos recuperados via endpoint consumidor; público: 0/32. 393/393 contextos por serviço;1920/1920 referências territoriais por API. ProvinceSelect liga o nome guardado ao preview sem reescrever valores. |31 distritos/157 postos por nome; localidades sem polígonos neste exemplar; integração renderizada por consumidor e ato de divulgação separados. |
|
||||
| AUD-COUNTRY-01–04/06–07, STATS-01 | Provas renovadas por método/formulário: perfil/empresa/manifestação/público/quatro apoios/carteira/interesse público em negócio/wizard/fundiário/três Balcões/simulador/CRM. Gravação→GET→filtro→agregado quando aplicável; residência/constituição/fiscal/fundos distintos da origem declarada; unknown preservado. |Seleção, recibos, variações condicionais, filtros/tabela e nova sessão na interface. Não contar testes documentais selecionados pela palavra original como requisitos de países. |
|
||||
| AUD-LAND-01, PART-01, LIB-01/02, SCAN-01; C39/C40 |404 dos originais cifrados corrigido em fundiário e sala de parceria por VerifiedDocumentBinary comum; novos uploads registam tamanho/autor. PDF/PNG/JPEG com ClamD real, hash, quarentena/perfis/partilha/revogação/adulteração/recuperação. Lote documental QA privado, idempotente, sem substituir identidade conflituosa; actor sem permissão/ambiente ativo recusados. |Renderização PDF/impressão, ações visuais e restantes consumidores/tipos; lote ativo autenticado. Serviço ClamD real demonstrado separado de arranque/manutenção permanente/GD ativo. |
|
||||
| AUD-OP-02/SCAN-01; C41/C42 |PDFs sintéticos apenas com cabeçalho não provavam legibilidade: novos testes usam PDF válido de uma página, parseado independentemente. No servidor 18097, novo PDF anexado à oportunidadeQA12→scan real→download→nova sessão→recuperação; outra conta 403. Cinco binários anteriores preservados por SHA; PNG/3WebP recuperados, sem novo scan das derivadas. Credenciais fixas do script removidas; passwords/remember tokens de dois atoresQA auxiliares rodados; MFA/conta 18 preservados. |PDF renderizado e interface documental pendentes. O placeholder anterior permanece conservado como evidência e não certifica abertura de PDF. |
|
||||
| AUD-OP-03/04 |Schema normativo §26 preparado e rastreável:110 definições,9 contratos Create/Patch/Response, sem renumerar IDs. |Workflow e validação/gates especializados continuam não implementados; nenhum fecho funcional atribuído ao artefacto. |
|
||||
| C37, AUD-UI-01, I1–I3 |Evidências anteriores conservadas, nenhum novo ensaio de login investidor neste lote. |Login/reload/logout/nova sessão e A/B completos na interface continuam pendentes. |
|
||||
|
||||
| Nível INE 2019 | Esperado no exemplar | Resolvido QA | Pendente |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Província |11|11|0|
|
||||
| Distrito |159|128|31|
|
||||
| Posto |411|254|157|
|
||||
| Total |581|393|188|
|
||||
|
||||
Os denominadores da edição 2019 não são os 1920 territórios do cadastro existente. [188 lacunas por nome](correspondencias-cartograficas.md), incluindo Cidade De Maputo agregado e Mandie duplicado no próprio workbook; nenhuma equivalência por código/fuzzy, nem fusão de cadastros. Aceitação das fontes oficiais preservada.
|
||||
|
||||
**Mesma matriz, sem requisitos amplos recém-fechados:**3354 requisitos/3354 linhas canónicas;5830 ocorrências de origem. IDs, fontes, trechos, diferenças de versão, critérios e estados conservados.
|
||||
|
||||
| Estado | Requisitos/linhas | Ocorrências |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3198 | 5380 |
|
||||
| não implementado | 146 | 440 |
|
||||
| dependente de decisão concreta | 4 | 4 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 5 | 5 |
|
||||
|
||||
Evidências privadas: `geometry-crosswalk-http-qa.json` (aplicação posterior22, além das 2+8 anteriores registadas na auditoria), `geometry-crosswalk-preservation-qa.json` (total 32 novas), `geometry-crosswalk-consumers-http-qa.json` (HTTP 32 novas/0 públicas), `geometry-consumers-path-qa.json` (serviço393), `territory-catalogue-coverage.json` (catálogo1920). Não confundir aplicação parcial inicial interrompida por Mandie com duplicação: as 10 já aplicadas foram reutilizadas na continuação; ambiguidade nominal fica pendente e não bloqueia outras correspondências únicas.
|
||||
|
||||
Testes dirigidos: `geometry-crosswalk-qa.xml` (casos de vínculo/pai/exemplar/conflito/replay/reversão), `document-independent-qa.xml` (dois percursos PDF/PNG/JPEG/perfis), `document-batch-independent-qa.xml` (um caso ClamD real; dois de fixture), `countries-independent-qa.xml` e `countries-forms-independent-qa.xml` (métodos identificados na [cobertura](cobertura-percursos-i0-i3.md)). TypeScript/build/PHPStan dos componentes alterados passaram. A guarda de concorrência recusou um arranque paralelo de testes; ficou em `countries-forms-independent-lock-refusal-qa.xml`; o ensaio sequencial posterior passou. Isto é tooling protegido, não falha de formulário.
|
||||
|
||||
Complemento documental: `document-types-fixture-qa.json`, `resource-originals-preservation-http-qa.json`, `resource-http-actors-rotation-qa.json` e nova versão de `resources-real-http-qa.json`; versão anterior arquivada antes da atualização. Credenciais novas somente no ficheiro privado local; nenhum cookie/OTP/body exportado. Não alterar os originais anteriores para tornar o ensaio verde.
|
||||
|
||||
Código preparado e QA API/persistência demonstradas neste âmbito; interface não demonstrada. `candidate_revision_fixed=false`, `integrated_ui_ab_passed=false`, `activation_ready=false`. [Backlog atual](backlog.md) e [ordem de ativação/reversão](plano-ativacao.md). Não há candidata final nem novo backup final de ativação emitido.
|
||||
|
||||
**Versões anteriores preservadas:** as secções abaixo documentam os instantes anteriores. 361 e2/39/179 são a baseline 09/10; o estado cartográfico corrente deste lote é393 e0/31/157. Ready/hashes/contagens antigas não certificam esta revisão. C37 mantém a sua confirmação visual pendente, sem bloquear as frentes independentes.
|
||||
<!-- qa-independent-20261010:end -->
|
||||
|
||||
## C37 — causa confirmada; seleção de conta QA corrigida — P0, visual pendente
|
||||
|
||||
O pedido de reabertura referia a conta esperada18. A captura manual21:22–21:24UTC demonstra que foi selecionada a conta13 restaurada. O MFA foi alcançado, mas a autenticação nunca se completou. Causa concreta: credenciais MFA da conta13 cifradas pela chave de origem, incompatíveis com a chave estávelQA; a conta18 sintética não foi desafiada nessa tentativa. Nenhuma chave, MFA ou conta13 foi alterada para contornar o problema.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Ensaio manual de perfis v1 — QA exclusiva
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Ensaio visual futuro deste lote — contas QA, sem credenciais
|
||||
|
||||
Servidor exclusivo **http://127.0.0.1:18097**. Para a instalação, usar o **Superutilizador sintético QA27** já designado na dupla27/30; obter credenciais eMFA somente no ficheiro privado local. **Promotor QA21 continua a conta do dashboard, não substituí-lo por27. C37/QA18 e A/B continuam pendentes; não é pedida nova tentativa agora.**
|
||||
|
||||
1. ComoQA27, login/MFA normal e abrir **http://127.0.0.1:18097/management/profile-install**. Conferir oalvo apresentado `desk_restore_20261009_174726_test:55441`, schema instalado, dependências020/030 presentes, provas atuais e dupla existente. Não executarbootstrap ou escolher titulares novos neste servidor usado.
|
||||
2. Atualizar a página, terminar sessão einiciar outra. Esperado:mesmo ledger e dupla,semnova instalação ou atribuição. Os testes de instalação desde schema vazio/reversão pertencem àbaseisolada do runner; não remover o040 do servidorQA emuso para demonstrar o botão.
|
||||
3. Quando retomarQA21, dashboard→Minhas oportunidades; #30 apresenta acesso restrito e não catálogo; #33 apresenta catálogo público; #32 apresenta aprovação interna,sempublicação. Os originais/contatos privados não entram na projeção pública.
|
||||
4. ComoQA21, abrir **http://127.0.0.1:18097/minhas-oportunidades/36/resources**:consultar PDF,DOCX eXLSX sintéticos. Guardar original, excluir logicamente um recurso, esperar404/downloadindisponível; restaurar einiciar outra sessão para recuperar os mesmosbytes. QA24/25 podemconsultar; organizaçãoQA23 énegada.
|
||||
5. Selecione CahoraBassa/Chitima ouCidadeMaxixe no consumidor territorial. Esperado:mesmoIDexistente, geometria administrativa comedição2019, legenda eárea selecionada. Guardar/recuperar seleção; não confundir limite administrativo comparceladeinvestimento. Inspecionar cadaoutroconsumidor no roteiroexistente.
|
||||
6. Registar URL,conta/ação/resultado visível eestado recuperado. Omitir Cookie,X-XSRF-TOKEN,password,segredo/códigoMFA. Incorporar falhasàmesma matriz antes defixar candidata.
|
||||
|
||||
Documentos fundiários QA21: **http://127.0.0.1:18097/investidor/terra-duat/01a12568-c330-73ec-a1ac-543ac0116a26**. Conferir PDF/PNG/JPEG e país MZ/origem PT; abrir impressão e gerar PDF no navegador. A recuperação do modelo por HTTP não demonstra o PDF final.
|
||||
|
||||
Partilha QA21/22: **http://127.0.0.1:18097/parcerias/salas/1**. Os três originais estão privados após o ensaio. QA21 partilha explicitamente um; QA22 recupera noutra sessão. QA21 revoga; QA22 deixa de o listar/obter; QA21 mantém o original. QA14 não participante e QA23 não têm acesso à sala.
|
||||
|
||||
Este roteiro não declara os passos executados. Instalação ativa e publicação de camadas permanecem ações futuras separadas.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Ensaio visual preparado — permanece pendente conforme pedido
|
||||
|
||||
URL QA **http://127.0.0.1:18097/dashboard**. Conta **Promotor QA21**, MFA normal, credenciais/código obtidos apenas localmente. Não incluir cookies, cabeçalhos X-XSRF-TOKEN, segredos ou corpo do código em capturas. C37/conta18 é um ensaio independente e continua pendente.
|
||||
|
||||
1. Entrar no dashboard como21. Esperado: **Minhas oportunidades** e **Nova oportunidade**, ambos funcionais, no contexto autorizado. URLlista `/minhas-oportunidades`; criação `/minhas-oportunidades/create`.
|
||||
2. Abrir cada ID QA30–39 em `/minhas-oportunidades/<id>/view` e, nos estados editáveis, `/edit`. Esperado: estado privado e publicação distintos; núcleo único, categoria correta e permissões aplicadas. #36–39devem continuar drafts.
|
||||
3. Criar rascunho incompleto, guardar; logout/nova sessãoMFA e recuperar. Não usar as fichas publicadas para substituir este passo. Esperado: mesmos dados/ID, sem mistura com outrosprojetos/oportunidades. Se ainda não houver localizações/documentos, guardar primeiro e abrir **Recursos**.
|
||||
4. Nos recursos `/minhas-oportunidades/<id>/resources`, selecionar país e hierarquia; conferir mensagem de território cadastrado, geometria selecionada disponível/ausente e mapa. A parcela declarada em DUAT deve manter geometria própria, sem substituir por limiteadministrativo. Conferir original/prévia antes/depois de recuperar.
|
||||
5. Num rascunho de ensaio das seis categorias, introduzir erro semântico e guardar; submeter. Esperado:422, mensagem junto ao campo, valores preservados; depois corrigir e resubmeter. Concessão/financiamento/fornecimento/outros devem informar perfilmodularpendente.
|
||||
6. Como24, abrir mesma ficha/fila (`/minhas-oportunidades?queue=review`), atribuir/iniciar→solicitarcorreção→21corrige→nova revisão/gates. Registar motivo e documentos da própria versão. Ensaiar parecer por destinatário29: outra resposta negada e tarefa/parecer recuperados sem gateautoaprovado.
|
||||
7. Como25, fila editorial (`?queue=publication`), designar Publicador independente; como29, fila `?queue=assigned`, comparar prévia/hash/teaser e publicar. OutroPublicadornãodesignado deve recebernegação; aprovarprópria/mesmaorganização/múltiplospapéis deve continuar negado. Não atribuirresponsabilidadesreais aos atoresQA.
|
||||
8. Depois de publicar, solicitar revisão/materialalteração; esperado: publicação anterior preservada até nova revisão. Fundiário32não pode contornar autorizaçãoexternapendente. Conferir suspensão e recuperação dos motivos/tarefas.
|
||||
|
||||
Registar por ação URL/ator/ID/estado/API/persistência/resultado visível; associar a mesma matriz. A/B completos e cincofases/19etapas continuam no roteiro existente, sem renumerar. Não fechar critérios por leitura dos dados ou totaldetestes.
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Ensaio manual atual — Promotor QA e workflow
|
||||
|
||||
**URL inicial exato: http://127.0.0.1:18097/dashboard. Conta: Promotor QA — conta sintética 21.** Se necessário, o login normal é `/login`, com MFA Fortify. Credenciais e códigos permanecem exclusivamente no ficheiro privado local; não os copiar para a conversa. Conferir identificação QA/porta 18097; não usar `portal-investidor.test`.
|
||||
|
||||
1. Entrar como Promotor 21, concluir MFA e atualizar dashboard. Esperado: “Minhas oportunidades” e “Nova oportunidade”, sessão mantida. Um retorno a login entra como reprodução pendente, não como sucesso.
|
||||
2. Pelo dashboard, clicar “Nova oportunidade”, escolher organização QA representada e categoria imobiliária; criar e guardar **rascunho incompleto**, incluindo um campo opcional limpo. Esperado: ID, estado rascunho e ausência de bloqueio por campos ainda não preenchidos.
|
||||
3. Anotar o ID sem copiar informação privada; logout, nova sessão/MFA, dashboard → “Minhas oportunidades” → mesmo ID. Esperado: recuperação exata, organização/autor preservados. Submeter incompleto deve mostrar campos pendentes e manter o rascunho.
|
||||
4. Abrir recursos pelo formulário: MZ → Cabo Delgado → distrito válido → principal; guardar/reabrir. Esperado: mesmos IDs/nome, filhos coerentes; mapa administrativo quando disponível. Não usar limite administrativo como geometria de parcela. Carregar documento sintético permitido; confirmar scan/recuperação e vínculo ao mesmo registo.
|
||||
5. Completar núcleo e módulo imobiliário com dados explicitamente sintéticos. O catálogo de ensaio possui sector/subsector/atividade QA; contacto é da organização QA. Usar `sale`, `warehouse`, `completed`, condição `good`, áreas positivas, moeda/preço positivo, ocupação/título/ónus/avaliação e licença aplicável; documento e localização selecionados no contrato. Preencher resumo/pedido/evidências e declaração de verdade. Para este primeiro ciclo usar visibilidade **internal**. Os editores de objetos/grelhas ainda incompletos devem ser registados como falhas: não contornar pela consola nem fingir visual concluída.
|
||||
6. Guardar com motivo → submeter. Esperado: `/view`, estado submetido, formulário bloqueado, snapshot/versionamento e tarefa de revisão sem responsável/prazo inventado. A rota `/edit` fechada não deve ser usada para continuar revisão.
|
||||
7. Noutra sessão QA entrar como **Validador 24**; abrir o mesmo ID autorizado, atribuir/iniciar revisão, solicitar correção com motivo e campo identificado. Esperado: necessidades de informação, motivo recuperável e tarefa de correção.
|
||||
8. Voltar como 21: abrir mesma oportunidade, corrigir, guardar com motivo e resubmeter. Logout/nova sessão deve recuperar a versão corrigida sem perder a decisão anterior.
|
||||
9. Como 24: iniciar a revisão dessa versão, registar os gates aplicáveis com decisão/evidência/fundamento; aprovar. Esperado: versão/hash aprovados, aprovação prematura recusada.
|
||||
10. Como **Publicador 25**: abrir `/view`, rever prévia, confirmar âmbito e publicar. Esperado: publicado interno, sem exposição na API pública. Para o ensaio público separado, preparar capa, legenda/alt/direitos e duas derivadas analisadas; confirmar prévia/hash e publicar explicitamente.
|
||||
11. Como 21, solicitar alteração material com fundamento, editar a proposta privada. Esperado: nova revisão sem aprovação reutilizável; ficha pública anterior permanece intacta. Como 25 suspender com motivo; esperado GET público 404 e história preservada.
|
||||
12. Em registo separado, como 24 rejeitar com fundamento; como 21 pedir recurso; como **revisor independente 29** reabrir. Esperado: rejeição original conservada. Rejeitor e membro da organização devem ser recusados. Conta 31 com papéis acumulados/Auditor continua sem escrita. Não atribuir responsabilidades reais às contas sintéticas.
|
||||
|
||||
Recolher por passo apenas hora UTC, perfil/ID, URL sem tokens, ação, resultado visível e referência do registo. Omitir Cookie, X-XSRF-TOKEN, passwords, segredo/código MFA e documentos privados de capturas. C37/investidor 18 e cenários A/B continuam no roteiro existente, separados deste ensaio. Sucesso HTTP abaixo não certifica nenhum dos passos visuais.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
**Endereço inicial: http://127.0.0.1:18097/dashboard.** Não usar portal-investidor.test nem outra porta. O servidor verifica `desk_restore_20261009_174726_test:55441` antes de escritas. Em Network, confirmar o header `X-Investmoz-QA-Target` de uma resposta de página/API. Capturas devem omitir Cookie, X-XSRF-TOKEN, Authorization, credenciais, segredos e corpos de login/MFA.
|
||||
|
||||
Credenciais e material MFA ficam apenas no ficheiro local privado `storage/app/private/investmoz-v2/portal-audit/profiles-v1-actors-private.json`. Consulte no editor e no autenticador local; não os copie para a conversa. Todas as contas sintéticas foram configuradas normalmente por Fortify sob a chave QA estável. Não gerar outra chave nem desativar MFA.
|
||||
|
||||
## Contas e estado atual
|
||||
|
||||
| Conta QA | Estado após os ensaios |
|
||||
| --- | --- |
|
||||
| 20 | Público, sem criação de oportunidades |
|
||||
| 21 | Promotor da organização A; autor do rascunho 15 |
|
||||
| 22 | Outro Promotor autorizado da organização A; coedição |
|
||||
| 23 | Promotor da organização B; acesso à oportunidade 15 negado |
|
||||
| 24 | Validador revogado; conta e login MFA preservados |
|
||||
| 25 | Publicador; sem edição técnica ou aprovação de rascunho |
|
||||
| 26 | Auditor; leitura e exportação de metadados |
|
||||
| 27 e 30 | Os dois Superutilizadores atuais |
|
||||
| 28 | Titular substituído; conta preservada, administração negada |
|
||||
| 29 | Terceiro aprovador do bootstrap QA; não é Superutilizador |
|
||||
| 31 | Promotor + Validador + Auditor; restrição de leitura prevalece |
|
||||
|
||||
## Primeiro ensaio: Promotor, recuperação e coedição
|
||||
|
||||
1. Numa sessão QA isolada, entrar como 21: login → MFA → dashboard. Esperado: “Minhas oportunidades” e “Nova oportunidade”. Atualizar a página mantém a sessão; registar se volta ao login.
|
||||
2. Clicar “Minhas oportunidades” → abrir 15 → editar. Esperado: mesmo ID, organização e rascunho; título editado por 22 e Cabo Delgado recuperados. O dossier privado não se transforma em oportunidade publicada.
|
||||
3. Selecionar província/distrito disponíveis e conferir o mapa/estado de cobertura. Guardar com fundamento. Esperado: mensagem de gravação; mudar o pai limpa filhos. Limite administrativo não representa perímetro do terreno.
|
||||
4. Logout → nova sessão/login MFA 21 → dashboard → Minhas → 15. Esperado: campo, nome, ID, versão e mapa recuperados. Separar prova do desenho, API e persistência.
|
||||
5. Noutra sessão, 22 → dashboard → Minhas → 15 → editar/guardar. Esperado: autorizado; autor 21 preservado e editor 22 registado. Nova sessão 21 recupera a edição.
|
||||
6. Como 23, abrir o URL da oportunidade 15. Esperado: 403 controlado sem conteúdo da organização A. Como 31, leitura permitida e gravação negada; os dados não mudam. Como 20, criação não aparece e acesso direto é negado.
|
||||
7. Como 21, solicitar arquivo com razão. Esperado: pedido persistido, estado draft mantido. Promotor não ganha exclusão/restauro. Submissão e publicação completas continuam indisponíveis pelo contrato; não interpretar isso como falha MFA/C37.
|
||||
|
||||
## Pedido de Promotor e decisão independente
|
||||
|
||||
1. Como 20: dashboard → “Solicitar perfil de Promotor” → `/perfil/promotor`. Esperado: somente pedidos e comprovativos próprios. Se necessário, preparar um anexo sintético privado no dossier do Balcão; aguardar scan clean real.
|
||||
2. Escolher representação, organização existente autorizada quando aplicável, tipos, comprovativo e fundamento. Submeter. Esperado: ID e estado pending; nenhuma criação autorizada antes da decisão.
|
||||
3. Logout/nova sessão 20 → acompanhamento. Esperado: mesmo pedido recuperado. Antes de repetir por erro de interface, conferir o acompanhamento; a mesma chave não duplica o pedido.
|
||||
4. Noutra sessão, 27 ou 30 → Gestão de perfis → consultar comprovativo → conferir identidade, representação e documentos → escolher tipos → decidir com razão. Declarar apenas conferências efetivamente realizadas. Rejeição pode manter conferências falsas e não concede perfil. Aprovação própria é negada.
|
||||
5. Nova sessão 20 após aprovação → dashboard. Esperado: Minhas/Nova para o âmbito e tipos aprovados; outro tipo/organização continua negado. Documentos e decisão original permanecem.
|
||||
|
||||
## Revisão, auditoria e operações críticas
|
||||
|
||||
1. Como 25, consultar 15. Esperado: sem edição técnica, aprovação ou publicação de draft. A publicação positiva integral ainda depende do contrato completo; não fabricar estados para encerrar o teste.
|
||||
2. Como 26 → Auditoria → filtrar `superuser_replaced` → exportar JSON. Esperado: metadados da substituição 28→30, sem segredos ou snapshots privados; exportação auditada. Escrita operacional continua negada com outros papéis.
|
||||
3. Como 27/30 → Gestão de perfis. Esperado: dupla 27/30, alertas, pedidos, atribuições e formulários. Como 28, administração negada; como 24, login MFA válido mas papel revogado.
|
||||
4. Para ensaio positivo do Validador, atribuir explicitamente 24 pelo 27/30, com tipos e fundamento. Preservar a revogação anterior. Filas e completude ainda pendentes. Ao terminar, revogar a nova atribuição: sessão antiga perde acesso; nova sessão conserva autenticação, sem o papel.
|
||||
5. Para uma substituição adicional, usar candidato sintético QA com MFA. Preparar → outro titular aprova → aplicar. Esperado: sempre dois titulares, terceiro isolado negado, replay negado e acesso anterior revogado. Não escolher titulares reais nem repetir bootstrap para regressar a 28.
|
||||
6. Elevação exige outro titular, ação/objeto exatos, papel operacional e MFA recente. Não remove impedimentos de autoria/Auditor. Após 15 minutos, decisão administrativa exige novo MFA por logout/login QA; não desativar MFA.
|
||||
7. Recuperação do titular usa código Fortify local, sem captura. Código consumido não autentica. Emergência quando ambos perdem todos os meios permanece uma decisão/procedimento institucional.
|
||||
|
||||
## Registar os resultados na mesma matriz
|
||||
|
||||
Por ação: conta QA, URL, hora UTC, ID, esperado, observado, mensagem visível, status API e campo após nova sessão. Omitir segredos. Associar ao AUD-PROFILE correspondente; falhas adicionais entram nos casos de reprodução. C37 e A/B não são fechados por este roteiro.
|
||||
|
||||
Os cenários A/B mantêm as cinco fases e 19 etapas do roteiro existente: criação → gravação → nova sessão → recuperação → aplicabilidade → ferramenta → resultado no dossier → Balcão → acompanhamento, incluindo tarefa independente em paralelo e ausência de mistura/acesso indevido.
|
||||
|
||||
Relatório e backlog autenticados: http://127.0.0.1:18097/qa-auditoria/relatorio-executivo e http://127.0.0.1:18097/qa-auditoria/backlog. Lista da matriz: `/qa-auditoria/pedidos-abertos`. Acesso do gestor QA 17, Auditor 26 ou Super 27/30 com MFA; Público e Promotor isolado não recebem acesso administrativo.
|
||||
@@ -0,0 +1,263 @@
|
||||
# InvestMoz — contratos e workflow de oportunidades em QA
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Estado atual — instalador, documentos e cartografia
|
||||
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T10:32:41+00:00: 030000, 040000 e 09/190000 ausentes; zero geometrias; manifesto preservado. **C37, Promotor QA21 e A/B permanecem visualmente pendentes. Candidata à ativação ainda não fixada.**
|
||||
|
||||
| Requisitos | Código preparado / prova QA | Critério ainda aberto |
|
||||
| --- | --- | --- |
|
||||
| PROFILE-31 / OPS-01 | GET/POST `/management/profile-install`, bootstrap separado; prévia, dependências, hash, MFA, repetição e auditoria. Instalação vazia revertida/reinstalada; falha de auditoria reverte DDL e ledger, permitindo repetir. Uso/histórico impedem remoção. No servidor já utilizado, `changed=false`; nenhum titular ou privilégio alterado. Promotor, Auditor e organização alheia recebem 403. | Instalação/bootstrap visual e titulares reais designados; recuperação simultânea da dupla depende de D-R1. Backup do schema não recupera MFA perdido. |
|
||||
| GEO-02/03 | **15 novas**: cinco distritos e dez postos; **393 reutilizadas**. Total **408**: 11 províncias, 133 distritos e 264 postos. API do mapa recupera os 15 novos limites. 1.990 cadastros e 361 geometrias originais preservados; sem publicação. | **173 lacunas nomeadas**: 26 distritos e 147 postos; ambiguidades e edições distintas. Seletores/mapas por consumidor continuam com aceitação visual pendente. Localidades/parcelas não inferidas. |
|
||||
| OP-02 / documental | DOCX/XLSX: pacote válido, scan ClamD real, acesso de QA21/24/25 e QA23 negado; exclusão lógica, restauro e original idêntico noutra sessão MFA. | Renderização de Office, exportações/PDFs derivados e restantes consumidores continuam pendentes por operação. |
|
||||
| LAND-01 / PART-01 / SCAN-01 | PDF, PNG e JPEG em fundiário e sala de parceria: seis scans reais com hashes iguais. Autor recupera originais; partilha explícita permite nova sessão; revogação retira acesso/listagem, sem apagar original. Gestor não ganha acesso por esse papel. **91 pedidos** no recibo próprio. | Modelo de impressão recuperado, mas PDF gerado no navegador e interface não demonstrados. Estes seis scans não encerram outros tipos, derivadas ou permissões. |
|
||||
| OP-03 / OP-04 | Código da interface e resposta distinguem aprovação interna, disponibilização restrita e catálogo público. Casos #30 restrito, #33 público e #32 aprovado sem publicação recuperados pela API; consulta da política pública efetiva. | Interface; Response integral/§27 e condições sem regra inequívoca continuam parciais. Testes dos seis contratos não encerram a demonstração visual. |
|
||||
| PROFILE-22/28 | [Quatro perfis e SLA com regra, fundamento, efeito e decisões concretas](propostas-perfis-modulares-sla.md). | Propostas **inativas**; quatro categorias continuam com rascunhos recuperáveis, mas submissão/publicação bloqueadas. Nenhum prazo ou responsável real configurado. |
|
||||
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_104956_test:55441` → **295 tabelas com linhas/hashes iguais e 69 ficheiros fisicamente recuperados por hash**; todos os ficheiros referenciados recuperados por hash. Arquivo privado inclui o runtime e a chave QA estável. Readiness só atesta preparação técnica desta revisão. Repetir após fixar a candidata e preparar backup ativo apenas numa futura ativação autorizada.
|
||||
|
||||
**Matriz única:** 3.386 requisitos/linhas e 6.838 ocorrências de origem; todas as fontes, critérios e versões preservados; sem novos fechos globais. Contagens por estado abaixo e na projeção dos pedidos abertos.
|
||||
|
||||
**Próximo lote executável:** completar Response/§27 reutilizando entidades; verificar exportações e PDFs derivados por consumidor; prosseguir correspondências territoriais por nome, pai e edição; completar países/origem, filtros e estatísticas por formulário. Incorporar provas visuais quando retomadas. Decisões D-M1–M4, D-S1/S2, D-SLA e D-R1 estão individualizadas e não bloqueiam o trabalho independente.
|
||||
|
||||
**Histórico preservado:** os blocos seguintes descrevem revisões anteriores. 393 geometrias/188 lacunas eram o estado antes deste lote; “instalador 040000 em falta” deixou de descrever o código QA. Esses estados anteriores não provam esta revisão nem autorizam instalação ativa.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Resultado atual por categoria — semântica e atribuições em QA, 10/10/2026
|
||||
|
||||
Este bloco conserva a revisão semântica anterior; o estado atual está no início do documento; as secções anteriores abaixo permanecem como histórico. Estados antigos sobre objetos opacos, coordenação editorial e ausência de 190000 ficam confrontados com os resultados concretos deste bloco. **Código preparado e HTTP/persistência em QA não encerram interface nem funcionalidade ativa.** Não houve escrita na base ativa. C37 e aceitação visual permanecem pendentes.
|
||||
|
||||
Servidor **http://127.0.0.1:18097**, base **desk_restore_20261009_174726_test:55441**, Fortify e MFA normais. **Promotor QA 21** continua a conta de ensaio do dashboard. Não usar Auditor ou Superutilizador em seu lugar.
|
||||
|
||||
| Categoria / ID QA | Rascunho | Edição / recuperação | Submissão | Revisão | Publicação |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Investimento — #30 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Imobiliário — #31 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Fundiário/DUAT — #32 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Bloqueada: autorização externa pendente |
|
||||
| Venda de negócio — #33 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Pública, só teaser; prova editorial posterior |
|
||||
| Parceria de negócio — #34 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Franquia — #35 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Concessão — #36 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Financiamento — #37 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Fornecimento — #38 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Outros — #39 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
|
||||
As dez categorias recuperaram dados noutra sessão autenticada. As seis com contrato definido aceitaram os cenários semânticos documentados; as quatro restantes conservaram rascunhos, sem inventar um perfil modular para as submeter. Fundiário #32 separa aprovação interna da decisão externa. Venda #33 passou depois pelo percurso editorial público, sem divulgar resumo/dados privados.
|
||||
|
||||
**Campos e regras preparados:** cronograma com início/fim/responsável e dependências acíclicas; maturidade com os oito códigos normativos; viabilidade com cenário/indicadores/modelo e estudo, sem score de sucesso; serviços/título/entidade emissora/ocupação/avaliação imobiliários; parcelas com editor de vértices/anéis MultiPolygon, benfeitorias e consulta comunitária; constituição/beneficiário efetivo, exercícios existentes, ativos/passivos, obrigações laborais, licenças, NDA, governação e franquia. O contrato é elaborado no servidor e consumido pelo editor, sem templates JSON divergentes no cliente. Objetos legados conservam chaves anteriores. O schema original de 110 definições mantém SHA `9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0`.
|
||||
|
||||
**Falhas corrigidas com prova:** 12 recusas 422 identificaram o campo e conservaram o rascunho inválido: fim anterior ao início, ciclo, falta de cenário/modelo, serviços incompletos, título de outra oportunidade, comunidades sem consulta, financeiros insuficientes e contingências não declaradas. Erros `canonical.<campo>` são devolvidos pela API e ligados aos controlos; a posição/clareza visual ainda não foi observada. O formulário núcleo duplicado foi retirado do modo perfil; gestão legada mantém o seu acesso próprio.
|
||||
|
||||
**Filas e perfis:** revisão, preparação editorial, recurso e tarefas atribuídas reutilizam `tasks`, com ligação explícita ao bigint da oportunidade. #30 pediu parecer a 29: o destinatário errado recebeu403, a aprovação com parecer pendente422; a resposta foi guardada e concluiu a tarefa, sem aprovar o gate automaticamente. #33: coordenador editorial25 atribuiu a29; tarefa recuperada na fila; outro Publicador403; o designado publicou. Papéis/âmbitos/independência/responsabilidades são verificados no servidor; revogação após aprovação bloqueia publicação no teste específico. Responsabilidades atribuídas neste ensaio são sintéticas, sem competência institucional real inferida. SLA permanece `unconfigured` e `due_at=null`.
|
||||
|
||||
**Consumidores territoriais/documentais:** seis contratos recuperaram país MZ e uma localização com limite disponível; API retornou dois limites de contexto. O editor e os campos `location_id` consultam o território mais profundo e mantêm a parcela separada do limite administrativo. País FR do mercado do investimento foi gravado/recuperado; isto não encerra origem/filtros/estatísticas de todos os formulários. Originais dos seis casos permitiram21/24/25 e negaram organização estrangeira23(403). O download/prévia reutiliza `VerifiedDocumentBinary`, que verifica cifra/hash/scan e controla falhas; não altera os originais. #33 recuperou original da imagem e duas derivadas limpas pelo ClamD real. PDF válido de uma página, parser estrito, upload/scan ClamD real e SHA; download 21/24/25, negação 23(403); exclusão lógica→download404/opção removida; restauração→nova sessão→bytes/hash idênticos. Prova `opportunity-semantic-documents-http-qa.json`.
|
||||
|
||||
**Limites concretos:** correspondência entre titular/documento/cadastro e âmbito urbanístico exige evidência e decisão do gate, sem inferir autenticidade jurídica pela existência de um UUID. Limites urbanísticos e materialidade não são inventados. Unidades aplicáveis, Response integral e relações do §27 ainda precisam de reconciliação. O schema original exige pelo menos um exercício financeiro; casos com zero exercícios devem ter regra expressa reconciliada antes de serem declarados cobertos. A moeda obedece ao padrão do contrato; não existe aqui prova de catálogo completo ISO4217. DOCX/XLSX, partilhas e PDFs/exportações/derivadas sem prova específica continuam abertos. C37, A/B e todo o percurso visual por perfil permanecem pendentes.
|
||||
|
||||
**Preservação e compilação:** 18 conjuntos de linhas anteriores por ID/colunas originais preservados; MFA18 intacto. Manifesto ativo `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` preservado. Manifesto QA `0e610cd650d6b788edfdc9cbf697d125cb9330958914e13a8a8bbd14a781b395`; bundle próprio `build-qa-opportunities`, sem recompilar o ativo. TypeScript, PHPStan e testes específicos passaram; as contagens de testes não são usadas como critério de conclusão.
|
||||
|
||||
Mesma matriz: **3.386 requisitos / 3.386 linhas**, **6.838 ocorrências de origem**. IDs, fontes, critérios, versões divergentes e referências anteriores conservados.
|
||||
|
||||
| Estado canónico | Requisitos | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluÃdo e demonstrado | 6 | 7 |
|
||||
|
||||
Linhas principais: **AUD-OP-02/03/04, AUD-GEO-03, AUD-PROFILE-11/12/19/25/28/31 e AUD-OPS-01**, em [matriz-requisitos.csv](matriz-requisitos.csv). Nenhum desses critérios visuais foi fechado por API.
|
||||
|
||||
|
||||
## Próximo trabalho executável e decisões separadas — lote semântico atual
|
||||
|
||||
| Prioridade / requisito | O que já tem prova QA | Falta / próxima ação e critério concreto |
|
||||
| --- | --- | --- |
|
||||
| P0 — UI-01/C37 | Diagnóstico/MFA preservados; ensaios HTTP21 não fecham18. | C37 e confirmação visual continuam pendentes conforme pedido. Quando retomados, dashboard→MFA→sessão→refresh→logout→novo login; rastreio sanitizado e primeiro ponto de perda. Nenhum novo teste visual solicitado agora. |
|
||||
| P1 — OP-04 / PROFILE-28/25 | Seis editores/regras no contrato do servidor; dez drafts e doze rejeições por campo recuperadas. | Observar cada categoria na UI21, erro junto ao controlo e nova sessão; reconciliar unidades aplicáveis, entidade/título/cadastro e limites urbanísticos com gates; completar Response/§27 apenas reutilizando entidades. Não regredir source/enums/IDs ou tornar campos incompletos obrigatórios para guardar. |
|
||||
| P1 — OP-03 / PROFILE-11/12/19 | Filas, parecer atribuído, resposta, revisão e coordenação editorial com task/hash/papéis; negações específicas. | Interface por24/25/29, reatribuição e revogação; incidentes, disponibilidade e revisão periódica. Parecer externo requer responsabilidade institucional concreta, não um ator fictício; aprovação interna não substitui decisão externa. |
|
||||
| P1 — GEO-03 / COUNTRY/STATS | Seis contratos ligam localização/documentos por IDs e país MZ; investimento recupera mercadoFR; editores consultam geometrias no nível selecionado. | Verificar visual de todos os consumidores afetados, país/origem→recuperação→filtro→estatística por formulário. Resolver nomes pendentes do pacote:31distritos+157postos; localidades/parcelas não inferidas de limites administrativos. Reutilizar importação autenticada/idempotente; publicação de camada é separada. |
|
||||
| P1 — OP-02 / LIB / PROFILE-23 | Originais por perfil, negação de organização23, cifragem/hash, PNG e duas derivadas ClamD; PDF do ensaio atual separado. | Continuar DOCX/XLSX, partilhas, PDFs/exportações/derivadas sem prova específica; preservar originals/versões. Consistência de erro404 recurso excluído versus423 scan pendente foi corrigida no consumidor de oportunidades. |
|
||||
| P1 — PROFILE-23 | Dois avisos preexistentes de league/commonmark identificados no lote anterior. | Avaliar consumidores e corrigir compatibilidade emQA; não apagar o achado nem atribuir a Opis. |
|
||||
| P2 — PROFILE-31 / OPS-01 | 190000confirmadaQA/ausenteativo;030/040QA;baseBalcãoativa instalada, hashes atualizados. | Construir instalação/bootstrap autenticados040 com duasidentidades reais explicitamente designadas; não mudar titulares nem retirar privilégio ativo automaticamente. Apósvisual, fixar candidata e renovarbackup/restauro dessa revisão; executar apenas migrações faltantes pelo mecanismo adequado. |
|
||||
|
||||
Decisões independentes, sem bloquear este lote:
|
||||
|
||||
1. **Concessão, financiamento, fornecimento e outros:** quais versões imutáveis de perfil modular, campos obrigatórios, documentos e gates são aprovadas para cada categoria? Sem esse contrato, guardar/editar funciona; submissão/revisão/publicação continuam bloqueadas.
|
||||
2. **SLA:** qual política versionada fixa prazo por tipo de tarefa, tolerância, número de ciclos e escalamento? Quem assume formalmente cada competência de coordenação, parecer técnico/jurídico, publicação e recurso? Manter prazo nulo e atribuição explícita até existir decisão; não usar nomes/prazos de QA como norma.
|
||||
3. **Condições sem regra inequívoca:** qual âmbito/citação estabelece a aplicabilidade de unidades/limites urbanísticos e materialidade? Como reconciliar o mínimo de um exercício do schema com entidades sem exercícios existentes? Não substituir valores ausentes por zero nem alterar silenciosamente a versão aceita.
|
||||
|
||||
Não iniciar jornadas educativas novas. Mantêm-se cinco fases/19 etapas e URLs, dossier privado, oportunidade publicada, fontes oficiais aceites e histórico.
|
||||
|
||||
|
||||
## Ativação reconciliada: 030000, 040000 e **09/190000** — nenhuma execução no ativo
|
||||
|
||||
A omissão de **2026_10_09_190000_prepare_investor_path** na sequência anterior foi um erro documental; a extensão **continua necessária e não foi substituída**. Não confundir com **2026_10_07_190000_create_internal_virtual_desk**, que já está instalada no ativo.
|
||||
|
||||
| Recurso | QA restaurada:55441 | Ativo investemoz:5432, READ ONLY | Evidência |
|
||||
| --- | --- | --- | --- |
|
||||
| Gestão020000 | Instalada | Instalada | Ledger/schema anteriores preservados |
|
||||
| Balcão07/190000 e orientação08/120000 | Instalados | Ambos no ledger | Leitura efetiva das migrações, sem boot de sessão ativa |
|
||||
| Recursos09/030000 | Instalada | Ausente | Tabelas e ledger; não repetirQA |
|
||||
| Perfis10/040000 | Instalada | Ausente | Tabelas/verificaçãoQA; nunca instalada no ativo neste trabalho |
|
||||
| Percurso09/190000 | **Ledger + duas colunas + definição publicada** | **Ledger ausente; duas colunas ausentes; nenhuma definição** | `dashboard-active-readonly.json`, `opportunity-semantic-environment-qa.json` |
|
||||
|
||||
DefiniçãoQA `61000919-0000-4000-8000-000000000001`, publicada, SHA256 **e0acbc723cd3def7e415b2ef7a0173add08387c3cedabac9bb7b86409e4d25bf**. Duas colunas: `investor_journeys.path_definition_id` e `journey_steps.path_detail`. Mantém cinco fases e rastreabilidade das19etapas; não renumera/reescreve os roteiros existentes. Recurso preparado/definição publicada não demonstra A/B na interface.
|
||||
|
||||
Dependências verificadas no código: **030000** usa oportunidades/documentos existentes e cria três coleções de recursos; **040000** acrescenta quatro tabelas de autorização/auditoria, uma coluna de revisão em users e três colunas em opportunities, preservando identidades/histórico; não usa as colunas de190000. **190000** usa investor_journeys/journey_steps/questionnaire_versions e depende da baseBalcão/orientação incluída no instalador. Não existe dependência DDL entre030/040 e190; disponibilizar o workflow requer020+030+040 e tasks/catalogue existentes; disponibilizar o percurso integrado requer190 e as capacidades instaladas/configuradas. Os fingerprints incluem código comum de perfis: alterar o código invalida provas antigas de readiness, sem converter isso numa dependência de schema.
|
||||
|
||||
| Ordem futura | Ação concreta e efeito / contagem esperada | Dependência e reversão |
|
||||
| --- | --- | --- |
|
||||
| 0 | Incorporar visualC37/Promotor21/A-B; corrigir falhas e fixar revisão/manifesta/configuração. Renovar dumpDB+ficheirosoriginaisprivados+chaveestável e restauroverificado numa novaQA. | Candidata ainda não fixada; provas antigas não são backup dessa revisão. Nunca key:generate indiscriminado. |
|
||||
| 1 | Disponibilizar código/Composer/bundle após aprovação de ativação, flagsfalse. | Nesta análise só buildQA foi atualizado; manifestativo preservado. Reversão para código/build anteriores e flagsfalse; assegurar compatibilidade de leitura. |
|
||||
| 2 | **030000**, se ledger/schema realmente ausentes: préviaGET e aplicaçãoPOST autenticada `/management/opportunity-resources-install` com hash/confirm. | Três coleções resource e metadado de cifra; **zero documentos/dados publicados/importados**. Base020/documentos existentes. Downgrade destructive recusado se originais cifrados usados. |
|
||||
| 3 | **040000**, se ausente: instalar quatro tabelas/colunas através de mecanismo autenticado e bootstrap/recuperação da dupla, ainda por implementar paraativo. | Não substituir por migrateglobal/configmanual para contornar guarda. **Zero contas/grants QA** noativo. Titulares/reconciliação de permissões reais precisam de decisão explícita. Código independente de190. |
|
||||
| 4 | **09/190000**, se ausente: préviaGET e aplicaçãoPOST autenticada `/management/balcao-install`; instalador seleciona só migrações faltantes. | BaseBalcão07/190000 e orientação08/120000 jáconstamdoativo: não repetir. **Duas colunas + uma definição publicada**, se não existir equivalente; **zero dossiers/journeys criados automaticamente**. Versões publicadas imutáveis. |
|
||||
| 5 | Configurar catálogos/responsabilidades reais e instalar apenas contratos modulares aprovados; comparar IDs/citações/versões. | A autoridade sintética QA3e1ff315… e atores21–31 não são dados deativação. Sem alteração automática de valores legados. |
|
||||
| 6 | Importação cartográfica autenticada privada `/management/territory-geometry-import`: prévia/correspondências/hash→aplicação idempotente→auditoria. | QA393=11províncias+128distritos+254postos; ativo0naúltimaleitura. **393 candidatas inequívocas**, sujeitas à prévia da revisão e aos mesmos IDs;188pendentes(31/157), sem criar geometrias falsas. Contagem final deve ser medida no alvo. Reversão por lote/metadados e histórico, sem apagar cadastros. |
|
||||
| 7 | Ativar flags de perfis/oportunidades e percurso separadamente, testar navegação completa e permissões. | 030/040 antesdoscontratos/workflow;190antesdodisponibilizarpercurso; C37/visual/A-B não encerrados. Preparação paralela e acesso direto continuam. |
|
||||
| 8 | **Publicar camadas, oportunidades ou documentos como ações separadas e explícitas**. | Nenhuma instalação/importação publica automaticamente; publicação não ativa cálculo fiscal. Usar decisões/evidências/hash atuais e autorização de divulgação. |
|
||||
|
||||
Reversão: desativarflags/voltarbundle compatível e conservar originais/auditoria. Migrações usadas não devem ser eliminadas por rollback genérico;190recusadownquandohápercurso,030recusacomcifrados,040preservaperfil/oportunidade. Quando necessária reversão de dados, usar o restauroverificado da candidata com reconciliação das escritas posteriores. **ready=true é preparação; não instalação, ativação nem aceitação funcional.**
|
||||
|
||||
|
||||
## Reprodução e evidências adicionais — mesmo inventário
|
||||
|
||||
| Caso / requisito | Passos e esperado | Observado / causa | Correção e estado |
|
||||
| --- | --- | --- | --- |
|
||||
| C55 — OP-02 | QA21→PDF limpo→exclusão lógica→GETdownload. Esperado: recurso indisponível, sem atribuir falha de scan. Restaurar e recuperar original. | HTTP423/quarentena porque download juntava row inexistente à condição de scan. | Separa ausência/exclusão404 de quarentena423; teste específico e ensaioPDFweb18097. Originais/hash preservados; visualpendente. Prova da falha `opportunity-semantic-documents-http-qa-attempt1.json`. |
|
||||
| C56 — OP-04/PROFILE-25 | Promotor21 abre editor de categoria; campos de núcleo devem ter um único valor/controlo e erros associados. | Revisão do código: formulário legado e contrato canónico duplicavam título/território/campos, com valores independentes. | Contrato canónico único no modo perfil; formulário legado continua na gestão própria. BuildQA/TS e payloadAPI; observação visualpendente. |
|
||||
| C57 — OP-04/PROFILE-28 | Guardar fim anterior ao início/ciclo/serviços incompletos/documento estrangeiro/comunidade sem consulta/financeiros incompletos; recuperar noutra sessão; submeter. | Regras anteriores aceitavam objetos opacos sem semântica suficiente. | 12negativas422 com caminho de campo; rascunhos recuperados; seis cenários completos submetidos/revistos. Não extrapolar a regras jurídicas ainda sem âmbito/gate. |
|
||||
| C58 — OP-03/PROFILE-19/12 | Parecer com destinatário técnico/jurídico; responder outro ator; aprovar pendente. Depois atribuir publicação e tentar outro Publicador. | Fluxo anterior não tinha destinatário/resposta/fila completa nem atribuição editorial. | #30destinatário29/resposta/tarefa;outro403/aprovação422; #33filaeditorial29/outro25publica403/designado29publica. Sem gateautoaprovado/prazofictício; visualpendente. |
|
||||
| 190000 — OPS-01/PROFILE-31 | Comparar ledger/duascolunas/definição QA eativo, distinguir migraçãoBalcão07 depercurso09. | Plano anterior omitia recurso ainda ausenteativo. | 09/190000confirmadaQA e ausenteativo, necessária/nãosubstituída; seqüência autenticada recolocada. Nenhuma execuçãoativa. |
|
||||
|
||||
|
||||
## Ensaio visual preparado — permanece pendente conforme pedido
|
||||
|
||||
URL QA **http://127.0.0.1:18097/dashboard**. Conta **Promotor QA21**, MFA normal, credenciais/código obtidos apenas localmente. Não incluir cookies, cabeçalhos X-XSRF-TOKEN, segredos ou corpo do código em capturas. C37/conta18 é um ensaio independente e continua pendente.
|
||||
|
||||
1. Entrar no dashboard como21. Esperado: **Minhas oportunidades** e **Nova oportunidade**, ambos funcionais, no contexto autorizado. URLlista `/minhas-oportunidades`; criação `/minhas-oportunidades/create`.
|
||||
2. Abrir cada ID QA30–39 em `/minhas-oportunidades/<id>/view` e, nos estados editáveis, `/edit`. Esperado: estado privado e publicação distintos; núcleo único, categoria correta e permissões aplicadas. #36–39devem continuar drafts.
|
||||
3. Criar rascunho incompleto, guardar; logout/nova sessãoMFA e recuperar. Não usar as fichas publicadas para substituir este passo. Esperado: mesmos dados/ID, sem mistura com outrosprojetos/oportunidades. Se ainda não houver localizações/documentos, guardar primeiro e abrir **Recursos**.
|
||||
4. Nos recursos `/minhas-oportunidades/<id>/resources`, selecionar país e hierarquia; conferir mensagem de território cadastrado, geometria selecionada disponível/ausente e mapa. A parcela declarada em DUAT deve manter geometria própria, sem substituir por limiteadministrativo. Conferir original/prévia antes/depois de recuperar.
|
||||
5. Num rascunho de ensaio das seis categorias, introduzir erro semântico e guardar; submeter. Esperado:422, mensagem junto ao campo, valores preservados; depois corrigir e resubmeter. Concessão/financiamento/fornecimento/outros devem informar perfilmodularpendente.
|
||||
6. Como24, abrir mesma ficha/fila (`/minhas-oportunidades?queue=review`), atribuir/iniciar→solicitarcorreção→21corrige→nova revisão/gates. Registar motivo e documentos da própria versão. Ensaiar parecer por destinatário29: outra resposta negada e tarefa/parecer recuperados sem gateautoaprovado.
|
||||
7. Como25, fila editorial (`?queue=publication`), designar Publicador independente; como29, fila `?queue=assigned`, comparar prévia/hash/teaser e publicar. OutroPublicadornãodesignado deve recebernegação; aprovarprópria/mesmaorganização/múltiplospapéis deve continuar negado. Não atribuirresponsabilidadesreais aos atoresQA.
|
||||
8. Depois de publicar, solicitar revisão/materialalteração; esperado: publicação anterior preservada até nova revisão. Fundiário32não pode contornar autorizaçãoexternapendente. Conferir suspensão e recuperação dos motivos/tarefas.
|
||||
|
||||
Registar por ação URL/ator/ID/estado/API/persistência/resultado visível; associar a mesma matriz. A/B completos e cincofases/19etapas continuam no roteiro existente, sem renumerar. Não fechar critérios por leitura dos dados ou totaldetestes.
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
Atualizado em **2026-10-10T08:52:56+00:00**. Incremento da mesma auditoria e matriz; não cria inventário paralelo. Este relatório discrimina **código preparado**, **HTTP/persistência demonstrados em QA** e **funcionalidade ativa**.
|
||||
|
||||
## Incremento canónico e workflow em QA — 10/10/2026
|
||||
|
||||
Servidor **http://127.0.0.1:18097**; destino guardado **desk_restore_20261009_174726_test:55441**. Zero escritas na base ativa. **C37 e toda a aceitação visual deste incremento permanecem pendentes.** Props da interface, API e persistência foram verificadas; nenhum clique ou renderização no navegador foi observado.
|
||||
|
||||
| Requisito na matriz única | Funciona / prova concreta em QA | Falta para fechar |
|
||||
| --- | --- | --- |
|
||||
| AUD-OP-04; AUD-PROFILE-28 | Dez categorias guardam rascunhos incompletos e recuperam noutra sessão MFA. Seis passam o contrato estrutural: investimento, imobiliário, DUAT, venda, parceria e franquia. Adapter do mesmo schema de 110 definições; condições `allOf` originais recuperadas, IDs existentes mantidos. | Semântica e editores dos objetos incompletos do §25; contrato Response integral; quatro perfis modulares aprovados; demonstração visual por categoria. |
|
||||
| AUD-OP-03; PROFILE-08/11/12 | Promotor 21 criou a oportunidade **18**, submeteu; Validador 24 solicitou correção; 21 corrigiu/resubmeteu; 24 reviu gates e aprovou; Publicador 25 publicou com visibilidade interna. O número 18 identifica a oportunidade, não a conta do investidor C37. | Visual integral, todas as categorias, pareceres/incidentes e coordenação editorial completa. |
|
||||
| PROFILE-09/10/12/21 | Imobiliário **19**: rejeição fundamentada por 24 → recurso reaberto pelo revisor independente 29 → nova aprovação 24 → publicação pública 25 → alteração privada sem alterar a ficha pública → suspensão com GET público 404. Autor Prom+Val+Pub e aprovador Val+Pub receberam 403. | Visual, filas institucionais e competências reais; suspensão/retoma e demais ações editoriais por todos os perfis. |
|
||||
| PROFILE-19/21/22 | Tarefas existentes em `tasks`, vínculo à oportunidade por origem/ID/correlação; atribuição explícita, motivos e decisões recuperáveis. Correção/rejeição concluem revisão; resubmissão não herda atribuição; suspensão conclui tarefas pendentes. Recurso do próprio rejeitor e membro da organização negados. SLA `unconfigured`, responsável e prazo iniciais nulos. | Filas completas, reatribuição/disponibilidade, coordenação editorial e política SLA aprovada. Não foram inventados responsáveis nem prazos. |
|
||||
| COUNTRY-01; GEO-03; OP-02 | Localização MZ/Cabo Delgado/distrito e documentos ligados ao mesmo contrato recuperados nas seis categorias. Oportunidade **28** publicou usando a localização principal mesmo em segundo lugar na lista. Documentos e imagem/duas derivadas verificados com ClamD real e hashes correspondentes. DUAT **20** aprovado internamente não publicou: 422 por autorização externa pendente. | Outros países/formulários, origem/filtros/estatísticas transversais, mapas renderizados, 188 correspondências cartográficas e operações documentais restantes. |
|
||||
| PROFILE-30; DASH-01 | Conta **Promotor QA — conta sintética 21**, MFA normal confirmado, organização e tipos autorizados. Dashboard devolve navegação do Promotor. Permissões temporárias dos testes foram revogadas e uma nova sessão 21 recuperou o dashboard. | Dashboard → formulário → guardar → nova sessão → workflow e resultado visíveis. Auditor ou Superutilizador não substituem este ensaio. |
|
||||
|
||||
Os requisitos acima continuam **parciais**: nenhum passou a concluído pelo incremento. O detalhe está no [relatório do workflow](workflow-oportunidades-qa.md); fontes, versões, critérios e percursos permanecem na [mesma matriz](matriz-requisitos.csv).
|
||||
|
||||
| Estado canónico | Requisitos / linhas da matriz | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 6 | 7 |
|
||||
|
||||
Total: **3.386 requisitos = 3.386 linhas**, **6.838 ocorrências**, **3.380 requisitos abertos**. As referências e versões anteriores foram preservadas.
|
||||
|
||||
|
||||
## Provas reproduzíveis e limites
|
||||
|
||||
| Prova privada local (sem credenciais) | Percurso / camada |
|
||||
| --- | --- |
|
||||
| `opportunity-workflow-http-qa.json` | 288 pedidos ao servidor 18097, Fortify/MFA normal; dez categorias, gravação/recuperação noutra sessão, ciclo investimento18, correção/gates/aprovação/publicação interna; rejeição/negativas iniciais. |
|
||||
| `opportunity-workflow-extended-http-qa.json` | 91 pedidos; recurso29, imobiliário19 público com ClamD/derivadas, confirmação/hash, proposta privada preserva público, suspensão404; fundiário20 autorizado internamente mas publicação422. |
|
||||
| `opportunity-workflow-multirole-http-qa.json` | 45 pedidos; autor Prom+Val+Pub403, aprovador Val+Pub403, Auditor acumulado403, três atribuições temporárias revogadas; nova sessão Promotor21 normal. |
|
||||
| `opportunity-workflow-territory-tasks-http-qa.json` | 95 pedidos; oportunidade28 localiza principal em segundo lugar, conclui revisão por correção, resubmete sem atribuição herdada e suspende sem tarefas abertas; oportunidade29 conclui tarefa com rejeição. |
|
||||
| `opportunity-workflow-verification-qa.json` | Releitura atual do contrato e referências: seis passam, quatro sem perfil; país/localização e hashes documentais. O total territorial inclui fixture QA isolada e não prova cobertura real. |
|
||||
| `opportunity-workflow-final-tests-qa.xml` | 19 testes / 426 asserções, zero erros/falhas; testes específicos de estados, binding estrangeiro, integridade de bytes, draft/null, filtros privados e independência. Fixtures de política não são prova de ClamD real. |
|
||||
| `profiles-v1-preservation-qa.json` | Registos anteriores de 18 conjuntos conservados por ID/colunas originais; MFA18 e build ativo preservados. Captura 2026-10-10T08:51:35+00:00. |
|
||||
|
||||
PHPStan dos componentes alterados, TypeScript e compilação QA passaram. Manifesto QA **`984244d5dd9ece719d5c2d2ab852a6019aae8f728dab654c5a8bd6e75adec5a8`**; ativo **`75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94`**. Nenhuma página foi renderizada/clicada no navegador deste ensaio. A extensão de fonte `source.json` mantém flags false como fotografia histórica da extração, não como configuração do runtime atual.
|
||||
|
||||
## Compatibilidade e contratos restantes
|
||||
|
||||
Fonte integral: especificação do cadastro, SHA256 `9f173ed3024d2beddef7888b692d8b81fc5e4700249955f2bae9bdd2eb2be504`; §25 campos/semântica, §26 schema, §27 gates. Artefacto normativo de 110 definições preservado, SHA256 `9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0`. O adapter distingue entrada parcial e submissão, liga documentos/localizações/imagens pelos IDs existentes e valida relações/scan/bytes. Snapshots, decisões, motivos e hashes usam `management_draft`, `workflow_context` e auditoria existentes.
|
||||
|
||||
IDs bigint de oportunidades/sectors não foram convertidos aos UUID de exemplos; não foram criados cadastros paralelos. Tarefas reutilizam `tasks`; a sua coluna `opportunity_id` aponta a oportunidades CRM UUID e não recebe o bigint do portal: a relação é explícita por source_system/external_id/correlation_id. Contactos e subsector/atividade reutilizam `contacts`, `investors`, `investor_contacts`, `economic_activities` e versões existentes. As referências acrescentadas para o ensaio são marcadas QA-synthetic, não oficiais, e não devem ser importadas no ativo.
|
||||
|
||||
Este incremento **não implementa integralmente** os objetos normativos descritos apenas como object, as relações SQL adicionais do §27 nem o Response integral. Validação estrutural aceita parte desses objetos sem detalhamento: exige continuação semântica antes de ativar os contratos. Aprovação interna é uma decisão identificada de QA; não confirma atos externos, autenticidade ou situação jurídica. DUAT pendente foi recusado na publicação mesmo após todos os gates internos.
|
||||
|
||||
O rascunho admite ausência, vazio e null nos campos ainda não preenchidos, preservando as restrições de valores fornecidos e impedindo campos readonly/verified do cliente. Contratos de concessão/financiamento/fornecimento/outros ficam em rascunho até existir perfil versionado aprovado e imutável; não foi fabricada configuração para os submeter.
|
||||
|
||||
## Ensaio manual atual — Promotor QA e workflow
|
||||
|
||||
**URL inicial exato: http://127.0.0.1:18097/dashboard. Conta: Promotor QA — conta sintética 21.** Se necessário, o login normal é `/login`, com MFA Fortify. Credenciais e códigos permanecem exclusivamente no ficheiro privado local; não os copiar para a conversa. Conferir identificação QA/porta 18097; não usar `portal-investidor.test`.
|
||||
|
||||
1. Entrar como Promotor 21, concluir MFA e atualizar dashboard. Esperado: “Minhas oportunidades” e “Nova oportunidade”, sessão mantida. Um retorno a login entra como reprodução pendente, não como sucesso.
|
||||
2. Pelo dashboard, clicar “Nova oportunidade”, escolher organização QA representada e categoria imobiliária; criar e guardar **rascunho incompleto**, incluindo um campo opcional limpo. Esperado: ID, estado rascunho e ausência de bloqueio por campos ainda não preenchidos.
|
||||
3. Anotar o ID sem copiar informação privada; logout, nova sessão/MFA, dashboard → “Minhas oportunidades” → mesmo ID. Esperado: recuperação exata, organização/autor preservados. Submeter incompleto deve mostrar campos pendentes e manter o rascunho.
|
||||
4. Abrir recursos pelo formulário: MZ → Cabo Delgado → distrito válido → principal; guardar/reabrir. Esperado: mesmos IDs/nome, filhos coerentes; mapa administrativo quando disponível. Não usar limite administrativo como geometria de parcela. Carregar documento sintético permitido; confirmar scan/recuperação e vínculo ao mesmo registo.
|
||||
5. Completar núcleo e módulo imobiliário com dados explicitamente sintéticos. O catálogo de ensaio possui sector/subsector/atividade QA; contacto é da organização QA. Usar `sale`, `warehouse`, `completed`, condição `good`, áreas positivas, moeda/preço positivo, ocupação/título/ónus/avaliação e licença aplicável; documento e localização selecionados no contrato. Preencher resumo/pedido/evidências e declaração de verdade. Para este primeiro ciclo usar visibilidade **internal**. Os editores de objetos/grelhas ainda incompletos devem ser registados como falhas: não contornar pela consola nem fingir visual concluída.
|
||||
6. Guardar com motivo → submeter. Esperado: `/view`, estado submetido, formulário bloqueado, snapshot/versionamento e tarefa de revisão sem responsável/prazo inventado. A rota `/edit` fechada não deve ser usada para continuar revisão.
|
||||
7. Noutra sessão QA entrar como **Validador 24**; abrir o mesmo ID autorizado, atribuir/iniciar revisão, solicitar correção com motivo e campo identificado. Esperado: necessidades de informação, motivo recuperável e tarefa de correção.
|
||||
8. Voltar como 21: abrir mesma oportunidade, corrigir, guardar com motivo e resubmeter. Logout/nova sessão deve recuperar a versão corrigida sem perder a decisão anterior.
|
||||
9. Como 24: iniciar a revisão dessa versão, registar os gates aplicáveis com decisão/evidência/fundamento; aprovar. Esperado: versão/hash aprovados, aprovação prematura recusada.
|
||||
10. Como **Publicador 25**: abrir `/view`, rever prévia, confirmar âmbito e publicar. Esperado: publicado interno, sem exposição na API pública. Para o ensaio público separado, preparar capa, legenda/alt/direitos e duas derivadas analisadas; confirmar prévia/hash e publicar explicitamente.
|
||||
11. Como 21, solicitar alteração material com fundamento, editar a proposta privada. Esperado: nova revisão sem aprovação reutilizável; ficha pública anterior permanece intacta. Como 25 suspender com motivo; esperado GET público 404 e história preservada.
|
||||
12. Em registo separado, como 24 rejeitar com fundamento; como 21 pedir recurso; como **revisor independente 29** reabrir. Esperado: rejeição original conservada. Rejeitor e membro da organização devem ser recusados. Conta 31 com papéis acumulados/Auditor continua sem escrita. Não atribuir responsabilidades reais às contas sintéticas.
|
||||
|
||||
Recolher por passo apenas hora UTC, perfil/ID, URL sem tokens, ação, resultado visível e referência do registo. Omitir Cookie, X-XSRF-TOKEN, passwords, segredo/código MFA e documentos privados de capturas. C37/investidor 18 e cenários A/B continuam no roteiro existente, separados deste ensaio. Sucesso HTTP abaixo não certifica nenhum dos passos visuais.
|
||||
|
||||
|
||||
## Próximo trabalho executável — incremento de 10/10/2026
|
||||
|
||||
| Prioridade / requisito | Correção concreta seguinte | Critério de fecho e dependência |
|
||||
| --- | --- | --- |
|
||||
| P0 — UI-01/C37; DASH-01/C43 | Incorporar ensaio visual com Promotor 21 e, separadamente, investidor 18; registar falhas com URL, ação, estado e resposta sanitizada. | Login/MFA → dashboard → guardar → logout → nova sessão → recuperar, sem loop, perda ou acesso indevido; não substituível por HTTP. |
|
||||
| P1 — OP-04; PROFILE-28 | Completar editores e validações semânticas §25: grelhas de serviços/ónus, avaliação/documento, ocupação/título/cadastro, comunidades/consulta/benfeitorias, financeiros/beneficiário efetivo, NDA, governação e franquia; completar Response. Reutilizar contrato e entidades atuais. | Rascunho parcial recuperável; cada condição aplicável recusa dados inválidos e aceita dados/evidências completos; interface por categoria. Confirmar correspondências de objetos opacos do schema, sem alterar os significados legados silenciosamente. |
|
||||
| P1 — OP-03; PROFILE-11/12/19/21 | Completar filas/revisões e editoriais, reatribuição, pareceres, incidentes e recurso; manter hash da versão e independência. | Atores distintos e responsáveis explicitamente designados, motivos/evidência e histórico; revogação reavaliada. Ciclo principal já demonstrado por HTTP, não repetir o inventário. |
|
||||
| P1 — COUNTRY/STATS/GEO | Completar cobertura por formulário/consumidor, mapas e nomes cartográficos pendentes. | País/origem persistidos → recuperação → filtros/estatísticas; território cadastrado ≠ geometria ≠ mapa apresentado. 393 limites privados não encerram integração visual; faltam 31 distritos e 157 postos. |
|
||||
| P1 — OP-02/LIB/PROFILE-23 | Verificar tipos/partilhas/PDFs e derivadas ainda sem prova específica e autorizações do original. | ClamD real separado por operação; hashes/originais preservados; acesso permitido/negado e resultado visível. PNG/documentos do ciclo e duas derivadas já demonstrados. |
|
||||
| P1 — PROFILE-23 (achado adicional) | Composer audit apontou dois avisos preexistentes em `league/commonmark`: GHSA-97jj-33gv-5xf9 e GHSA-3q6v-r5mr-hxv8. Identificar consumidores/configuração e preparar correção compatível em QA. | Prova local `workflow-composer-audit.json`; avaliar aplicação dos avisos e validar os consumidores afetados. Não atribuir aos três pacotes Opis nem declarar exploração demonstrada. |
|
||||
| P2 — PROFILE-22/31 | Configuração SLA versionada, instalaçao autenticada de perfis/dupla e preparação de ativação. | Decisões reais explícitas; ensaio backup/restauro da candidata posterior à visual; nenhum prazo ou titular real escolhido por QA. |
|
||||
|
||||
**Decisões concretas:** (1) quais versões imutáveis de perfil, campos, documentos e gates são aprovadas para concessão, financiamento, fornecimento e outros? (2) qual política versionada fixa prazos, tolerância, ciclos e escalamento? (3) quem assume formalmente as responsabilidades institucionais e os exatamente dois superutilizadores ativos? Não bloqueiam as correções independentes, nem foram inferidas dos atores sintéticos.
|
||||
|
||||
| Grupo dos pedidos abertos (projeção da mesma matriz) | Requisitos |
|
||||
| --- | ---: |
|
||||
| Parciais | 3187 |
|
||||
| Não implementados | 145 |
|
||||
| Preparados em QA | 46 |
|
||||
| Instalados com falha de acesso | 2 |
|
||||
|
||||
Lista integral: **http://127.0.0.1:18097/qa-auditoria/pedidos-abertos**, sob MFA de gestor/Auditor/Superutilizador QA. Cada cartão conserva o estado canónico, fontes, lacuna e próxima ação. “Preparado em QA” indica um escopo provado, não conclusão total.
|
||||
|
||||
|
||||
## Sequência reconciliada de disponibilização futura — não executada
|
||||
|
||||
Leitura ativa de 10/10: PostgreSQL `investemoz:5432`, transação READ ONLY, seis oportunidades publicadas; 020000 instalada, 030000/040000/percurso ausentes, flags desativadas, zero geometrias. Build ativo preservado. A revisão candidata **não está fixada**; `ready=true` e fingerprints anteriores não autorizam nem demonstram instalação.
|
||||
|
||||
| Ordem | Ação e efeito previsto | Dependência / reversão |
|
||||
| --- | --- | --- |
|
||||
| 0 | Incorporar visual C37/C43/A/B e workflow, corrigir falhas; fixar revisão, configuração e manifestos. Renovar backup DB + ficheiros privados/originais + chave estável e provar restauro numa nova QA isolada. | Provas anteriores preservadas, sem as apresentar como restauro desta revisão. Não gerar nova chave. |
|
||||
| 1 | Disponibilizar código e dependências compatíveis, flags desligadas. Composer adiciona `opis/json-schema 2.4.1`, `opis/string 2.1.0`, `opis/uri 1.1.0`; zero atualizações/remoções preexistentes. | Não importar contas, classificações, contactos ou documentos sintéticos QA. Reverter código/assets compatíveis, preservando payloads/histórico. |
|
||||
| 2 | Não repetir 020000. Se 030000 continuar ausente, usar prévia/aplicação autenticada `/management/opportunity-resources-install`: três tabelas, uma coluna documental, uma função e três triggers previstos. | Backup/restauro confirmado; hashes/contagens verificados no estado real; não publica nem habilita o percurso. |
|
||||
| 3 | Preparar e testar o mecanismo autenticado 040000: quatro tabelas, uma coluna de utilizador, três de oportunidade, uma função e dois triggers previstos; dupla inicial/substituição independente. | Aplicação atual demonstrada exclusivamente QA. Instalação/bootstrap ativo autenticado ainda pendente; titulares reais não definidos. Não remover privilégios ativos automaticamente. |
|
||||
| 4 | Configurar referências/contactos/hierarquia, perfis/tipos/representação, responsabilidades e MFA; quatro contratos modulares apenas após aprovação da versão. | Reutilizar dados mestres reais. As referências sintéticas de ensaio não resolvem cadastros ativos vazios. SLA permanece indeterminado até política aprovada. |
|
||||
| 5 | Importação cartográfica autenticada, privada e idempotente; se ativo permanecer zero e correspondências iguais, até **393 = 11 províncias + 128 distritos + 254 postos**. Esperados 581 = 11/159/411; pendentes 188 = 0/31/157, nomes no relatório de correspondências. | Prévia → correspondências inequívocas → scan/hash → aplicar → auditoria; nenhuma localização/conta/perímetro de projeto inventado. Reversão pelo lote preserva origem/uso/histórico. **Publicação de camadas é ação separada.** |
|
||||
| 6 | Disponibilizar flags/assets/navegação aprovados e o percurso I1–I3 pela instalação existente, sem repetir ações já presentes. Publicar cada oportunidade separadamente, com aprovação/hash/prévia/âmbito. | Cinco fases/19 etapas preservadas, dossier privado separado. Voltar flags a false limita disponibilização; manter guardas de confidencialidade para novos dados. Não executar down destrutivo após uso. |
|
||||
|
||||
Após uso, reverter por configuração compatível, suspensão/arquivo autorizado ou compensação auditada; não eliminar decisões, documentos, consentimentos, relações ou originais. Restauro global exige reconciliar alterações posteriores ao backup. Este plano não contém aprovação de ativação nem declaração de instalação ativa.
|
||||
|
||||
@@ -1,5 +1,166 @@
|
||||
# Gestão de Oportunidades integrada no Dashboard
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Estado atual — instalador, documentos e cartografia
|
||||
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T10:32:41+00:00: 030000, 040000 e 09/190000 ausentes; zero geometrias; manifesto preservado. **C37, Promotor QA21 e A/B permanecem visualmente pendentes. Candidata à ativação ainda não fixada.**
|
||||
|
||||
| Requisitos | Código preparado / prova QA | Critério ainda aberto |
|
||||
| --- | --- | --- |
|
||||
| PROFILE-31 / OPS-01 | GET/POST `/management/profile-install`, bootstrap separado; prévia, dependências, hash, MFA, repetição e auditoria. Instalação vazia revertida/reinstalada; falha de auditoria reverte DDL e ledger, permitindo repetir. Uso/histórico impedem remoção. No servidor já utilizado, `changed=false`; nenhum titular ou privilégio alterado. Promotor, Auditor e organização alheia recebem 403. | Instalação/bootstrap visual e titulares reais designados; recuperação simultânea da dupla depende de D-R1. Backup do schema não recupera MFA perdido. |
|
||||
| GEO-02/03 | **15 novas**: cinco distritos e dez postos; **393 reutilizadas**. Total **408**: 11 províncias, 133 distritos e 264 postos. API do mapa recupera os 15 novos limites. 1.990 cadastros e 361 geometrias originais preservados; sem publicação. | **173 lacunas nomeadas**: 26 distritos e 147 postos; ambiguidades e edições distintas. Seletores/mapas por consumidor continuam com aceitação visual pendente. Localidades/parcelas não inferidas. |
|
||||
| OP-02 / documental | DOCX/XLSX: pacote válido, scan ClamD real, acesso de QA21/24/25 e QA23 negado; exclusão lógica, restauro e original idêntico noutra sessão MFA. | Renderização de Office, exportações/PDFs derivados e restantes consumidores continuam pendentes por operação. |
|
||||
| LAND-01 / PART-01 / SCAN-01 | PDF, PNG e JPEG em fundiário e sala de parceria: seis scans reais com hashes iguais. Autor recupera originais; partilha explícita permite nova sessão; revogação retira acesso/listagem, sem apagar original. Gestor não ganha acesso por esse papel. **91 pedidos** no recibo próprio. | Modelo de impressão recuperado, mas PDF gerado no navegador e interface não demonstrados. Estes seis scans não encerram outros tipos, derivadas ou permissões. |
|
||||
| OP-03 / OP-04 | Código da interface e resposta distinguem aprovação interna, disponibilização restrita e catálogo público. Casos #30 restrito, #33 público e #32 aprovado sem publicação recuperados pela API; consulta da política pública efetiva. | Interface; Response integral/§27 e condições sem regra inequívoca continuam parciais. Testes dos seis contratos não encerram a demonstração visual. |
|
||||
| PROFILE-22/28 | [Quatro perfis e SLA com regra, fundamento, efeito e decisões concretas](auditoria-portal/propostas-perfis-modulares-sla.md). | Propostas **inativas**; quatro categorias continuam com rascunhos recuperáveis, mas submissão/publicação bloqueadas. Nenhum prazo ou responsável real configurado. |
|
||||
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_104956_test:55441` → **295 tabelas com linhas/hashes iguais e 69 ficheiros fisicamente recuperados por hash**; todos os ficheiros referenciados recuperados por hash. Arquivo privado inclui o runtime e a chave QA estável. Readiness só atesta preparação técnica desta revisão. Repetir após fixar a candidata e preparar backup ativo apenas numa futura ativação autorizada.
|
||||
|
||||
**Matriz única:** 3.386 requisitos/linhas e 6.838 ocorrências de origem; todas as fontes, critérios e versões preservados; sem novos fechos globais. Contagens por estado abaixo e na projeção dos pedidos abertos.
|
||||
|
||||
**Próximo lote executável:** completar Response/§27 reutilizando entidades; verificar exportações e PDFs derivados por consumidor; prosseguir correspondências territoriais por nome, pai e edição; completar países/origem, filtros e estatísticas por formulário. Incorporar provas visuais quando retomadas. Decisões D-M1–M4, D-S1/S2, D-SLA e D-R1 estão individualizadas e não bloqueiam o trabalho independente.
|
||||
|
||||
**Histórico preservado:** os blocos seguintes descrevem revisões anteriores. 393 geometrias/188 lacunas eram o estado antes deste lote; “instalador 040000 em falta” deixou de descrever o código QA. Esses estados anteriores não provam esta revisão nem autorizam instalação ativa.
|
||||
<!-- qa-installation-cartography-20261010:end -->
|
||||
|
||||
<!-- qa-semantic-20261010:begin -->
|
||||
## Ensaio visual preparado — permanece pendente conforme pedido
|
||||
|
||||
URL QA **http://127.0.0.1:18097/dashboard**. Conta **Promotor QA21**, MFA normal, credenciais/código obtidos apenas localmente. Não incluir cookies, cabeçalhos X-XSRF-TOKEN, segredos ou corpo do código em capturas. C37/conta18 é um ensaio independente e continua pendente.
|
||||
|
||||
1. Entrar no dashboard como21. Esperado: **Minhas oportunidades** e **Nova oportunidade**, ambos funcionais, no contexto autorizado. URLlista `/minhas-oportunidades`; criação `/minhas-oportunidades/create`.
|
||||
2. Abrir cada ID QA30–39 em `/minhas-oportunidades/<id>/view` e, nos estados editáveis, `/edit`. Esperado: estado privado e publicação distintos; núcleo único, categoria correta e permissões aplicadas. #36–39devem continuar drafts.
|
||||
3. Criar rascunho incompleto, guardar; logout/nova sessãoMFA e recuperar. Não usar as fichas publicadas para substituir este passo. Esperado: mesmos dados/ID, sem mistura com outrosprojetos/oportunidades. Se ainda não houver localizações/documentos, guardar primeiro e abrir **Recursos**.
|
||||
4. Nos recursos `/minhas-oportunidades/<id>/resources`, selecionar país e hierarquia; conferir mensagem de território cadastrado, geometria selecionada disponível/ausente e mapa. A parcela declarada em DUAT deve manter geometria própria, sem substituir por limiteadministrativo. Conferir original/prévia antes/depois de recuperar.
|
||||
5. Num rascunho de ensaio das seis categorias, introduzir erro semântico e guardar; submeter. Esperado:422, mensagem junto ao campo, valores preservados; depois corrigir e resubmeter. Concessão/financiamento/fornecimento/outros devem informar perfilmodularpendente.
|
||||
6. Como24, abrir mesma ficha/fila (`/minhas-oportunidades?queue=review`), atribuir/iniciar→solicitarcorreção→21corrige→nova revisão/gates. Registar motivo e documentos da própria versão. Ensaiar parecer por destinatário29: outra resposta negada e tarefa/parecer recuperados sem gateautoaprovado.
|
||||
7. Como25, fila editorial (`?queue=publication`), designar Publicador independente; como29, fila `?queue=assigned`, comparar prévia/hash/teaser e publicar. OutroPublicadornãodesignado deve recebernegação; aprovarprópria/mesmaorganização/múltiplospapéis deve continuar negado. Não atribuirresponsabilidadesreais aos atoresQA.
|
||||
8. Depois de publicar, solicitar revisão/materialalteração; esperado: publicação anterior preservada até nova revisão. Fundiário32não pode contornar autorizaçãoexternapendente. Conferir suspensão e recuperação dos motivos/tarefas.
|
||||
|
||||
Registar por ação URL/ator/ID/estado/API/persistência/resultado visível; associar a mesma matriz. A/B completos e cincofases/19etapas continuam no roteiro existente, sem renumerar. Não fechar critérios por leitura dos dados ou totaldetestes.
|
||||
|
||||
|
||||
## Resultado atual por categoria — semântica e atribuições em QA, 10/10/2026
|
||||
|
||||
Este bloco conserva a revisão semântica anterior; o estado atual está no início do documento; as secções anteriores abaixo permanecem como histórico. Estados antigos sobre objetos opacos, coordenação editorial e ausência de 190000 ficam confrontados com os resultados concretos deste bloco. **Código preparado e HTTP/persistência em QA não encerram interface nem funcionalidade ativa.** Não houve escrita na base ativa. C37 e aceitação visual permanecem pendentes.
|
||||
|
||||
Servidor **http://127.0.0.1:18097**, base **desk_restore_20261009_174726_test:55441**, Fortify e MFA normais. **Promotor QA 21** continua a conta de ensaio do dashboard. Não usar Auditor ou Superutilizador em seu lugar.
|
||||
|
||||
| Categoria / ID QA | Rascunho | Edição / recuperação | Submissão | Revisão | Publicação |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Investimento — #30 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Imobiliário — #31 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Fundiário/DUAT — #32 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Bloqueada: autorização externa pendente |
|
||||
| Venda de negócio — #33 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Pública, só teaser; prova editorial posterior |
|
||||
| Parceria de negócio — #34 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Franquia — #35 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | Aceite | Gates e aprovação interna | Interna; sem exposição pública |
|
||||
| Concessão — #36 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Financiamento — #37 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Fornecimento — #38 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
| Outros — #39 | Incompleto guardado e recuperado | Edição gravada, nova sessão MFA | 422: perfil modular por aprovar | Não elegível | Bloqueada |
|
||||
|
||||
As dez categorias recuperaram dados noutra sessão autenticada. As seis com contrato definido aceitaram os cenários semânticos documentados; as quatro restantes conservaram rascunhos, sem inventar um perfil modular para as submeter. Fundiário #32 separa aprovação interna da decisão externa. Venda #33 passou depois pelo percurso editorial público, sem divulgar resumo/dados privados.
|
||||
|
||||
**Campos e regras preparados:** cronograma com início/fim/responsável e dependências acíclicas; maturidade com os oito códigos normativos; viabilidade com cenário/indicadores/modelo e estudo, sem score de sucesso; serviços/título/entidade emissora/ocupação/avaliação imobiliários; parcelas com editor de vértices/anéis MultiPolygon, benfeitorias e consulta comunitária; constituição/beneficiário efetivo, exercícios existentes, ativos/passivos, obrigações laborais, licenças, NDA, governação e franquia. O contrato é elaborado no servidor e consumido pelo editor, sem templates JSON divergentes no cliente. Objetos legados conservam chaves anteriores. O schema original de 110 definições mantém SHA `9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0`.
|
||||
|
||||
**Falhas corrigidas com prova:** 12 recusas 422 identificaram o campo e conservaram o rascunho inválido: fim anterior ao início, ciclo, falta de cenário/modelo, serviços incompletos, título de outra oportunidade, comunidades sem consulta, financeiros insuficientes e contingências não declaradas. Erros `canonical.<campo>` são devolvidos pela API e ligados aos controlos; a posição/clareza visual ainda não foi observada. O formulário núcleo duplicado foi retirado do modo perfil; gestão legada mantém o seu acesso próprio.
|
||||
|
||||
**Filas e perfis:** revisão, preparação editorial, recurso e tarefas atribuídas reutilizam `tasks`, com ligação explícita ao bigint da oportunidade. #30 pediu parecer a 29: o destinatário errado recebeu403, a aprovação com parecer pendente422; a resposta foi guardada e concluiu a tarefa, sem aprovar o gate automaticamente. #33: coordenador editorial25 atribuiu a29; tarefa recuperada na fila; outro Publicador403; o designado publicou. Papéis/âmbitos/independência/responsabilidades são verificados no servidor; revogação após aprovação bloqueia publicação no teste específico. Responsabilidades atribuídas neste ensaio são sintéticas, sem competência institucional real inferida. SLA permanece `unconfigured` e `due_at=null`.
|
||||
|
||||
**Consumidores territoriais/documentais:** seis contratos recuperaram país MZ e uma localização com limite disponível; API retornou dois limites de contexto. O editor e os campos `location_id` consultam o território mais profundo e mantêm a parcela separada do limite administrativo. País FR do mercado do investimento foi gravado/recuperado; isto não encerra origem/filtros/estatísticas de todos os formulários. Originais dos seis casos permitiram21/24/25 e negaram organização estrangeira23(403). O download/prévia reutiliza `VerifiedDocumentBinary`, que verifica cifra/hash/scan e controla falhas; não altera os originais. #33 recuperou original da imagem e duas derivadas limpas pelo ClamD real. PDF válido de uma página, parser estrito, upload/scan ClamD real e SHA; download 21/24/25, negação 23(403); exclusão lógica→download404/opção removida; restauração→nova sessão→bytes/hash idênticos. Prova `opportunity-semantic-documents-http-qa.json`.
|
||||
|
||||
**Limites concretos:** correspondência entre titular/documento/cadastro e âmbito urbanístico exige evidência e decisão do gate, sem inferir autenticidade jurídica pela existência de um UUID. Limites urbanísticos e materialidade não são inventados. Unidades aplicáveis, Response integral e relações do §27 ainda precisam de reconciliação. O schema original exige pelo menos um exercício financeiro; casos com zero exercícios devem ter regra expressa reconciliada antes de serem declarados cobertos. A moeda obedece ao padrão do contrato; não existe aqui prova de catálogo completo ISO4217. DOCX/XLSX, partilhas e PDFs/exportações/derivadas sem prova específica continuam abertos. C37, A/B e todo o percurso visual por perfil permanecem pendentes.
|
||||
|
||||
**Preservação e compilação:** 18 conjuntos de linhas anteriores por ID/colunas originais preservados; MFA18 intacto. Manifesto ativo `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` preservado. Manifesto QA `0e610cd650d6b788edfdc9cbf697d125cb9330958914e13a8a8bbd14a781b395`; bundle próprio `build-qa-opportunities`, sem recompilar o ativo. TypeScript, PHPStan e testes específicos passaram; as contagens de testes não são usadas como critério de conclusão.
|
||||
|
||||
Mesma matriz: **3.386 requisitos / 3.386 linhas**, **6.838 ocorrências de origem**. IDs, fontes, critérios, versões divergentes e referências anteriores conservados.
|
||||
|
||||
| Estado canónico | Requisitos | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluÃdo e demonstrado | 6 | 7 |
|
||||
|
||||
Linhas principais: **AUD-OP-02/03/04, AUD-GEO-03, AUD-PROFILE-11/12/19/25/28/31 e AUD-OPS-01**, em [matriz-requisitos.csv](matriz-requisitos.csv). Nenhum desses critérios visuais foi fechado por API.
|
||||
<!-- qa-semantic-20261010:end -->
|
||||
|
||||
<!-- qa-workflow-20261010:begin -->
|
||||
## Ensaio manual atual — Promotor QA e workflow
|
||||
|
||||
**URL inicial exato: http://127.0.0.1:18097/dashboard. Conta: Promotor QA — conta sintética 21.** Se necessário, o login normal é `/login`, com MFA Fortify. Credenciais e códigos permanecem exclusivamente no ficheiro privado local; não os copiar para a conversa. Conferir identificação QA/porta 18097; não usar `portal-investidor.test`.
|
||||
|
||||
1. Entrar como Promotor 21, concluir MFA e atualizar dashboard. Esperado: “Minhas oportunidades” e “Nova oportunidade”, sessão mantida. Um retorno a login entra como reprodução pendente, não como sucesso.
|
||||
2. Pelo dashboard, clicar “Nova oportunidade”, escolher organização QA representada e categoria imobiliária; criar e guardar **rascunho incompleto**, incluindo um campo opcional limpo. Esperado: ID, estado rascunho e ausência de bloqueio por campos ainda não preenchidos.
|
||||
3. Anotar o ID sem copiar informação privada; logout, nova sessão/MFA, dashboard → “Minhas oportunidades” → mesmo ID. Esperado: recuperação exata, organização/autor preservados. Submeter incompleto deve mostrar campos pendentes e manter o rascunho.
|
||||
4. Abrir recursos pelo formulário: MZ → Cabo Delgado → distrito válido → principal; guardar/reabrir. Esperado: mesmos IDs/nome, filhos coerentes; mapa administrativo quando disponível. Não usar limite administrativo como geometria de parcela. Carregar documento sintético permitido; confirmar scan/recuperação e vínculo ao mesmo registo.
|
||||
5. Completar núcleo e módulo imobiliário com dados explicitamente sintéticos. O catálogo de ensaio possui sector/subsector/atividade QA; contacto é da organização QA. Usar `sale`, `warehouse`, `completed`, condição `good`, áreas positivas, moeda/preço positivo, ocupação/título/ónus/avaliação e licença aplicável; documento e localização selecionados no contrato. Preencher resumo/pedido/evidências e declaração de verdade. Para este primeiro ciclo usar visibilidade **internal**. Os editores de objetos/grelhas ainda incompletos devem ser registados como falhas: não contornar pela consola nem fingir visual concluída.
|
||||
6. Guardar com motivo → submeter. Esperado: `/view`, estado submetido, formulário bloqueado, snapshot/versionamento e tarefa de revisão sem responsável/prazo inventado. A rota `/edit` fechada não deve ser usada para continuar revisão.
|
||||
7. Noutra sessão QA entrar como **Validador 24**; abrir o mesmo ID autorizado, atribuir/iniciar revisão, solicitar correção com motivo e campo identificado. Esperado: necessidades de informação, motivo recuperável e tarefa de correção.
|
||||
8. Voltar como 21: abrir mesma oportunidade, corrigir, guardar com motivo e resubmeter. Logout/nova sessão deve recuperar a versão corrigida sem perder a decisão anterior.
|
||||
9. Como 24: iniciar a revisão dessa versão, registar os gates aplicáveis com decisão/evidência/fundamento; aprovar. Esperado: versão/hash aprovados, aprovação prematura recusada.
|
||||
10. Como **Publicador 25**: abrir `/view`, rever prévia, confirmar âmbito e publicar. Esperado: publicado interno, sem exposição na API pública. Para o ensaio público separado, preparar capa, legenda/alt/direitos e duas derivadas analisadas; confirmar prévia/hash e publicar explicitamente.
|
||||
11. Como 21, solicitar alteração material com fundamento, editar a proposta privada. Esperado: nova revisão sem aprovação reutilizável; ficha pública anterior permanece intacta. Como 25 suspender com motivo; esperado GET público 404 e história preservada.
|
||||
12. Em registo separado, como 24 rejeitar com fundamento; como 21 pedir recurso; como **revisor independente 29** reabrir. Esperado: rejeição original conservada. Rejeitor e membro da organização devem ser recusados. Conta 31 com papéis acumulados/Auditor continua sem escrita. Não atribuir responsabilidades reais às contas sintéticas.
|
||||
|
||||
Recolher por passo apenas hora UTC, perfil/ID, URL sem tokens, ação, resultado visível e referência do registo. Omitir Cookie, X-XSRF-TOKEN, passwords, segredo/código MFA e documentos privados de capturas. C37/investidor 18 e cenários A/B continuam no roteiro existente, separados deste ensaio. Sucesso HTTP abaixo não certifica nenhum dos passos visuais.
|
||||
|
||||
|
||||
## Incremento canónico e workflow em QA — 10/10/2026
|
||||
|
||||
Servidor **http://127.0.0.1:18097**; destino guardado **desk_restore_20261009_174726_test:55441**. Zero escritas na base ativa. **C37 e toda a aceitação visual deste incremento permanecem pendentes.** Props da interface, API e persistência foram verificadas; nenhum clique ou renderização no navegador foi observado.
|
||||
|
||||
| Requisito na matriz única | Funciona / prova concreta em QA | Falta para fechar |
|
||||
| --- | --- | --- |
|
||||
| AUD-OP-04; AUD-PROFILE-28 | Dez categorias guardam rascunhos incompletos e recuperam noutra sessão MFA. Seis passam o contrato estrutural: investimento, imobiliário, DUAT, venda, parceria e franquia. Adapter do mesmo schema de 110 definições; condições `allOf` originais recuperadas, IDs existentes mantidos. | Semântica e editores dos objetos incompletos do §25; contrato Response integral; quatro perfis modulares aprovados; demonstração visual por categoria. |
|
||||
| AUD-OP-03; PROFILE-08/11/12 | Promotor 21 criou a oportunidade **18**, submeteu; Validador 24 solicitou correção; 21 corrigiu/resubmeteu; 24 reviu gates e aprovou; Publicador 25 publicou com visibilidade interna. O número 18 identifica a oportunidade, não a conta do investidor C37. | Visual integral, todas as categorias, pareceres/incidentes e coordenação editorial completa. |
|
||||
| PROFILE-09/10/12/21 | Imobiliário **19**: rejeição fundamentada por 24 → recurso reaberto pelo revisor independente 29 → nova aprovação 24 → publicação pública 25 → alteração privada sem alterar a ficha pública → suspensão com GET público 404. Autor Prom+Val+Pub e aprovador Val+Pub receberam 403. | Visual, filas institucionais e competências reais; suspensão/retoma e demais ações editoriais por todos os perfis. |
|
||||
| PROFILE-19/21/22 | Tarefas existentes em `tasks`, vínculo à oportunidade por origem/ID/correlação; atribuição explícita, motivos e decisões recuperáveis. Correção/rejeição concluem revisão; resubmissão não herda atribuição; suspensão conclui tarefas pendentes. Recurso do próprio rejeitor e membro da organização negados. SLA `unconfigured`, responsável e prazo iniciais nulos. | Filas completas, reatribuição/disponibilidade, coordenação editorial e política SLA aprovada. Não foram inventados responsáveis nem prazos. |
|
||||
| COUNTRY-01; GEO-03; OP-02 | Localização MZ/Cabo Delgado/distrito e documentos ligados ao mesmo contrato recuperados nas seis categorias. Oportunidade **28** publicou usando a localização principal mesmo em segundo lugar na lista. Documentos e imagem/duas derivadas verificados com ClamD real e hashes correspondentes. DUAT **20** aprovado internamente não publicou: 422 por autorização externa pendente. | Outros países/formulários, origem/filtros/estatísticas transversais, mapas renderizados, 188 correspondências cartográficas e operações documentais restantes. |
|
||||
| PROFILE-30; DASH-01 | Conta **Promotor QA — conta sintética 21**, MFA normal confirmado, organização e tipos autorizados. Dashboard devolve navegação do Promotor. Permissões temporárias dos testes foram revogadas e uma nova sessão 21 recuperou o dashboard. | Dashboard → formulário → guardar → nova sessão → workflow e resultado visíveis. Auditor ou Superutilizador não substituem este ensaio. |
|
||||
|
||||
Os requisitos acima continuam **parciais**: nenhum passou a concluído pelo incremento. O detalhe está no [relatório do workflow](auditoria-portal/workflow-oportunidades-qa.md); fontes, versões, critérios e percursos permanecem na [mesma matriz](auditoria-portal/matriz-requisitos.csv).
|
||||
|
||||
| Estado canónico | Requisitos / linhas da matriz | Ocorrências de origem |
|
||||
| --- | ---: | ---: |
|
||||
| parcial | 3229 | 6379 |
|
||||
| não implementado | 145 | 446 |
|
||||
| dependente de decisão concreta | 5 | 5 |
|
||||
| implementado mas não ativado | 1 | 1 |
|
||||
| concluído e demonstrado | 6 | 7 |
|
||||
|
||||
Total: **3.386 requisitos = 3.386 linhas**, **6.838 ocorrências**, **3.380 requisitos abertos**. As referências e versões anteriores foram preservadas.
|
||||
|
||||
**Leitura temporal:** as secções seguintes preservam relatórios anteriores e respetivas evidências. Afirmações antigas sobre submissão sempre bloqueada, Validador 24 revogado, conta 29 apenas de bootstrap, contagens ou fingerprints não descrevem este incremento. O estado atual está acima e em [workflow-oportunidades-qa.md](auditoria-portal/workflow-oportunidades-qa.md).
|
||||
<!-- qa-workflow-20261010:end -->
|
||||
|
||||
<!-- qa-dashboard-20261010:begin -->
|
||||
## Estado corrente: acesso ao dashboard C43 —10/10/2026
|
||||
|
||||
**C43 — causas encontradas no código/compilação:**(1)AppSidebar mostrava só o primeiro filho de cada grupo, descartando oportunidades; (2)menu Painel apontava /investidor mesmo para gestor; (3)dashboard de gestão colocava criação no fim como Criar oportunidade, sem rótulo Nova oportunidade. A secção de gestão só é devolvida pelo backend a contas verificadas em portal.managers; o seu ocultamento para investidor é autorização esperada, não motivo para conceder perfil administrativo.
|
||||
|
||||
Correção QA: atalhos Gestão de Oportunidades/Nova oportunidade no início do dashboard, sidebar/menu agrupado; Painel da gestão→/dashboard, dossiers→/investidor. Nova ação archive conserva o rascunho em status archived/deleted_at=null; delete/exclusão lógica mantém o contrato anterior. Restauro volta a draft, ID/histórico intactos, sem publicação. Código usa flags false por omissão; QA usa assets `public/build-qa-opportunities`, versão Inertia desse manifesto. **`public/build` ativo manteve SHA 75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94.** Recursos 030000 não são requisito para navegar/criar rascunho básico.
|
||||
|
||||
**C44 — progresso específico:**primeiro adaptador imobiliário aceita campos fornecidos nas 24 propriedades realEstatePatch, validando tipos/enums/limites/UUID e recusando campos desconhecidos/de resposta. Guarda/merge privadamente no management_draft atual. Ainda faltam formulário especializado, vínculos canónicos, condições finais/Create/Response, DUAT/transação e workflow. Nenhuma coleção/tabela paralela, renumeração ou decisão externa criada. O.source.json conserva o retrato histórico da extração, não certifica o estado runtime atual.
|
||||
|
||||
QA exclusiva: http://127.0.0.1:18097, alvo real `desk_restore_20261009_174726_test:55441`. Ativo consultado por transação SQL READ ONLY; 0 escritas/instalações/publicações ativas. Cinco fases/19 etapas, 5 dossiers/95 etapas e 393 geometrias QA preservados. C37 e aceitação visual permanecem abertos.
|
||||
|
||||
Gestores QA 14/17 têm email verificado+`manage-portal`; gestor 17 possui MFA normal. Investidores QA 13/18 não têm gestão. No ativo, a leitura confirmou contas 2/13 autorizadas, 020000 instalada e 030000 ausente; o dashboard ativo contém título Gestão de Oportunidades e ação antiga Criar oportunidade. Não há hotfile/cache de configuração/cache de rotas a explicar a ausência. URL configurado ativo: http://portal-investidor.test; **URL/conta da sessão reportada ainda desconhecidos**, sem atribuição especulativa de causa a essa conta.
|
||||
|
||||
[Ensaio desde o dashboard](auditoria-portal/demonstracao-dashboard-oportunidades.md),[matriz única](auditoria-portal/matriz-requisitos.csv),[pedidos abertos/backlog](auditoria-portal/backlog.md),[evidências](auditoria-portal/relatorio-executivo.md). API/persistência/assetsQA demonstrados; visual e ativação pendentes.393 geometrias e schema 110 defs não encerram o cadastro funcional.
|
||||
|
||||
**Histórico preservado:** os blocos anteriores a C43/C44 abaixo conservam as suas fontes e provas. Os seus hashes/ready/contagens não certificam a revisão atual; a primeira adaptação imobiliária não equivale ao schema integral nem ao workflow.
|
||||
<!-- qa-dashboard-20261010:end -->
|
||||
|
||||
<!-- qa-independent-20261010:begin -->
|
||||
## Estado corrente — 10/10/2026
|
||||
|
||||
[Estado corrente na matriz existente](auditoria-portal/matriz-requisitos.csv), [relatório](auditoria-portal/relatorio-executivo.md) e [backlog](auditoria-portal/backlog.md):3354 requisitos/5830origens;32 geometrias adicionaisQA,393 privadas no total; correspondências11/128/254 e lacunas0/31/157. Recuperação documental cifrada fundiário/parceria e loteQA demonstrados por operações específicas; países/origem renovados por formulário. Schema especializado110defs/9 contratos preparado com fonte/hash, ainda sem validaçãoruntime/workflow integrado. Maturidade7/8 no draft foi corrigida em lote posterior ao texto histórico abaixo, sem reinterpretar seis valores legados; isso não conclui publicação/workflow. C37 e A/Bvisuais continuam pendentes. Recursos ativos não instalados no último retrato conhecido, a reconfirmar antes de futura ação; ready/hashes antigos não representam a revisão atual. Nenhuma alteração ativa. Cinco fases/19 etapas e URLs/IDs/cadastros preservados.
|
||||
|
||||
As atualizações datadas09/10e entregas abaixo permanecem como histórico, incluindo falhas/contagens posteriormente corrigidas; prevalecem os critérios/evidências atuais do relatório e da mesma matriz.
|
||||
<!-- qa-independent-20261010:end -->
|
||||
|
||||
Continuação da auditoria — 09/10/2026, após 18:22 locais: [relatório atual](auditoria-portal/relatorio-executivo.md), [mesma matriz consolidada](auditoria-portal/matriz-requisitos.csv) e [backlog atual](auditoria-portal/backlog.md). 3.354 requisitos canónicos / 5.830 linhas de origem; critérios/versões/aliases e percurso interface–API–gravação–recuperação–resultado conservados. QA: 373/376 testes, 4.608 asserções, três ClamD reais saltados. Países públicos/simulador, origem/estatísticas e hierarquia do Balcão demonstrados no servidor; UI e cobertura transversal pendentes. Maturidade 7/8 e draft incompleto educativo reproduzidos como 422 na QA, não concluídos. Recursos não instalados; prova renovada às 16:18:09 UTC, fingerprint `c328f542…`, preview `ready=true`. As provas anteriores abaixo são históricas. Nenhuma escrita ativa por esta auditoria.
|
||||
|
||||
Atualização de auditoria — 09/10/2026, 17:23:42 locais: [relatório transversal](auditoria-portal/relatorio-executivo.md), [matriz única](auditoria-portal/matriz-requisitos.csv), [backlog](auditoria-portal/backlog.md) e [plano consolidado](auditoria-portal/plano-ativacao.md). O plano de recursos foi renovado para o hash `e90ab685dac9441b7fc5f9da231770cb607a91b7e991876ded652827d76efa50` e está pronto por leitura, **não instalado**. Backup/restauro verificado em 15:16:03 UTC, validade de 24 h. QA global: 368 passaram/4.401 asserções e três ClamD saltados. UI desta versão pendente por ausência de browser conectado. Os registos de hashes/validade abaixo mantêm-se como histórico, não como prova corrente.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source_document_sha256": "9f173ed3024d2beddef7888b692d8b81fc5e4700249955f2bae9bdd2eb2be504",
|
||||
"source_paragraphs": "2110–14681 (§26)",
|
||||
"artifact_sha256": "9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0",
|
||||
"definitions": 110,
|
||||
"schema_dialect": "https://json-schema.org/draft/2020-12/schema",
|
||||
"core_id_compatibility": "Normative UUID examples retained; existing opportunity bigint IDs must use an explicit adapter, never a renumbering migration.",
|
||||
"contracts": [
|
||||
"realEstateCreate",
|
||||
"realEstatePatch",
|
||||
"realEstateResponse",
|
||||
"landRightsCreate",
|
||||
"landRightsPatch",
|
||||
"landRightsResponse",
|
||||
"businessTransactionCreate",
|
||||
"businessTransactionPatch",
|
||||
"businessTransactionResponse"
|
||||
],
|
||||
"runtime_validation_implemented": false,
|
||||
"workflow_implemented": false,
|
||||
"qa_activation": false,
|
||||
"active_writes": 0,
|
||||
"state_scope": "Historical extraction snapshot before the runtime adapter. These booleans are not current runtime/installation flags.",
|
||||
"current_runtime_reference": "docs/investmoz/v2/auditoria-portal/workflow-oportunidades-qa.md"
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { profileLinks } from '@/components/profile-navigation';
|
||||
import type { ProfileAccess } from '@/types/profile-access';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { portalNavigation } from '@/lib/portal-navigation';
|
||||
@@ -15,27 +17,52 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { Auth } from '@/types';
|
||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
||||
|
||||
export function AppSidebar() {
|
||||
const { auth, existingBusinessEnabled, educationEnabled, aiEnabled } =
|
||||
usePage<{
|
||||
auth: Auth;
|
||||
existingBusinessEnabled?: boolean;
|
||||
educationEnabled?: boolean;
|
||||
aiEnabled?: boolean;
|
||||
}>().props;
|
||||
const {
|
||||
auth,
|
||||
existingBusinessEnabled,
|
||||
educationEnabled,
|
||||
aiEnabled,
|
||||
opportunityManagement,
|
||||
profileAccess,
|
||||
} = usePage<{
|
||||
auth: Auth;
|
||||
existingBusinessEnabled?: boolean;
|
||||
educationEnabled?: boolean;
|
||||
aiEnabled?: boolean;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
}>().props;
|
||||
const groups = portalNavigation({
|
||||
authenticated: !!auth.user,
|
||||
manager: auth.canManagePortal,
|
||||
businesses: existingBusinessEnabled,
|
||||
education: educationEnabled,
|
||||
ai: aiEnabled,
|
||||
opportunityManagement,
|
||||
});
|
||||
const items = groups.map((group) => ({
|
||||
title: group.label,
|
||||
href: group.children?.find((item) => item.href)?.href ?? '/',
|
||||
icon: LayoutGrid,
|
||||
}));
|
||||
if (auth.canManagePortal && opportunityManagement) {
|
||||
items.push({
|
||||
title: opportunityManagement.manage.label,
|
||||
href: opportunityManagement.manage.href,
|
||||
icon: LayoutGrid,
|
||||
});
|
||||
if (opportunityManagement.installed) {
|
||||
items.push({
|
||||
title: opportunityManagement.create.label,
|
||||
href: opportunityManagement.create.href,
|
||||
icon: LayoutGrid,
|
||||
});
|
||||
}
|
||||
}
|
||||
items.push(...profileLinks(profileAccess).map(link => ({...link, icon: LayoutGrid})));
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
|
||||
@@ -40,6 +40,30 @@ export function ProvinceSelect({
|
||||
const [error, setError] = useState('');
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [geometrySelection, setGeometrySelection] = useState<{ value: string; id: string } | null>(null);
|
||||
const [geometryFeedback, setGeometryFeedback] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setGeometrySelection(null);
|
||||
setGeometryFeedback('');
|
||||
if (!value) return;
|
||||
// Preserve historical text. Only one exact catalogue match supplies a map ID.
|
||||
void fetch(`${endpoint}?${new URLSearchParams({ type: 'province', page: '1', q: value })}`, {
|
||||
signal: controller.signal, headers: { Accept: 'application/json' },
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) throw new Error('Não foi possível consultar a correspondência cartográfica.');
|
||||
const data = await response.json();
|
||||
const matches = (data.data as Option[]).filter((option) => option.name === value);
|
||||
if (controller.signal.aborted) return;
|
||||
if (matches.length === 1 && !data.next_page_url)
|
||||
setGeometrySelection({ value, id: matches[0].id });
|
||||
else setGeometryFeedback('Seleção guardada preservada; sem correspondência cartográfica inequívoca neste cadastro.');
|
||||
}).catch((cause: unknown) => {
|
||||
if (!controller.signal.aborted) setGeometryFeedback(cause instanceof Error ? cause.message : 'Erro de ligação.');
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [endpoint, value, retry]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -127,6 +151,12 @@ export function ProvinceSelect({
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{geometryFeedback && <p role="status">{geometryFeedback}</p>}
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={geometrySelection && geometrySelection.value === value ? geometrySelection.id : undefined}
|
||||
management={endpoint.startsWith('/management/')}
|
||||
endpoint={endpoint.startsWith('/minhas-oportunidades/') ? '/minhas-oportunidades/territory-geometries' : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -181,6 +211,7 @@ export function TerritoryFields({
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={path.at(-1)?.id}
|
||||
management={endpoint.startsWith('/management/')}
|
||||
endpoint={endpoint.startsWith('/minhas-oportunidades/') ? '/minhas-oportunidades/territory-geometries' : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
import OpportunityParcelGeometry from "./opportunity-parcel-geometry";
|
||||
import TerritoryGeometryPreview from "./territory-geometry-preview";
|
||||
import { control } from "@/pages/management/shared";
|
||||
|
||||
type Data = Record<string, unknown>;
|
||||
type Schema = {
|
||||
$ref?: string;
|
||||
type?: string | string[];
|
||||
properties?: Record<string, Schema>;
|
||||
items?: Schema;
|
||||
enum?: unknown[];
|
||||
const?: unknown;
|
||||
format?: string;
|
||||
maxLength?: number;
|
||||
required?: string[];
|
||||
};
|
||||
type Resource = {
|
||||
id: string;
|
||||
payload: { title?: string; role?: string; [key: string]: unknown };
|
||||
file?: { file_name: string };
|
||||
};
|
||||
function resourceTerritory(resource?: Resource) {
|
||||
const p = resource?.payload;
|
||||
const id =
|
||||
p?.locality_id ?? p?.admin_post_id ?? p?.district_id ?? p?.province_id;
|
||||
return typeof id === "string" ? id : undefined;
|
||||
}
|
||||
export type CanonicalOptions = {
|
||||
version: string;
|
||||
schema: Schema;
|
||||
definitions: Record<string, Schema>;
|
||||
contacts: { id: string; full_name: string }[];
|
||||
activities: {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
sector_id: number;
|
||||
label_pt_mz: string;
|
||||
}[];
|
||||
resources: Record<string, Resource[]>;
|
||||
gates: string[];
|
||||
countries: { code: string; name: string }[];
|
||||
authorities: { id: string; label_pt_mz: string }[];
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
official_title: "Título oficial",
|
||||
promoter_type: "Tipo de Promotor",
|
||||
internal_contact_id: "Contacto interno",
|
||||
sector_id: "Sector",
|
||||
subsector_id: "Subsector",
|
||||
economic_activity_id: "Atividade económica",
|
||||
location_mode: "Uma ou várias localizações",
|
||||
geographic_scope: "Âmbito geográfico",
|
||||
multi_district_rationale: "Justificação territorial",
|
||||
locations: "Localizações guardadas",
|
||||
executive_summary: "Resumo executivo",
|
||||
demand_statement: "Necessidade e proposta",
|
||||
visibility: "Visibilidade após publicação",
|
||||
truth_declaration: "Declaração de veracidade",
|
||||
publication_languages: "Versões por idioma",
|
||||
language_code: "Idioma",
|
||||
title: "Título",
|
||||
summary: "Resumo",
|
||||
documents: "Documentos guardados",
|
||||
evidences: "Evidências",
|
||||
document_id: "Documento associado",
|
||||
evidence_type: "Tipo de evidência",
|
||||
description: "Descrição",
|
||||
images: "Imagens guardadas",
|
||||
review_due_date: "Data de revisão editorial",
|
||||
real_estate: "Imobiliário",
|
||||
land_rights: "DUAT e direitos fundiários",
|
||||
business_transaction: "Negócio",
|
||||
transaction_type: "Modalidade",
|
||||
property_type: "Tipo de imóvel",
|
||||
construction_status: "Estado construtivo",
|
||||
property_condition: "Condição do imóvel",
|
||||
land_area: "Área do terreno",
|
||||
built_area: "Área construída",
|
||||
area_unit: "Unidade da área",
|
||||
units: "Unidades",
|
||||
rooms: "Compartimentos",
|
||||
floors: "Pisos",
|
||||
amenities: "Comodidades",
|
||||
utilities: "Serviços e acessos",
|
||||
occupancy_status: "Ocupação",
|
||||
title_type: "Tipo de título",
|
||||
registration_number: "Número de registo",
|
||||
encumbrances: "Ónus e restrições",
|
||||
price: "Preço do imóvel",
|
||||
rent: "Renda",
|
||||
rent_periodicity: "Periodicidade",
|
||||
deposit: "Caução",
|
||||
valuation: "Avaliação",
|
||||
viewing: "Visitas",
|
||||
planning_permit_document_id: "Licença de construção",
|
||||
use_license_document_id: "Licença de utilização",
|
||||
operation_type: "Operação fundiária admissível",
|
||||
duat_status: "Situação do DUAT",
|
||||
duat_number: "Número do DUAT",
|
||||
holder_id: "Titular cadastrado",
|
||||
holder_name: "Nome do titular",
|
||||
issuing_authority_id: "Entidade emissora",
|
||||
issue_date: "Data de emissão",
|
||||
expiry_date: "Data de validade",
|
||||
authorized_purpose: "Finalidade autorizada",
|
||||
exploitation_plan_document_id: "Plano de exploração",
|
||||
parcels: "Parcelas",
|
||||
location_id: "Localização associada",
|
||||
area: "Área",
|
||||
cadastral_number: "Número cadastral",
|
||||
overlap_status: "Sobreposição",
|
||||
improvements: "Benfeitorias",
|
||||
communities: "Comunidades",
|
||||
community_consultation: "Consulta comunitária",
|
||||
overlaps_conflicts: "Sobreposições e conflitos",
|
||||
required_authorizations: "Autorizações necessárias",
|
||||
consideration: "Contraprestação",
|
||||
legal_entity_id: "Entidade representada",
|
||||
legal_form: "Forma jurídica",
|
||||
percentage_offered: "Percentagem oferecida",
|
||||
asking_price: "Valor solicitado",
|
||||
financial_periods: "Exercícios financeiros",
|
||||
assets_liabilities: "Ativos e passivos",
|
||||
employees: "Empregos e obrigações",
|
||||
contracts_licenses: "Contratos e licenças",
|
||||
litigation_contingencies: "Litígios e contingências",
|
||||
reason_for_transaction: "Fundamentação da operação",
|
||||
partner_contribution: "Contribuição do parceiro",
|
||||
governance_rights: "Direitos de governação",
|
||||
franchise_terms: "Condições da franquia",
|
||||
confidentiality_level: "Confidencialidade",
|
||||
due_diligence_status: "Preparação da análise",
|
||||
opportunity_type: "Tipo de investimento",
|
||||
maturity_stage: "Etapa de maturidade",
|
||||
investment_modalities: "Modalidades de investimento",
|
||||
investor_profiles: "Perfis de investidor",
|
||||
products: "Produtos",
|
||||
assets: "Ativos",
|
||||
inputs: "Insumos",
|
||||
markets: "Mercados",
|
||||
competitive_advantage: "Vantagem competitiva",
|
||||
employment: "Emprego",
|
||||
impact_indicators: "Impactos",
|
||||
total_investment: "Investimento total",
|
||||
currency: "Moeda",
|
||||
investment_breakdown: "Rubricas de investimento",
|
||||
secured_financing: "Financiamento assegurado",
|
||||
funding_sought: "Financiamento procurado",
|
||||
financial_indicators: "Indicadores financeiros",
|
||||
milestones: "Marcos",
|
||||
licenses: "Licenças",
|
||||
environmental_social: "Aspetos ambientais e sociais",
|
||||
risks: "Riscos",
|
||||
relations: "Relações",
|
||||
amount: "Montante",
|
||||
name: "Nome",
|
||||
label: "Designação",
|
||||
status: "Estado",
|
||||
date: "Data",
|
||||
method: "Método",
|
||||
appraiser: "Avaliador",
|
||||
year: "Exercício",
|
||||
revenue: "Receita",
|
||||
profit: "Resultado",
|
||||
type: "Tipo",
|
||||
value: "Valor",
|
||||
details: "Detalhes",
|
||||
mitigation: "Mitigação",
|
||||
due_date: "Prazo",
|
||||
authority: "Entidade competente",
|
||||
assessment_status: "Estado da avaliação",
|
||||
environmental_category: "Categoria ambiental",
|
||||
direct_jobs: "Empregos diretos",
|
||||
employment_type: "Tipo de emprego",
|
||||
geometry: "Geometria da parcela",
|
||||
module_profile_version: "Perfil modular aprovado",
|
||||
start_date: "Data de início",
|
||||
end_date: "Data de fim",
|
||||
responsible_name: "Responsável declarado",
|
||||
dependencies: "Marcos dos quais depende",
|
||||
scenario: "Cenário analisado",
|
||||
npv: "Valor atual líquido",
|
||||
irr: "Taxa interna de retorno (%)",
|
||||
payback_years: "Prazo de retorno (anos)",
|
||||
horizon_years: "Horizonte (anos)",
|
||||
discount_rate: "Taxa de desconto (%)",
|
||||
model_document_id: "Modelo financeiro associado",
|
||||
title_holder_name: "Titular do título",
|
||||
title_document_id: "Documento do título",
|
||||
holder_document_id: "Documento do titular",
|
||||
expected_completion_date: "Conclusão prevista",
|
||||
occupancy_details: "Condições de ocupação",
|
||||
occupancy_document_id: "Contrato de ocupação",
|
||||
urban_limit_document_id: "Fonte do limite urbanístico",
|
||||
max_built_area: "Área edificável máxima declarada",
|
||||
amenities_other: "Outra comodidade",
|
||||
communities_affected: "Existem comunidades afetadas?",
|
||||
exploitation_plan_required: "Plano de exploração aplicável?",
|
||||
entity_registration: "Registo e beneficiário efetivo",
|
||||
tax_identifier: "Identificação fiscal",
|
||||
legal_document_id: "Documento de constituição/registo",
|
||||
beneficial_owner_document_id: "Documento do beneficiário efetivo",
|
||||
available_financial_years: "Exercícios financeiros existentes",
|
||||
liabilities: "Passivos",
|
||||
cash_flow: "Fluxo de caixa",
|
||||
excluded_assets: "Ativos excluídos",
|
||||
guarantees: "Garantias",
|
||||
reconciliation_document_id: "Documento de reconciliação",
|
||||
obligations: "Obrigações",
|
||||
labor_liabilities: "Passivos laborais",
|
||||
transferability: "Transmissibilidade",
|
||||
consent_document_id: "Consentimento de transmissão",
|
||||
nda_document_id: "Acordo de confidencialidade",
|
||||
teaser: "Resumo autorizado para publicação",
|
||||
board: "Órgãos de gestão",
|
||||
reserved_matters: "Matérias reservadas",
|
||||
dividends: "Dividendos",
|
||||
exit: "Saída",
|
||||
deadlock: "Resolução de impasse",
|
||||
initial_fee: "Encargo inicial",
|
||||
royalties: "Royalties e encargos",
|
||||
support: "Apoio",
|
||||
exclusivity: "Exclusividade",
|
||||
renewal: "Renovação",
|
||||
termination: "Cessação",
|
||||
brand_license_document_id: "Licença da marca",
|
||||
publish_contact: "Autoriza publicar o contacto?",
|
||||
public_contact_id: "Contacto para divulgação",
|
||||
};
|
||||
function FieldErrors({
|
||||
path,
|
||||
errors,
|
||||
}: {
|
||||
path: string;
|
||||
errors: Record<string, string>;
|
||||
}) {
|
||||
const message = errors[path];
|
||||
return message ? (
|
||||
<p id={path + "-error"} className="text-red-700" role="alert">
|
||||
{message}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
function Editor({
|
||||
name,
|
||||
path,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
errors,
|
||||
}: {
|
||||
name: string;
|
||||
path: string;
|
||||
schema: Schema;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
options: CanonicalOptions;
|
||||
errors: Record<string, string>;
|
||||
}) {
|
||||
if (schema.$ref)
|
||||
schema = options.definitions[schema.$ref.split("/").pop()!] ?? schema;
|
||||
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||
const label = labels[name] ?? name.replaceAll("_", " ");
|
||||
const props = {
|
||||
id: path,
|
||||
"aria-invalid": !!errors[path],
|
||||
"aria-describedby": errors[path] ? path + "-error" : undefined,
|
||||
};
|
||||
let input;
|
||||
if (name === "geometry")
|
||||
input = <OpportunityParcelGeometry value={value} onChange={onChange} />;
|
||||
else if (
|
||||
name.endsWith("document_id") ||
|
||||
name === "location_id" ||
|
||||
name === "image_id"
|
||||
) {
|
||||
const kind =
|
||||
name === "location_id"
|
||||
? "locations"
|
||||
: name === "image_id"
|
||||
? "images"
|
||||
: "documents";
|
||||
const rows = options.resources[kind] ?? [];
|
||||
input = (
|
||||
<>
|
||||
<select
|
||||
{...props}
|
||||
className={control}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{rows.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.payload.title ??
|
||||
r.payload.role ??
|
||||
r.file?.file_name ??
|
||||
r.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{name === "location_id" && !!value && (
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={resourceTerritory(
|
||||
rows.find((r) => r.id === value),
|
||||
)}
|
||||
endpoint="/minhas-oportunidades/territory-geometries"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (name === "country_code")
|
||||
input = (
|
||||
<select
|
||||
{...props}
|
||||
className={control}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{options.countries.map((c) => (
|
||||
<option key={c.code} value={c.code}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
else if (name === "issuing_authority_id" || name === "authority_id")
|
||||
input = (
|
||||
<select
|
||||
{...props}
|
||||
className={control}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{options.authorities.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label_pt_mz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
else if (schema.enum)
|
||||
input = (
|
||||
<select
|
||||
{...props}
|
||||
className={control}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{schema.enum
|
||||
.filter((v) => v !== null)
|
||||
.map((v) => (
|
||||
<option key={String(v)} value={String(v)}>
|
||||
{String(v).replaceAll("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
else if (types.includes("boolean") || schema.const === true)
|
||||
input = (
|
||||
<select
|
||||
{...props}
|
||||
className={control}
|
||||
value={value === true ? "true" : value === false ? "false" : ""}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value === "true",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
<option value="true">Sim</option>
|
||||
<option value="false">Não</option>
|
||||
</select>
|
||||
);
|
||||
else if (types.includes("array")) {
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
let item = schema.items ?? { type: "string" };
|
||||
if (item.$ref)
|
||||
item = options.definitions[item.$ref.split("/").pop()!] ?? item;
|
||||
const itemTypes = Array.isArray(item.type) ? item.type : [item.type];
|
||||
return (
|
||||
<fieldset className="space-y-2 rounded border p-3">
|
||||
<legend>{label}</legend>
|
||||
<FieldErrors path={path} errors={errors} />
|
||||
{rows.map((v, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Editor
|
||||
name={name}
|
||||
path={path + "." + i}
|
||||
schema={schema.items ?? { type: "string" }}
|
||||
value={v}
|
||||
options={options}
|
||||
errors={errors}
|
||||
onChange={(next) =>
|
||||
onChange(
|
||||
rows.map((r, j) => (j === i ? next : r)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
onChange(rows.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
Retirar linha {i + 1}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
onChange([
|
||||
...rows,
|
||||
itemTypes.includes("object")
|
||||
? {}
|
||||
: itemTypes.includes("array")
|
||||
? []
|
||||
: "",
|
||||
])
|
||||
}
|
||||
>
|
||||
Adicionar linha
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
} else if (types.includes("object") || schema.properties) {
|
||||
const fields = schema.properties ?? {};
|
||||
const data =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Data)
|
||||
: {};
|
||||
return (
|
||||
<fieldset className="space-y-2 rounded border p-3">
|
||||
<legend>{label}</legend>
|
||||
<FieldErrors path={path} errors={errors} />
|
||||
{Object.entries(fields).map(([key, s]) => (
|
||||
<Editor
|
||||
key={key}
|
||||
name={key}
|
||||
path={path + "." + key}
|
||||
schema={s}
|
||||
value={data[key]}
|
||||
options={options}
|
||||
errors={errors}
|
||||
onChange={(v) => onChange({ ...data, [key]: v })}
|
||||
/>
|
||||
))}
|
||||
{!Object.keys(fields).length && (
|
||||
<p>
|
||||
Este detalhe exige definição contratual antes da
|
||||
submissão.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
} else {
|
||||
const numeric = types.includes("number") || types.includes("integer");
|
||||
input =
|
||||
numeric || schema.format === "date" ? (
|
||||
<input
|
||||
{...props}
|
||||
className={control}
|
||||
type={numeric ? "number" : "date"}
|
||||
step={types.includes("integer") ? 1 : "any"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: numeric
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
{...props}
|
||||
className={control}
|
||||
value={String(value ?? "")}
|
||||
maxLength={schema.maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={path} className="block">
|
||||
{label}
|
||||
</label>
|
||||
{input}
|
||||
<FieldErrors path={path} errors={errors} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function OpportunityCanonicalFields({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
category,
|
||||
organization,
|
||||
actorId,
|
||||
sectors,
|
||||
disabled = false,
|
||||
errors = {},
|
||||
}: {
|
||||
value: Data;
|
||||
onChange: (v: Data) => void;
|
||||
options: CanonicalOptions;
|
||||
category: string;
|
||||
organization: string | null;
|
||||
actorId: number;
|
||||
sectors: { id: number; name: string }[];
|
||||
disabled?: boolean;
|
||||
errors?: Record<string, string>;
|
||||
}) {
|
||||
const set = (key: string, v: unknown) =>
|
||||
onChange({
|
||||
...value,
|
||||
[key]: v,
|
||||
...(key === "sector_id"
|
||||
? { subsector_id: null, economic_activity_id: null }
|
||||
: key === "subsector_id"
|
||||
? { economic_activity_id: null }
|
||||
: {}),
|
||||
opportunity_category: category,
|
||||
promoter_id: organization ?? actorId,
|
||||
});
|
||||
const common = [
|
||||
"official_title",
|
||||
"promoter_type",
|
||||
"internal_contact_id",
|
||||
"publish_contact",
|
||||
"public_contact_id",
|
||||
"sector_id",
|
||||
"subsector_id",
|
||||
"economic_activity_id",
|
||||
"location_mode",
|
||||
"geographic_scope",
|
||||
"multi_district_rationale",
|
||||
"locations",
|
||||
"executive_summary",
|
||||
"demand_statement",
|
||||
"documents",
|
||||
"evidences",
|
||||
"publication_languages",
|
||||
"visibility",
|
||||
"currency",
|
||||
"review_due_date",
|
||||
"images",
|
||||
"truth_declaration",
|
||||
];
|
||||
const specialized =
|
||||
category === "real_estate"
|
||||
? ["real_estate"]
|
||||
: category === "land_rights"
|
||||
? ["land_rights"]
|
||||
: ["business_sale", "business_partnership", "franchise"].includes(
|
||||
category,
|
||||
)
|
||||
? ["business_transaction"]
|
||||
: category === "investment_project"
|
||||
? [
|
||||
"opportunity_type",
|
||||
"maturity_stage",
|
||||
"investment_modalities",
|
||||
"investor_profiles",
|
||||
"products",
|
||||
"assets",
|
||||
"inputs",
|
||||
"markets",
|
||||
"competitive_advantage",
|
||||
"employment",
|
||||
"impact_indicators",
|
||||
"total_investment",
|
||||
"currency",
|
||||
"investment_breakdown",
|
||||
"secured_financing",
|
||||
"funding_sought",
|
||||
"financial_indicators",
|
||||
"milestones",
|
||||
"licenses",
|
||||
"environmental_social",
|
||||
"risks",
|
||||
"relations",
|
||||
]
|
||||
: ["module_profile_version"];
|
||||
return (
|
||||
<fieldset disabled={disabled} className="space-y-4 rounded border p-4">
|
||||
<legend>Contrato da oportunidade — {category}</legend>
|
||||
<p>
|
||||
O rascunho pode ficar incompleto. A submissão verifica os campos
|
||||
aplicáveis e os recursos guardados. Validação interna não
|
||||
equivale a autorização externa.
|
||||
</p>
|
||||
{Array.from(new Set([...common, ...specialized])).map((key) => {
|
||||
if (
|
||||
[
|
||||
"sector_id",
|
||||
"subsector_id",
|
||||
"economic_activity_id",
|
||||
"internal_contact_id",
|
||||
"public_contact_id",
|
||||
].includes(key)
|
||||
) {
|
||||
const rows =
|
||||
key === "sector_id"
|
||||
? sectors.map((s) => ({ id: s.id, label: s.name }))
|
||||
: [
|
||||
"internal_contact_id",
|
||||
"public_contact_id",
|
||||
].includes(key)
|
||||
? options.contacts.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.full_name,
|
||||
}))
|
||||
: options.activities
|
||||
.filter((a) =>
|
||||
key === "subsector_id"
|
||||
? options.activities.some(
|
||||
(root) =>
|
||||
root.sector_id ===
|
||||
Number(
|
||||
value.sector_id,
|
||||
) &&
|
||||
a.parent_id === root.id,
|
||||
)
|
||||
: a.parent_id ===
|
||||
value.subsector_id,
|
||||
)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
label: a.label_pt_mz,
|
||||
}));
|
||||
return (
|
||||
<label key={key} className="block">
|
||||
{labels[key]}
|
||||
<FieldErrors
|
||||
path={"canonical." + key}
|
||||
errors={errors}
|
||||
/>
|
||||
<select
|
||||
className={control}
|
||||
value={String(value[key] ?? "")}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
key,
|
||||
e.target.value === ""
|
||||
? null
|
||||
: key === "sector_id"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{rows.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (["locations", "documents", "images"].includes(key)) {
|
||||
const selected = Array.isArray(value[key])
|
||||
? (value[key] as string[])
|
||||
: [];
|
||||
return (
|
||||
<fieldset key={key}>
|
||||
<legend>{labels[key]}</legend>
|
||||
<FieldErrors
|
||||
path={"canonical." + key}
|
||||
errors={errors}
|
||||
/>
|
||||
{(options.resources[key] ?? []).map((r) => (
|
||||
<label key={r.id} className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(r.id)}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
key,
|
||||
e.target.checked
|
||||
? [...selected, r.id]
|
||||
: selected.filter(
|
||||
(id) => id !== r.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>{" "}
|
||||
{r.payload.title ??
|
||||
r.payload.role ??
|
||||
r.file?.file_name ??
|
||||
r.id}
|
||||
<FieldErrors
|
||||
path={`canonical.${key}.${r.id}`}
|
||||
errors={errors}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{key === "locations" &&
|
||||
selected.map((id) => (
|
||||
<TerritoryGeometryPreview
|
||||
key={id}
|
||||
territoryId={resourceTerritory(
|
||||
options.resources.locations?.find(
|
||||
(r) => r.id === id,
|
||||
),
|
||||
)}
|
||||
endpoint="/minhas-oportunidades/territory-geometries"
|
||||
/>
|
||||
))}
|
||||
{!options.resources[key]?.length && (
|
||||
<p>
|
||||
Guarde primeiro em Recursos e regresse a
|
||||
este contrato.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
const schema = options.schema.properties?.[key];
|
||||
return schema ? (
|
||||
<Editor
|
||||
key={key}
|
||||
name={key}
|
||||
path={"canonical." + key}
|
||||
errors={errors}
|
||||
schema={schema}
|
||||
value={value[key]}
|
||||
options={options}
|
||||
onChange={(v) => set(key, v)}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import InteractiveInvestmentMap from "./interactive-investment-map";
|
||||
import { control } from "@/pages/management/shared";
|
||||
import type { MultiPolygon } from "geojson";
|
||||
|
||||
type Point = [number | null, number | null];
|
||||
export default function OpportunityParcelGeometry({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
}) {
|
||||
const data =
|
||||
value && typeof value === "object"
|
||||
? (value as { coordinates?: Point[][][] })
|
||||
: {};
|
||||
const polygons = Array.isArray(data.coordinates) ? data.coordinates : [];
|
||||
function replace(p: number, r: number, points: Point[]) {
|
||||
onChange({
|
||||
type: "MultiPolygon",
|
||||
coordinates: polygons.map((poly, i) =>
|
||||
i === p
|
||||
? poly.map((ring, j) => (j === r ? points : ring))
|
||||
: poly,
|
||||
),
|
||||
});
|
||||
}
|
||||
const valid =
|
||||
polygons.length > 0 &&
|
||||
polygons.every(
|
||||
(poly) =>
|
||||
poly.length > 0 &&
|
||||
poly.every(
|
||||
(ring) =>
|
||||
ring.length >= 4 &&
|
||||
ring.every(
|
||||
(point) =>
|
||||
point.length === 2 &&
|
||||
point.every(
|
||||
(c) =>
|
||||
typeof c === "number" &&
|
||||
Number.isFinite(c),
|
||||
),
|
||||
) &&
|
||||
ring[0][0] === ring.at(-1)?.[0] &&
|
||||
ring[0][1] === ring.at(-1)?.[1],
|
||||
),
|
||||
);
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<p>
|
||||
Indique longitude e latitude do perímetro declarado. O limite
|
||||
administrativo serve de contexto; não define a parcela.
|
||||
</p>
|
||||
{polygons.map((poly, p) => (
|
||||
<fieldset key={p} className="rounded border p-2">
|
||||
<legend>Polígono {p + 1}</legend>
|
||||
{poly.map((ring, r) => (
|
||||
<section key={r}>
|
||||
<h4>
|
||||
{r === 0
|
||||
? "Anel exterior"
|
||||
: `Anel interior ${r}`}
|
||||
</h4>
|
||||
{ring.map((point, i) => (
|
||||
<div key={i} className="flex flex-wrap gap-2">
|
||||
{[0, 1].map((axis) => (
|
||||
<label key={axis}>
|
||||
{axis === 0
|
||||
? "Longitude"
|
||||
: "Latitude"}
|
||||
<input
|
||||
className={control}
|
||||
type="number"
|
||||
step="any"
|
||||
min={axis === 0 ? -180 : -90}
|
||||
max={axis === 0 ? 180 : 90}
|
||||
value={point[axis] ?? ""}
|
||||
onChange={(e) =>
|
||||
replace(
|
||||
p,
|
||||
r,
|
||||
ring.map((v, j) =>
|
||||
j === i
|
||||
? (v.map(
|
||||
(c, k) =>
|
||||
k ===
|
||||
axis
|
||||
? e
|
||||
.target
|
||||
.value ===
|
||||
""
|
||||
? null
|
||||
: Number(
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
)
|
||||
: c,
|
||||
) as Point)
|
||||
: v,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
replace(
|
||||
p,
|
||||
r,
|
||||
ring.filter((_, j) => j !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar vértice {i + 1}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="underline mr-4"
|
||||
onClick={() =>
|
||||
replace(p, r, [...ring, [null, null]])
|
||||
}
|
||||
>
|
||||
Adicionar vértice
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="underline mr-4"
|
||||
disabled={
|
||||
!ring.length ||
|
||||
ring[0].some((c) => c === null)
|
||||
}
|
||||
onClick={() =>
|
||||
replace(p, r, [
|
||||
...ring,
|
||||
[...ring[0]] as Point,
|
||||
])
|
||||
}
|
||||
>
|
||||
Fechar anel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
type: "MultiPolygon",
|
||||
coordinates: polygons.map((v, i) =>
|
||||
i === p
|
||||
? v.filter((_, j) => j !== r)
|
||||
: v,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Retirar anel
|
||||
</button>
|
||||
</section>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="underline mr-4"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
type: "MultiPolygon",
|
||||
coordinates: polygons.map((v, i) =>
|
||||
i === p ? [...v, []] : v,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Adicionar anel interior
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
type: "MultiPolygon",
|
||||
coordinates: polygons.filter((_, i) => i !== p),
|
||||
})
|
||||
}
|
||||
>
|
||||
Retirar polígono
|
||||
</button>
|
||||
</fieldset>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
type: "MultiPolygon",
|
||||
coordinates: [...polygons, [[]]],
|
||||
})
|
||||
}
|
||||
>
|
||||
Adicionar polígono
|
||||
</button>
|
||||
{valid && (
|
||||
<InteractiveInvestmentMap
|
||||
points={[]}
|
||||
contextOnly
|
||||
areas={[
|
||||
{
|
||||
purpose: "declared_parcel",
|
||||
title: "Perímetro declarado pelo Promotor",
|
||||
source_reference:
|
||||
"Declaração do Promotor; revisão cadastral pendente",
|
||||
url: "#",
|
||||
geometry: {
|
||||
type: "MultiPolygon",
|
||||
coordinates: polygons as [number, number][][][],
|
||||
} as MultiPolygon,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
|
||||
import { button, control } from '@/pages/management/shared';
|
||||
|
||||
export default function OpportunityProfileActions({
|
||||
id,
|
||||
etag,
|
||||
actions,
|
||||
gates,
|
||||
documents,
|
||||
approvalHash,
|
||||
onErrors,
|
||||
participants = [],
|
||||
publishers = [],
|
||||
opinions = [],
|
||||
publicationVisibility,
|
||||
}: {
|
||||
id: number;
|
||||
etag: string;
|
||||
actions: string[];
|
||||
gates: string[];
|
||||
documents: { id: string; title: string }[];
|
||||
approvalHash?: string;
|
||||
publicationVisibility?: string;
|
||||
onErrors?: (errors: Record<string, string>) => void;
|
||||
publishers?: { id: number; name: string }[];
|
||||
participants?: {
|
||||
id: number;
|
||||
name: string;
|
||||
technical: boolean;
|
||||
legal: boolean;
|
||||
}[];
|
||||
opinions?: {
|
||||
id: string;
|
||||
gate: string;
|
||||
recipient_id: number;
|
||||
status: string;
|
||||
}[];
|
||||
}) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [items, setItems] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [gate, setGate] = useState(gates[0] ?? '');
|
||||
const [decision, setDecision] = useState('approved');
|
||||
const [evidence, setEvidence] = useState<string[]>([]);
|
||||
const [assignee, setAssignee] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [opinion, setOpinion] = useState('');
|
||||
const [actionErrors, setActionErrors] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const labels: Record<string, string> = {
|
||||
submit: 'Submeter para validação',
|
||||
approve: 'Aprovar versão',
|
||||
publish:
|
||||
publicationVisibility === 'public'
|
||||
? 'Publicar no catálogo público'
|
||||
: 'Disponibilizar com acesso restrito',
|
||||
request_revision: 'Preparar alteração material',
|
||||
review_gate: 'Guardar revisão especializada',
|
||||
assign_review: 'Atribuir validação',
|
||||
assign_publication: 'Atribuir publicação',
|
||||
appeal_uphold: 'Manter rejeição após recurso',
|
||||
appeal_reopen: 'Reabrir para correção',
|
||||
request_archive: 'Solicitar arquivo',
|
||||
appeal: 'Apresentar recurso',
|
||||
start_review: 'Iniciar validação',
|
||||
request_correction: 'Solicitar correções',
|
||||
pending_opinion: 'Solicitar parecer',
|
||||
respond_opinion: 'Responder ao parecer',
|
||||
reject: 'Rejeitar com fundamento',
|
||||
suspend: 'Suspender',
|
||||
archive: 'Arquivar',
|
||||
};
|
||||
async function send(action: string) {
|
||||
setBusy(true);
|
||||
setActionErrors({});
|
||||
onErrors?.({});
|
||||
try {
|
||||
const result = await profileJson(
|
||||
`/minhas-oportunidades/${id}/workflow`,
|
||||
{
|
||||
etag,
|
||||
action,
|
||||
reason,
|
||||
correction_items: items
|
||||
.split('\n')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
...(['review_gate', 'respond_opinion'].includes(action)
|
||||
? {
|
||||
gate,
|
||||
gate_decision: decision,
|
||||
evidence_document_ids: evidence,
|
||||
}
|
||||
: {}),
|
||||
...(action === 'pending_opinion'
|
||||
? { gate, recipient_user_id: Number(recipient) }
|
||||
: {}),
|
||||
...(action === 'respond_opinion'
|
||||
? { opinion_id: opinion }
|
||||
: {}),
|
||||
...(['assign_review', 'assign_publication'].includes(action)
|
||||
? { assignee_user_id: Number(assignee) }
|
||||
: {}),
|
||||
...(action === 'publish'
|
||||
? {
|
||||
confirm_publication: confirmed,
|
||||
preview_hash: approvalHash,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
setMessage(result.message);
|
||||
router.visit(`/minhas-oportunidades/${id}/view`);
|
||||
} catch (error) {
|
||||
if (error instanceof ProfileValidationError) {
|
||||
setActionErrors(error.fields);
|
||||
onErrors?.(error.fields);
|
||||
}
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Resposta indisponível. Confira o estado antes de repetir.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded border p-4"
|
||||
aria-label="Workflow interno"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Workflow interno</h2>
|
||||
<p>
|
||||
Guarde o contrato antes de submeter. Aprovação exige revisões
|
||||
especializadas desta versão; publicação exige outro utilizador
|
||||
autorizado. Uma decisão interna não substitui decisão externa.
|
||||
</p>
|
||||
<p>
|
||||
Aprovação interna confirma a revisão desta versão.
|
||||
Disponibilização restrita não aparece no catálogo público nem dá
|
||||
acesso a todas as contas. A publicação pública exige âmbito
|
||||
público e ação independente do Publicador; não representa uma
|
||||
decisão externa.
|
||||
</p>
|
||||
{!!actions.length && (
|
||||
<>
|
||||
<label className="block">
|
||||
Fundamentação
|
||||
<textarea
|
||||
className={control}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{actions.includes('request_correction') && (
|
||||
<label className="block">
|
||||
Campos ou documentos a corrigir, um por linha
|
||||
<textarea
|
||||
className={control}
|
||||
value={items}
|
||||
onChange={(e) => setItems(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(actions.includes('review_gate') ||
|
||||
actions.includes('pending_opinion') ||
|
||||
actions.includes('respond_opinion')) && (
|
||||
<fieldset>
|
||||
<legend>Revisão especializada</legend>
|
||||
<label>
|
||||
Revisão
|
||||
<select
|
||||
className={control}
|
||||
value={gate}
|
||||
onChange={(e) => setGate(e.target.value)}
|
||||
>
|
||||
{gates.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g.replaceAll('_', ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Decisão
|
||||
<select
|
||||
className={control}
|
||||
value={decision}
|
||||
onChange={(e) =>
|
||||
setDecision(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="approved">Aprovada</option>
|
||||
<option value="rejected">Rejeitada</option>
|
||||
</select>
|
||||
</label>
|
||||
{documents.map((d) => (
|
||||
<label key={d.id} className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={evidence.includes(d.id)}
|
||||
onChange={(e) =>
|
||||
setEvidence(
|
||||
e.target.checked
|
||||
? [...evidence, d.id]
|
||||
: evidence.filter(
|
||||
(id) => id !== d.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{d.title}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{(actions.includes('assign_review') ||
|
||||
actions.includes('assign_publication')) && (
|
||||
<label className="block">
|
||||
Responsável a designar
|
||||
<select
|
||||
className={control}
|
||||
value={assignee}
|
||||
onChange={(e) => setAssignee(e.target.value)}
|
||||
>
|
||||
<option value="">Por atribuir</option>
|
||||
{(actions.includes('assign_publication')
|
||||
? publishers
|
||||
: participants
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>
|
||||
O servidor verifica o papel, âmbito e
|
||||
independência. Nenhum responsável é atribuído
|
||||
automaticamente.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{actions.includes('pending_opinion') && (
|
||||
<label className="block">
|
||||
Destinatário do parecer
|
||||
<select
|
||||
className={control}
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
>
|
||||
<option value="">Por designar</option>
|
||||
{participants
|
||||
.filter((p) =>
|
||||
/legal|registry|authority|confidentiality|license/.test(
|
||||
gate,
|
||||
)
|
||||
? p.legal
|
||||
: p.technical,
|
||||
)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{actions.includes('respond_opinion') && (
|
||||
<label className="block">
|
||||
Parecer atribuído
|
||||
<select
|
||||
className={control}
|
||||
value={opinion}
|
||||
onChange={(e) => setOpinion(e.target.value)}
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
{opinions
|
||||
.filter((o) => o.status === 'pending')
|
||||
.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.gate.replaceAll('_', ' ')} —
|
||||
conta {o.recipient_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{Object.entries(actionErrors)
|
||||
.filter(([key]) => !key.startsWith('canonical'))
|
||||
.map(([key, msg]) => (
|
||||
<p key={key} role="alert">
|
||||
{key}: {msg}
|
||||
</p>
|
||||
))}
|
||||
{actions.includes('publish') && (
|
||||
<label className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>{' '}
|
||||
Confirmei a pré-visualização e o âmbito de
|
||||
divulgação desta versão.
|
||||
</label>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
className={button}
|
||||
disabled={
|
||||
busy || (action === 'publish' && !confirmed)
|
||||
}
|
||||
onClick={() => void send(action)}
|
||||
>
|
||||
{labels[action] ?? action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ProfileAccess } from '@/types/profile-access';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
||||
import {
|
||||
portalNavigation,
|
||||
currentNavigationGroup,
|
||||
@@ -14,6 +16,8 @@ export function PortalGroupedMenu() {
|
||||
educationEnabled?: boolean;
|
||||
aiEnabled?: boolean;
|
||||
portalOperator?: string | null;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
}>();
|
||||
const groups = portalNavigation({
|
||||
authenticated: !!props.auth?.user,
|
||||
@@ -21,6 +25,8 @@ export function PortalGroupedMenu() {
|
||||
businesses: props.existingBusinessEnabled,
|
||||
education: props.educationEnabled,
|
||||
ai: props.aiEnabled,
|
||||
opportunityManagement: props.opportunityManagement,
|
||||
profileAccess: props.profileAccess,
|
||||
});
|
||||
const current = currentNavigationGroup(groups, url);
|
||||
const currentItem = currentNavigationItem(groups, url);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import type { ProfileAccess } from '@/types/profile-access';
|
||||
|
||||
export function profileLinks(access: ProfileAccess | null | undefined) {
|
||||
if (!access) return [];
|
||||
return [
|
||||
...(access.mine
|
||||
? [{ title: 'Minhas oportunidades', href: access.mine }]
|
||||
: []),
|
||||
...(access.create
|
||||
? [{ title: 'Nova oportunidade', href: access.create }]
|
||||
: []),
|
||||
{
|
||||
title: access.mine
|
||||
? 'Autorizações de Promotor'
|
||||
: 'Solicitar perfil de Promotor',
|
||||
href: access.request,
|
||||
},
|
||||
...(!access.mine && access.review
|
||||
? [{ title: 'Revisão de oportunidades', href: access.review }]
|
||||
: []),
|
||||
...(access.audit
|
||||
? [{ title: 'Auditoria de acessos', href: access.audit }]
|
||||
: []),
|
||||
...(access.administration
|
||||
? [{ title: 'Gestão de perfis', href: access.administration }]
|
||||
: []),
|
||||
...(access.administration
|
||||
? [
|
||||
{
|
||||
title: 'Instalação dos perfis 040000',
|
||||
href: '/management/profile-install',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export default function ProfileNavigation() {
|
||||
const { profileAccess } = usePage<{
|
||||
profileAccess?: ProfileAccess | null;
|
||||
}>().props;
|
||||
if (!profileAccess) return null;
|
||||
return (
|
||||
<nav
|
||||
aria-label="Perfil e oportunidades"
|
||||
className="flex flex-wrap gap-4 rounded border p-4"
|
||||
>
|
||||
{profileLinks(profileAccess).map((link) => (
|
||||
<Link key={link.href} href={link.href} className="underline">
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,11 @@ type Context = {
|
||||
export default function TerritoryGeometryPreview({
|
||||
territoryId,
|
||||
management = false,
|
||||
endpoint: suppliedEndpoint,
|
||||
}: {
|
||||
territoryId?: string;
|
||||
management?: boolean;
|
||||
endpoint?: string;
|
||||
}) {
|
||||
const [context, setContext] = useState<Context | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
@@ -24,9 +26,9 @@ export default function TerritoryGeometryPreview({
|
||||
setError('');
|
||||
if (!territoryId) return;
|
||||
const controller = new AbortController();
|
||||
const endpoint = management
|
||||
const endpoint = suppliedEndpoint ?? (management
|
||||
? '/management/territory-geometries'
|
||||
: '/api/v1/investment/territory-geometries';
|
||||
: '/api/v1/investment/territory-geometries');
|
||||
void fetch(
|
||||
`${endpoint}?${new URLSearchParams({ territory_id: territoryId })}`,
|
||||
{
|
||||
@@ -51,7 +53,7 @@ export default function TerritoryGeometryPreview({
|
||||
);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [territoryId, management]);
|
||||
}, [territoryId, management, suppliedEndpoint]);
|
||||
if (!territoryId) return null;
|
||||
return (
|
||||
<section
|
||||
@@ -66,7 +68,7 @@ export default function TerritoryGeometryPreview({
|
||||
<p role="status">
|
||||
{context.selected_geometry_available
|
||||
? 'Limite administrativo disponível para este território.'
|
||||
: 'Sem limite administrativo disponível para este território.'}
|
||||
: 'Sem limite administrativo acessível para este território neste perfil.'}
|
||||
</p>
|
||||
<p>
|
||||
{context.context_geometry_count} limites disponíveis na
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ProfileAccess } from '@/types/profile-access';
|
||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
||||
|
||||
export type PortalNavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -10,6 +13,8 @@ export type PortalNavAccess = {
|
||||
businesses?: boolean;
|
||||
education?: boolean;
|
||||
ai?: boolean;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
};
|
||||
const link = (id: string, label: string, href: string): PortalNavItem => ({
|
||||
id,
|
||||
@@ -19,6 +24,7 @@ const link = (id: string, label: string, href: string): PortalNavItem => ({
|
||||
|
||||
/** One route catalogue for the public shell and authenticated pages. */
|
||||
export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
const opportunities = access.manager ? access.opportunityManagement : null;
|
||||
const groups: PortalNavItem[] = [
|
||||
{
|
||||
id: 'explore',
|
||||
@@ -124,7 +130,11 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
label: 'A minha área',
|
||||
children: access.authenticated
|
||||
? [
|
||||
link('dashboard', 'Painel', '/investidor'),
|
||||
link(
|
||||
'dashboard',
|
||||
opportunities ? 'Painel da gestão' : 'Painel',
|
||||
opportunities ? '/dashboard' : '/investidor',
|
||||
),
|
||||
link(
|
||||
'profile',
|
||||
'Perfil e empresas',
|
||||
@@ -164,6 +174,24 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
'/management/interests',
|
||||
),
|
||||
link('crm', 'CRM interno', '/management/crm/leads'),
|
||||
...(opportunities
|
||||
? [
|
||||
link(
|
||||
'manage-opportunities',
|
||||
opportunities.manage.label,
|
||||
opportunities.manage.href,
|
||||
),
|
||||
...(opportunities.installed
|
||||
? [
|
||||
link(
|
||||
'create-opportunity',
|
||||
opportunities.create.label,
|
||||
opportunities.create.href,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'manage-catalogue',
|
||||
label: 'Catálogo de investimento',
|
||||
@@ -173,11 +201,15 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
'Sectores',
|
||||
'/management/sectors',
|
||||
),
|
||||
link(
|
||||
'manage-opportunities',
|
||||
'Oportunidades',
|
||||
'/management/opportunities',
|
||||
),
|
||||
...(!opportunities
|
||||
? [
|
||||
link(
|
||||
'manage-opportunities',
|
||||
'Oportunidades',
|
||||
'/management/opportunities',
|
||||
),
|
||||
]
|
||||
: []),
|
||||
link(
|
||||
'manage-projects',
|
||||
'Projectos',
|
||||
@@ -245,6 +277,17 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
: []),
|
||||
],
|
||||
});
|
||||
if (access.authenticated && access.profileAccess) {
|
||||
const profile = access.profileAccess;
|
||||
groups.push({id: 'profile-opportunities', label: 'Perfil e oportunidades', children: [
|
||||
...(profile.mine ? [link('my-opportunities', 'Minhas oportunidades', profile.mine)] : []),
|
||||
...(profile.create ? [link('new-my-opportunity', 'Nova oportunidade', profile.create)] : []),
|
||||
link('promoter-profile', profile.mine ? 'Autorizações de Promotor' : 'Solicitar perfil de Promotor', profile.request),
|
||||
...(!profile.mine && profile.review ? [link('profile-review', 'Revisão de oportunidades', profile.review)] : []),
|
||||
...(profile.audit ? [link('profile-audit', 'Auditoria de acessos', profile.audit)] : []),
|
||||
...(profile.administration ? [link('profile-administration', 'Gestão de perfis', profile.administration)] : []),
|
||||
]});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export class ProfileValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly fields: Record<string, string>,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export async function profileJson(
|
||||
url: string,
|
||||
data: object = {},
|
||||
method: 'GET' | 'POST' = 'POST',
|
||||
) {
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='));
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': cookie ? decodeURIComponent(cookie.slice(11)) : '',
|
||||
},
|
||||
body: method === 'GET' ? undefined : JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new ProfileValidationError(
|
||||
result.message ?? 'A ação não foi concluída.',
|
||||
Object.fromEntries(
|
||||
Object.entries(result.errors ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.join(' ') : String(value),
|
||||
]),
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import ProfileNavigation from '@/components/profile-navigation';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { InvestmentContext } from '@/pages/investor-path/index';
|
||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
||||
|
||||
type Summary = {
|
||||
canCreateOpportunity?: boolean;
|
||||
requestsByStatus: Record<string, number>;
|
||||
publishedOpportunities: number;
|
||||
draftOpportunities: number;
|
||||
@@ -27,8 +30,10 @@ const card =
|
||||
export default function Dashboard({
|
||||
summary,
|
||||
investorPath,
|
||||
opportunityManagement,
|
||||
}: {
|
||||
summary: Summary | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
investorPath?: {
|
||||
contexts: InvestmentContext[];
|
||||
current: {
|
||||
@@ -45,8 +50,53 @@ export default function Dashboard({
|
||||
<main className="space-y-8 p-4 md:p-8">
|
||||
<Head title="Painel" />
|
||||
<h1 className="text-2xl font-semibold">Painel do portal</h1>
|
||||
<ProfileNavigation />
|
||||
{summary ? (
|
||||
<>
|
||||
{opportunityManagement && (
|
||||
<section
|
||||
aria-label="Gestão de Oportunidades"
|
||||
className="space-y-4 rounded-xl border p-5"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Gestão de Oportunidades
|
||||
</h2>
|
||||
<nav
|
||||
aria-label="Ações de oportunidades"
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
<Link
|
||||
className="rounded-lg border px-4 py-2 underline"
|
||||
href={opportunityManagement.manage.href}
|
||||
>
|
||||
{opportunityManagement.manage.label}
|
||||
</Link>
|
||||
{opportunityManagement.installed ? (
|
||||
<Link
|
||||
className="bg-primary text-primary-foreground rounded-lg px-4 py-2"
|
||||
href={opportunityManagement.create.href}
|
||||
>
|
||||
{opportunityManagement.create.label}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
className="underline"
|
||||
href={
|
||||
opportunityManagement.installationHref
|
||||
}
|
||||
>
|
||||
Preparar instalação da gestão de
|
||||
rascunhos
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Os rascunhos de oportunidades não publicam o
|
||||
dossier privado do investidor. Publicação exige
|
||||
uma ação própria.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Manifestações de interesse
|
||||
@@ -71,7 +121,9 @@ export default function Dashboard({
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Link href="/management/opportunities">
|
||||
Gestão de Oportunidades
|
||||
{opportunityManagement
|
||||
? 'Indicadores de oportunidades'
|
||||
: 'Gestão de Oportunidades'}
|
||||
</Link>
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
@@ -163,12 +215,13 @@ export default function Dashboard({
|
||||
aria-label="Acções rápidas"
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<Link
|
||||
{summary.canCreateOpportunity !== false && <Link
|
||||
className="underline"
|
||||
href="/management/opportunities/create"
|
||||
>
|
||||
Criar oportunidade
|
||||
</Link>
|
||||
{opportunityManagement?.create.label ??
|
||||
'Criar oportunidade'}
|
||||
</Link>}
|
||||
<Link
|
||||
className="underline"
|
||||
href="/management/sectors/create"
|
||||
|
||||
@@ -16,6 +16,10 @@ type Entry = {
|
||||
opportunity_category: string | null;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
can_edit?: boolean;
|
||||
working_status?: string;
|
||||
draft_title?: string;
|
||||
publication?: { scope: string; label: string; public_catalog: boolean };
|
||||
};
|
||||
export default function Opportunities({
|
||||
records,
|
||||
@@ -24,10 +28,13 @@ export default function Opportunities({
|
||||
categories,
|
||||
sectors,
|
||||
geographyReviewCount,
|
||||
basePath = '/management/opportunities',
|
||||
canCreate = true,
|
||||
}: {
|
||||
records: Paginated<Entry>;
|
||||
filters: {
|
||||
q?: string;
|
||||
queue?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
deleted?: boolean;
|
||||
@@ -38,9 +45,12 @@ export default function Opportunities({
|
||||
categories: string[];
|
||||
sectors: { id: number; name: string }[];
|
||||
geographyReviewCount: number;
|
||||
basePath?: string;
|
||||
canCreate?: boolean;
|
||||
}) {
|
||||
const form = useForm({
|
||||
q: filters.q ?? '',
|
||||
queue: filters.queue ?? '',
|
||||
status: filters.status ?? '',
|
||||
category: filters.category ?? '',
|
||||
deleted: Boolean(filters.deleted),
|
||||
@@ -48,10 +58,18 @@ export default function Opportunities({
|
||||
geography_scope: filters.geography_scope ?? '',
|
||||
});
|
||||
return (
|
||||
<Management title="Gestão de Oportunidades">
|
||||
<Link className={button} href="/management/opportunities/create">
|
||||
Nova oportunidade
|
||||
</Link>
|
||||
<Management
|
||||
title={
|
||||
basePath === '/minhas-oportunidades'
|
||||
? 'Minhas oportunidades'
|
||||
: 'Gestão de Oportunidades'
|
||||
}
|
||||
>
|
||||
{canCreate && (
|
||||
<Link className={button} href={`${basePath}/create`}>
|
||||
Nova oportunidade
|
||||
</Link>
|
||||
)}
|
||||
<p>
|
||||
{geographyReviewCount} registos com âmbito por indicar;
|
||||
referências existentes permanecem aceites.
|
||||
@@ -72,7 +90,7 @@ export default function Opportunities({
|
||||
<Link
|
||||
className="rounded border p-3"
|
||||
key={s}
|
||||
href={`/management/opportunities?status=${s}`}
|
||||
href={`${basePath}?status=${s}`}
|
||||
>
|
||||
{states[s] ?? s}: {counts[s] ?? 0}
|
||||
</Link>
|
||||
@@ -82,9 +100,31 @@ export default function Opportunities({
|
||||
className="flex flex-wrap gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.get('/management/opportunities');
|
||||
form.get(basePath);
|
||||
}}
|
||||
>
|
||||
{basePath === '/minhas-oportunidades' && (
|
||||
<label>
|
||||
Fila
|
||||
<select
|
||||
className={control}
|
||||
value={form.data.queue}
|
||||
onChange={(e) =>
|
||||
form.setData('queue', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todas as autorizadas</option>
|
||||
<option value="assigned">Atribuídas a mim</option>
|
||||
<option value="review">
|
||||
Validação e pareceres
|
||||
</option>
|
||||
<option value="publication">
|
||||
Preparação editorial
|
||||
</option>
|
||||
<option value="appeal">Recursos pendentes</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
aria-label="Pesquisa global"
|
||||
placeholder="Título ou referência"
|
||||
@@ -166,7 +206,7 @@ export default function Opportunities({
|
||||
{records.data.map((r) => (
|
||||
<article key={r.id} className="rounded border p-4">
|
||||
<h2 className="font-semibold">
|
||||
{r.title ?? 'Rascunho sem título'}
|
||||
{r.draft_title ?? r.title ?? 'Rascunho sem título'}
|
||||
</h2>
|
||||
<p>
|
||||
ID {r.id} · {r.reference} ·{' '}
|
||||
@@ -174,24 +214,25 @@ export default function Opportunities({
|
||||
·{' '}
|
||||
{r.deleted_at
|
||||
? 'Eliminado logicamente'
|
||||
: (states[r.status] ?? r.status)}
|
||||
: (r.working_status ?? r.status) === 'published'
|
||||
? 'Versão disponibilizada'
|
||||
: (states[r.working_status ?? r.status] ??
|
||||
r.working_status ??
|
||||
r.status)}
|
||||
</p>
|
||||
{r.publication && <p>{r.publication.label}</p>}
|
||||
<p>Atualizado: {r.updated_at}</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link
|
||||
href={`/management/opportunities/${r.id}/view`}
|
||||
>
|
||||
Ver
|
||||
</Link>
|
||||
<Link
|
||||
href={`/management/opportunities/${r.id}/edit`}
|
||||
>
|
||||
{r.deleted_at || r.status === 'archived'
|
||||
? 'Restaurar'
|
||||
: r.status === 'draft'
|
||||
? 'Continuar preenchimento'
|
||||
: 'Editar nova versão'}
|
||||
</Link>
|
||||
<Link href={`${basePath}/${r.id}/view`}>Ver</Link>
|
||||
{r.can_edit !== false && (
|
||||
<Link href={`${basePath}/${r.id}/edit`}>
|
||||
{r.deleted_at || r.status === 'archived'
|
||||
? 'Restaurar'
|
||||
: r.status === 'draft'
|
||||
? 'Continuar preenchimento'
|
||||
: 'Editar nova versão'}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import OpportunityProfileActions from '@/components/opportunity-profile-actions';
|
||||
import OpportunityCanonicalFields, {
|
||||
type CanonicalOptions,
|
||||
} from '@/components/opportunity-canonical-fields';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import TerritoryGeometryPreview from '@/components/territory-geometry-preview';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
TerritoryLevel,
|
||||
type TerritoryOption,
|
||||
} from '@/components/investment-territory';
|
||||
import { Management, Field, button, control } from './shared';
|
||||
type Values = Record<string, string | number | null>;
|
||||
import { Management, Field, button, control, states } from './shared';
|
||||
type Values = Record<string, unknown>;
|
||||
type Entry = {
|
||||
id: number;
|
||||
reference: string;
|
||||
@@ -51,8 +55,74 @@ export default function OpportunityDraft({
|
||||
occurred_at: string;
|
||||
}[];
|
||||
}) {
|
||||
const {
|
||||
basePath = '/management/opportunities',
|
||||
profileMode = false,
|
||||
profileOrganization,
|
||||
profileContext,
|
||||
profileAccess,
|
||||
profileActions = [],
|
||||
canonicalOptions,
|
||||
workflowState,
|
||||
workflowTasks = [],
|
||||
workflowParticipants,
|
||||
publicationPreview,
|
||||
publicationState,
|
||||
auth,
|
||||
canEditDraft = false,
|
||||
} = usePage<{
|
||||
basePath?: string;
|
||||
profileMode?: boolean;
|
||||
profileOrganization?: string | null;
|
||||
profileActions?: string[];
|
||||
profileContext?: Record<string, unknown> | null;
|
||||
profileAccess?: {
|
||||
contexts: { organization_id: string | null; type: string }[];
|
||||
} | null;
|
||||
publicationPreview?: {
|
||||
title: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
};
|
||||
workflowParticipants?: {
|
||||
publishers: { id: number; name: string }[];
|
||||
validators: {
|
||||
id: number;
|
||||
name: string;
|
||||
technical: boolean;
|
||||
legal: boolean;
|
||||
}[];
|
||||
};
|
||||
canonicalOptions?: CanonicalOptions;
|
||||
workflowState?: string;
|
||||
publicationState?: {
|
||||
scope: string;
|
||||
label: string;
|
||||
public_catalog: boolean;
|
||||
};
|
||||
workflowTasks?: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
assignee_user_id: number | null;
|
||||
due_at: string | null;
|
||||
}[];
|
||||
auth: { user: { id: number } };
|
||||
canEditDraft?: boolean;
|
||||
}>().props;
|
||||
const [data, setData] = useState<Values>({
|
||||
...values,
|
||||
...(profileMode
|
||||
? {
|
||||
organization_id: record
|
||||
? (profileOrganization ?? null)
|
||||
: (profileAccess?.contexts[0]?.organization_id ?? null),
|
||||
opportunity_category:
|
||||
values.opportunity_category ??
|
||||
profileAccess?.contexts[0]?.type ??
|
||||
'',
|
||||
}
|
||||
: {}),
|
||||
creation_key: crypto.randomUUID(),
|
||||
});
|
||||
const [busy, setBusy] = useState(false),
|
||||
@@ -66,9 +136,10 @@ export default function OpportunityDraft({
|
||||
const unavailable = Boolean(
|
||||
record?.deleted_at || record?.status === 'archived',
|
||||
);
|
||||
const archiveAvailable = Boolean(usePage().props.opportunityManagement);
|
||||
const set = (key: string, value: string) =>
|
||||
setData((d) => ({ ...d, [key]: value }));
|
||||
async function send(action: 'save' | 'delete' | 'restore') {
|
||||
async function send(action: 'save' | 'delete' | 'archive' | 'restore') {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
@@ -80,9 +151,9 @@ export default function OpportunityDraft({
|
||||
const url =
|
||||
action === 'save'
|
||||
? record
|
||||
? `/management/opportunities/${record.id}/draft`
|
||||
: '/management/opportunities/drafts'
|
||||
: `/management/opportunities/${record!.id}/lifecycle`;
|
||||
? `${basePath}/${record.id}/draft`
|
||||
: `${basePath}/drafts`
|
||||
: `${basePath}/${record!.id}/lifecycle`;
|
||||
const response = await fetch(url, {
|
||||
method: action === 'save' && record ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
@@ -116,8 +187,7 @@ export default function OpportunityDraft({
|
||||
}
|
||||
setMessage(result.message);
|
||||
if (result.etag) setToken(result.etag);
|
||||
if (!record)
|
||||
router.visit(`/management/opportunities/${result.id}/edit`);
|
||||
if (!record) router.visit(`${basePath}/${result.id}/edit`);
|
||||
else if (action !== 'save') router.reload();
|
||||
} catch {
|
||||
setMessage(
|
||||
@@ -138,7 +208,61 @@ export default function OpportunityDraft({
|
||||
: 'Nova oportunidade'
|
||||
}
|
||||
>
|
||||
<Link href="/management/opportunities">Voltar à gestão</Link>
|
||||
<Link href={basePath}>
|
||||
{profileMode ? 'Minhas oportunidades' : 'Voltar à gestão'}
|
||||
</Link>
|
||||
{profileMode && record && readOnly && canEditDraft && (
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`${basePath}/${record.id}/edit`}
|
||||
>
|
||||
Editar proposta privada
|
||||
</Link>
|
||||
)}
|
||||
{profileMode && !record && (
|
||||
<label className="block">
|
||||
Organização e tipo autorizados
|
||||
<select
|
||||
className={control}
|
||||
value={`${String(data.organization_id ?? '')}|${String(data.opportunity_category ?? '')}`}
|
||||
onChange={(e) => {
|
||||
const [organization, type] =
|
||||
e.target.value.split('|');
|
||||
setData((d) => ({
|
||||
...d,
|
||||
organization_id: organization || null,
|
||||
opportunity_category: type,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{(profileAccess?.contexts ?? []).map((c) => (
|
||||
<option
|
||||
key={`${c.organization_id}|${c.type}`}
|
||||
value={`${c.organization_id ?? ''}|${c.type}`}
|
||||
>
|
||||
{c.organization_id ?? 'Âmbito individual'} —{' '}
|
||||
{c.type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{profileMode && record && (
|
||||
<p>
|
||||
Estado da preparação:{' '}
|
||||
{(workflowState ?? record.status) === 'published'
|
||||
? 'Versão disponibilizada'
|
||||
: (states[workflowState ?? record.status] ??
|
||||
workflowState ??
|
||||
record.status)}
|
||||
. {publicationState?.label}.{' '}
|
||||
{profileContext?.request_archive
|
||||
? 'Pedido de arquivo registado; aguarda decisão independente.'
|
||||
: ''}{' '}
|
||||
O Promotor solicita arquivo; não elimina nem restaura
|
||||
oportunidades.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Classificação de origem: {classification}. Esta classificação
|
||||
não cria associações territoriais.
|
||||
@@ -147,7 +271,8 @@ export default function OpportunityDraft({
|
||||
<p>
|
||||
Versão anterior fechada. As alterações são guardadas numa
|
||||
nova versão de rascunho; a ficha publicada permanece
|
||||
intacta. Este incremento não submete, aprova ou publica.
|
||||
intacta. Guardar não submete: use as ações do workflow para
|
||||
revisão, aprovação e publicação independentes.
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
@@ -163,7 +288,7 @@ export default function OpportunityDraft({
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`/management/opportunities/${record?.id}/view`}
|
||||
href={`${basePath}/${record?.id}/view`}
|
||||
>
|
||||
Abrir versão atual
|
||||
</a>
|
||||
@@ -177,119 +302,153 @@ export default function OpportunityDraft({
|
||||
void send('save');
|
||||
}}
|
||||
>
|
||||
<fieldset
|
||||
disabled={busy || readOnly || unavailable}
|
||||
className="space-y-4"
|
||||
>
|
||||
<section aria-label="Localização registada">
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={path.at(-1)?.id}
|
||||
management
|
||||
/>
|
||||
<p>
|
||||
Hierarquia do cadastro territorial existente.
|
||||
Alterar esta seleção não cria relações com
|
||||
projetos/clusters.
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
'province_id',
|
||||
'district_id',
|
||||
'admin_post_id',
|
||||
'locality_id',
|
||||
] as const
|
||||
).map((key, index) => (
|
||||
<TerritoryLevel
|
||||
key={`${key}-${path[index - 1]?.id ?? ''}`}
|
||||
index={index}
|
||||
parent={path[index - 1]?.id ?? ''}
|
||||
selected={path[index]}
|
||||
endpoint="/management/territory-options"
|
||||
fieldError={errors[key]}
|
||||
onChange={(value) => {
|
||||
const next = [
|
||||
...path.slice(0, index),
|
||||
...(value ? [value] : []),
|
||||
];
|
||||
setPath(next);
|
||||
setData((previous) => ({
|
||||
...previous,
|
||||
province_id: next[0]?.id ?? '',
|
||||
district_id: next[1]?.id ?? '',
|
||||
admin_post_id: next[2]?.id ?? '',
|
||||
locality_id: next[3]?.id ?? '',
|
||||
}));
|
||||
}}
|
||||
{!profileMode && (
|
||||
<fieldset
|
||||
disabled={busy || readOnly || unavailable}
|
||||
className="space-y-4"
|
||||
>
|
||||
<section aria-label="Localização registada">
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={path.at(-1)?.id}
|
||||
management
|
||||
endpoint={
|
||||
profileMode
|
||||
? '/minhas-oportunidades/territory-geometries'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
<Field
|
||||
label="Categoria"
|
||||
error={errors.opportunity_category}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.opportunity_category ?? '')}
|
||||
onChange={(e) =>
|
||||
set('opportunity_category', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
<p>
|
||||
Hierarquia do cadastro territorial existente.
|
||||
Alterar esta seleção não cria relações com
|
||||
projetos/clusters.
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
'province_id',
|
||||
'district_id',
|
||||
'admin_post_id',
|
||||
'locality_id',
|
||||
] as const
|
||||
).map((key, index) => (
|
||||
<TerritoryLevel
|
||||
key={`${key}-${path[index - 1]?.id ?? ''}`}
|
||||
index={index}
|
||||
parent={path[index - 1]?.id ?? ''}
|
||||
selected={path[index]}
|
||||
endpoint={
|
||||
profileMode
|
||||
? '/minhas-oportunidades/territory-options'
|
||||
: '/management/territory-options'
|
||||
}
|
||||
fieldError={errors[key]}
|
||||
onChange={(value) => {
|
||||
const next = [
|
||||
...path.slice(0, index),
|
||||
...(value ? [value] : []),
|
||||
];
|
||||
setPath(next);
|
||||
setData((previous) => ({
|
||||
...previous,
|
||||
province_id: next[0]?.id ?? '',
|
||||
district_id: next[1]?.id ?? '',
|
||||
admin_post_id: next[2]?.id ?? '',
|
||||
locality_id: next[3]?.id ?? '',
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{Object.entries({
|
||||
title: 'Título oficial',
|
||||
summary: 'Resumo',
|
||||
description: 'Descrição',
|
||||
promoter_name: 'Promotor',
|
||||
}).map(([key, label]) => (
|
||||
<Field key={key} label={label} error={errors[key]}>
|
||||
{key === 'description' || key === 'summary' ? (
|
||||
<textarea
|
||||
className={control}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={control}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<Field
|
||||
label="Categoria"
|
||||
error={errors.opportunity_category}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.opportunity_category ?? '')}
|
||||
onChange={(e) =>
|
||||
set('opportunity_category', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
))}
|
||||
<Field label="Sector" error={errors.sector_id}>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.sector_id ?? '')}
|
||||
onChange={(e) => set('sector_id', e.target.value)}
|
||||
{Object.entries({
|
||||
title: 'Título oficial',
|
||||
summary: 'Resumo',
|
||||
description: 'Descrição',
|
||||
promoter_name: 'Promotor',
|
||||
}).map(([key, label]) => (
|
||||
<Field key={key} label={label} error={errors[key]}>
|
||||
{key === 'description' || key === 'summary' ? (
|
||||
<textarea
|
||||
className={control}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) =>
|
||||
set(key, e.target.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={control}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) =>
|
||||
set(key, e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
<Field label="Sector" error={errors.sector_id}>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.sector_id ?? '')}
|
||||
onChange={(e) =>
|
||||
set('sector_id', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{sectors.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label="Tipo de promotor"
|
||||
error={errors.promoter_type}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{sectors.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label="Tipo de promotor"
|
||||
error={errors.promoter_type}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.promoter_type ?? '')}
|
||||
onChange={(e) =>
|
||||
set('promoter_type', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{values.promoter_type &&
|
||||
![
|
||||
<select
|
||||
className={control}
|
||||
value={String(data.promoter_type ?? '')}
|
||||
onChange={(e) =>
|
||||
set('promoter_type', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{Boolean(values.promoter_type) &&
|
||||
![
|
||||
'public',
|
||||
'private',
|
||||
'ppp',
|
||||
'cooperative',
|
||||
'association',
|
||||
'community',
|
||||
'other',
|
||||
].includes(
|
||||
String(values.promoter_type),
|
||||
) && (
|
||||
<option
|
||||
value={String(values.promoter_type)}
|
||||
>
|
||||
{String(values.promoter_type)}{' '}
|
||||
(valor existente)
|
||||
</option>
|
||||
)}
|
||||
{[
|
||||
'public',
|
||||
'private',
|
||||
'ppp',
|
||||
@@ -297,174 +456,170 @@ export default function OpportunityDraft({
|
||||
'association',
|
||||
'community',
|
||||
'other',
|
||||
].includes(String(values.promoter_type)) && (
|
||||
<option
|
||||
value={String(values.promoter_type)}
|
||||
>
|
||||
{String(values.promoter_type)} (valor
|
||||
existente)
|
||||
</option>
|
||||
)}
|
||||
{[
|
||||
'public',
|
||||
'private',
|
||||
'ppp',
|
||||
'cooperative',
|
||||
'association',
|
||||
'community',
|
||||
'other',
|
||||
].map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label="Etapa de maturidade"
|
||||
error={errors.maturity_stage ?? errors.maturity_level}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={
|
||||
data.maturity_catalogue_version ===
|
||||
maturityCatalogue.version
|
||||
? String(data.maturity_stage ?? '')
|
||||
: data.maturity_level
|
||||
? `legacy:${data.maturity_level}`
|
||||
: ''
|
||||
].map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label="Etapa de maturidade"
|
||||
error={
|
||||
errors.maturity_stage ?? errors.maturity_level
|
||||
}
|
||||
onChange={(e) => {
|
||||
const stage = e.target.value;
|
||||
if (stage.startsWith('legacy:')) {
|
||||
setData((d) => ({
|
||||
...d,
|
||||
maturity_catalogue_version:
|
||||
maturityCatalogue.legacyVersion,
|
||||
maturity_level: Number(stage.slice(7)),
|
||||
maturity_stage: null,
|
||||
}));
|
||||
} else {
|
||||
setData((d) => ({
|
||||
...d,
|
||||
maturity_catalogue_version:
|
||||
maturityCatalogue.version,
|
||||
maturity_stage: stage || null,
|
||||
maturity_level: stage
|
||||
? Object.keys(
|
||||
maturityCatalogue.stages,
|
||||
).indexOf(stage) + 1
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{values.maturity_level &&
|
||||
values.maturity_catalogue_version !==
|
||||
maturityCatalogue.version && (
|
||||
<option
|
||||
value={`legacy:${values.maturity_level}`}
|
||||
>
|
||||
{
|
||||
maturityCatalogue.legacyLabels[
|
||||
String(values.maturity_level)
|
||||
]
|
||||
}{' '}
|
||||
(catálogo histórico; sem conversão)
|
||||
</option>
|
||||
<select
|
||||
className={control}
|
||||
value={
|
||||
data.maturity_catalogue_version ===
|
||||
maturityCatalogue.version
|
||||
? String(data.maturity_stage ?? '')
|
||||
: data.maturity_level
|
||||
? `legacy:${data.maturity_level}`
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const stage = e.target.value;
|
||||
if (stage.startsWith('legacy:')) {
|
||||
setData((d) => ({
|
||||
...d,
|
||||
maturity_catalogue_version:
|
||||
maturityCatalogue.legacyVersion,
|
||||
maturity_level: Number(
|
||||
stage.slice(7),
|
||||
),
|
||||
maturity_stage: null,
|
||||
}));
|
||||
} else {
|
||||
setData((d) => ({
|
||||
...d,
|
||||
maturity_catalogue_version:
|
||||
maturityCatalogue.version,
|
||||
maturity_stage: stage || null,
|
||||
maturity_level: stage
|
||||
? Object.keys(
|
||||
maturityCatalogue.stages,
|
||||
).indexOf(stage) + 1
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{Boolean(values.maturity_level) &&
|
||||
values.maturity_catalogue_version !==
|
||||
maturityCatalogue.version && (
|
||||
<option
|
||||
value={`legacy:${values.maturity_level}`}
|
||||
>
|
||||
{
|
||||
maturityCatalogue.legacyLabels[
|
||||
String(
|
||||
values.maturity_level,
|
||||
)
|
||||
]
|
||||
}{' '}
|
||||
(catálogo histórico; sem conversão)
|
||||
</option>
|
||||
)}
|
||||
{Object.entries(maturityCatalogue.stages).map(
|
||||
([code, label], index) => (
|
||||
<option key={code} value={code}>
|
||||
{index + 1}. {label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
{Object.entries(maturityCatalogue.stages).map(
|
||||
([code, label], index) => (
|
||||
<option key={code} value={code}>
|
||||
{index + 1}. {label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<p>
|
||||
O catálogo de oito etapas é guardado com a sua
|
||||
versão. Os valores históricos conservam o
|
||||
significado anterior.
|
||||
</p>
|
||||
</Field>
|
||||
<Field
|
||||
label="Modalidade de financiamento existente"
|
||||
error={errors.financing_modality}
|
||||
>
|
||||
<input
|
||||
className={control}
|
||||
value={String(data.financing_modality ?? '')}
|
||||
onChange={(e) =>
|
||||
set('financing_modality', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Impacto esperado"
|
||||
error={errors.expected_impact}
|
||||
>
|
||||
<textarea
|
||||
className={control}
|
||||
value={String(data.expected_impact ?? '')}
|
||||
onChange={(e) =>
|
||||
set('expected_impact', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{(data.opportunity_category === 'investment_project' ||
|
||||
(!data.opportunity_category &&
|
||||
data.estimated_investment_amount != null)) && (
|
||||
<>
|
||||
<Field
|
||||
label="Investimento estimado"
|
||||
error={errors.estimated_investment_amount}
|
||||
>
|
||||
<input
|
||||
className={control}
|
||||
value={String(
|
||||
data.estimated_investment_amount ?? '',
|
||||
)}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
'estimated_investment_amount',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Moeda"
|
||||
error={errors.estimated_investment_currency}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(
|
||||
data.estimated_investment_currency ??
|
||||
'',
|
||||
)}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
'estimated_investment_currency',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{['MZN', 'USD', 'EUR', 'ZAR'].map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{data.opportunity_category &&
|
||||
data.opportunity_category !== 'investment_project' && (
|
||||
</select>
|
||||
<p>
|
||||
O módulo especializado desta categoria permanece
|
||||
pendente. Pode guardar a identificação
|
||||
incompleta; não será submetida nem publicada.
|
||||
O catálogo de oito etapas é guardado com a sua
|
||||
versão. Os valores históricos conservam o
|
||||
significado anterior.
|
||||
</p>
|
||||
</Field>
|
||||
<Field
|
||||
label="Modalidade de financiamento existente"
|
||||
error={errors.financing_modality}
|
||||
>
|
||||
<input
|
||||
className={control}
|
||||
value={String(data.financing_modality ?? '')}
|
||||
onChange={(e) =>
|
||||
set('financing_modality', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Impacto esperado"
|
||||
error={errors.expected_impact}
|
||||
>
|
||||
<textarea
|
||||
className={control}
|
||||
value={String(data.expected_impact ?? '')}
|
||||
onChange={(e) =>
|
||||
set('expected_impact', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{(data.opportunity_category === 'investment_project' ||
|
||||
(!data.opportunity_category &&
|
||||
data.estimated_investment_amount != null)) && (
|
||||
<>
|
||||
<Field
|
||||
label="Investimento estimado"
|
||||
error={errors.estimated_investment_amount}
|
||||
>
|
||||
<input
|
||||
className={control}
|
||||
value={String(
|
||||
data.estimated_investment_amount ??
|
||||
'',
|
||||
)}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
'estimated_investment_amount',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Moeda"
|
||||
error={errors.estimated_investment_currency}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={String(
|
||||
data.estimated_investment_currency ??
|
||||
'',
|
||||
)}
|
||||
onChange={(e) =>
|
||||
set(
|
||||
'estimated_investment_currency',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{['MZN', 'USD', 'EUR', 'ZAR'].map(
|
||||
(c) => (
|
||||
<option key={c}>{c}</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
{Boolean(data.opportunity_category) &&
|
||||
data.opportunity_category !==
|
||||
'investment_project' && (
|
||||
<p>
|
||||
Guarde o rascunho incompleto e complete o
|
||||
contrato aplicável abaixo. Submissão e
|
||||
publicação exigem os documentos e as
|
||||
revisões desta categoria.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Field
|
||||
@@ -484,7 +639,7 @@ export default function OpportunityDraft({
|
||||
Guardar rascunho
|
||||
</button>
|
||||
)}
|
||||
{record && (
|
||||
{record && !profileMode && (
|
||||
<>
|
||||
<label className="block">
|
||||
<input
|
||||
@@ -497,7 +652,7 @@ export default function OpportunityDraft({
|
||||
Confirmo a{' '}
|
||||
{unavailable
|
||||
? 'restauração'
|
||||
: 'exclusão lógica/arquivo'}
|
||||
: 'operação indicada, preservando o registo'}
|
||||
, preservando histórico e relações
|
||||
</label>
|
||||
<button
|
||||
@@ -506,16 +661,32 @@ export default function OpportunityDraft({
|
||||
disabled={busy || !confirm}
|
||||
onClick={() =>
|
||||
void send(
|
||||
unavailable ? 'restore' : 'delete',
|
||||
unavailable
|
||||
? 'restore'
|
||||
: archiveAvailable
|
||||
? 'archive'
|
||||
: 'delete',
|
||||
)
|
||||
}
|
||||
>
|
||||
{unavailable
|
||||
? 'Restaurar'
|
||||
: closedVersion
|
||||
: archiveAvailable || closedVersion
|
||||
? 'Arquivar'
|
||||
: 'Excluir rascunho'}
|
||||
</button>
|
||||
{archiveAvailable &&
|
||||
!unavailable &&
|
||||
!closedVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy || !confirm}
|
||||
onClick={() => void send('delete')}
|
||||
>
|
||||
Excluir rascunho
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -525,7 +696,7 @@ export default function OpportunityDraft({
|
||||
<Link
|
||||
href={
|
||||
resourcesInstalled
|
||||
? `/management/opportunities/${record.id}/resources`
|
||||
? `${basePath}/${record.id}/resources`
|
||||
: '/management/opportunity-resources-install'
|
||||
}
|
||||
>
|
||||
@@ -534,11 +705,131 @@ export default function OpportunityDraft({
|
||||
: 'Plano de ativação de imagens, documentos e localizações'}
|
||||
</Link>
|
||||
)}
|
||||
{record && (
|
||||
<Link href={`/management/opportunities/${record.id}/context`}>
|
||||
{record && !profileMode && (
|
||||
<Link href={`${basePath}/${record.id}/context`}>
|
||||
Consultar projetos, clusters e relações existentes
|
||||
</Link>
|
||||
)}
|
||||
{profileMode && canonicalOptions && (
|
||||
<section>
|
||||
<OpportunityCanonicalFields
|
||||
value={
|
||||
(data.canonical ?? {}) as Record<string, unknown>
|
||||
}
|
||||
onChange={(v) =>
|
||||
setData((d) => ({ ...d, canonical: v }))
|
||||
}
|
||||
options={canonicalOptions}
|
||||
errors={errors}
|
||||
category={String(data.opportunity_category ?? '')}
|
||||
organization={
|
||||
(data.organization_id ?? null) as string | null
|
||||
}
|
||||
actorId={auth.user.id}
|
||||
sectors={sectors}
|
||||
disabled={busy || readOnly || unavailable}
|
||||
/>
|
||||
{!readOnly && !unavailable && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy}
|
||||
onClick={() => void send('save')}
|
||||
>
|
||||
Guardar contrato no rascunho
|
||||
</button>
|
||||
)}
|
||||
{Object.entries(errors)
|
||||
.filter(([key]) => key.startsWith('canonical'))
|
||||
.map(([key, msg]) => (
|
||||
<p key={key} role="alert">
|
||||
{key}: {msg}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{profileMode && record && profileActions.includes('publish') && (
|
||||
<section className="rounded border p-3">
|
||||
<h2>Pré-visualização da publicação</h2>
|
||||
<p>
|
||||
Âmbito solicitado:{' '}
|
||||
{(data.canonical as Record<string, unknown> | undefined)
|
||||
?.visibility === 'public'
|
||||
? 'Catálogo público, após ação do Publicador'
|
||||
: 'Acesso restrito aos perfis e à organização autorizados'}
|
||||
</p>
|
||||
<h3>{publicationPreview?.title}</h3>
|
||||
<p>{publicationPreview?.summary}</p>
|
||||
<p>{publicationPreview?.description}</p>
|
||||
<p>
|
||||
A ficha partilha apenas título, resumo, proposta, sector
|
||||
e território. Contactos, avaliações, evidências e
|
||||
originais conservam o acesso restrito.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
{profileMode && record && etag && (
|
||||
<OpportunityProfileActions
|
||||
participants={workflowParticipants?.validators}
|
||||
publishers={workflowParticipants?.publishers}
|
||||
opinions={Object.values(
|
||||
(profileContext?.opinions ?? {}) as Record<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
gate: string;
|
||||
recipient_id: number;
|
||||
status: string;
|
||||
}
|
||||
>,
|
||||
)}
|
||||
onErrors={setErrors}
|
||||
id={record.id}
|
||||
etag={token ?? etag}
|
||||
actions={profileActions}
|
||||
publicationVisibility={String(
|
||||
(data.canonical as Record<string, unknown> | undefined)
|
||||
?.visibility ?? 'internal',
|
||||
)}
|
||||
gates={canonicalOptions?.gates ?? []}
|
||||
documents={(
|
||||
canonicalOptions?.resources.documents ?? []
|
||||
).map((d) => ({
|
||||
id: d.id,
|
||||
title: d.payload.title ?? d.file?.file_name ?? d.id,
|
||||
}))}
|
||||
approvalHash={
|
||||
(
|
||||
profileContext?.approval as
|
||||
| { hash?: string }
|
||||
| undefined
|
||||
)?.hash
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{profileMode && record && (
|
||||
<section className="space-y-2 rounded border p-3">
|
||||
<h2>
|
||||
Acompanhamento interno —{' '}
|
||||
{(workflowState ?? record.status) === 'published'
|
||||
? 'Versão disponibilizada'
|
||||
: (states[workflowState ?? record.status] ??
|
||||
workflowState ??
|
||||
record.status)}
|
||||
</h2>
|
||||
<p>
|
||||
SLA: sem política/prazo aprovado; as tarefas podem ser
|
||||
preparadas em paralelo.
|
||||
</p>
|
||||
{workflowTasks.map((t) => (
|
||||
<p key={t.id}>
|
||||
{t.title} — {t.status}; responsável:{' '}
|
||||
{t.assignee_user_id ?? 'por atribuir'}; prazo:{' '}
|
||||
{t.due_at ?? 'não definido'}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<section aria-label="Histórico de gestão">
|
||||
<h2>Histórico</h2>
|
||||
{history.length ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import TerritoryGeometryPreview from '@/components/territory-geometry-preview';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
@@ -144,9 +144,10 @@ export default function OpportunityResources({
|
||||
assessment: string[];
|
||||
imageProcessingAvailable: boolean;
|
||||
}) {
|
||||
const { basePath = '/management/opportunities', readOnly = false, countries = [] } = usePage<{basePath?: string; readOnly?: boolean; countries?: {code:string;name:string}[]}>().props;
|
||||
const [kind, setKind] = useState<Kind>('locations');
|
||||
const [editing, setEditing] = useState<Resource | null>(null);
|
||||
const [payload, setPayload] = useState<Payload>({});
|
||||
const [payload, setPayload] = useState<Payload>({country_code:'MZ'});
|
||||
const [path, setPath] = useState<TerritoryOption[]>([]);
|
||||
const [geometry, setGeometry] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@@ -161,7 +162,7 @@ export default function OpportunityResources({
|
||||
function select(nextKind: Kind, record: Resource | null = null) {
|
||||
setKind(nextKind);
|
||||
setEditing(record);
|
||||
setPayload(record?.payload ?? {});
|
||||
setPayload(record?.payload ?? (nextKind === 'locations' ? {country_code:'MZ'} : {}));
|
||||
setPath(record?.territoryPath ?? []);
|
||||
setGeometry(
|
||||
record?.payload.geometry
|
||||
@@ -174,7 +175,7 @@ export default function OpportunityResources({
|
||||
setErrors({});
|
||||
}
|
||||
async function execute(action: 'save' | 'delete' | 'restore' | 'prepare') {
|
||||
if (busy) return;
|
||||
if (busy || readOnly) return;
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
setMessage('');
|
||||
@@ -204,7 +205,7 @@ export default function OpportunityResources({
|
||||
form.set('action', action);
|
||||
form.set('confirm', confirm ? '1' : '0');
|
||||
}
|
||||
const url = `/management/opportunities/${opportunity.id}/resources/${kind}${editing ? `/${editing.id}` : ''}${action === 'save' ? '' : action === 'prepare' ? '/prepare' : '/lifecycle'}`;
|
||||
const url = `${basePath}/${opportunity.id}/resources/${kind}${editing ? `/${editing.id}` : ''}${action === 'save' ? '' : action === 'prepare' ? '/prepare' : '/lifecycle'}`;
|
||||
const csrf =
|
||||
document.cookie
|
||||
.split('; ')
|
||||
@@ -266,7 +267,7 @@ export default function OpportunityResources({
|
||||
!!opportunity.deleted_at || opportunity.status === 'archived';
|
||||
return (
|
||||
<Management title="Imagens, documentos e localizações">
|
||||
<Link href={`/management/opportunities/${opportunity.id}/edit`}>
|
||||
<Link href={`${basePath}/${opportunity.id}/edit`}>
|
||||
Regressar ao rascunho #{opportunity.id}
|
||||
</Link>
|
||||
<p>
|
||||
@@ -327,7 +328,7 @@ export default function OpportunityResources({
|
||||
<button
|
||||
className={button}
|
||||
key={k}
|
||||
disabled={busy}
|
||||
disabled={busy || readOnly}
|
||||
onClick={() => select(k)}
|
||||
>
|
||||
{k === 'locations'
|
||||
@@ -388,7 +389,7 @@ export default function OpportunityResources({
|
||||
{r.file?.malware_scan_status === 'clean' &&
|
||||
!r.deleted_at && (
|
||||
<a
|
||||
href={`/management/opportunities/${opportunity.id}/resources/${kind}/${r.id}/download`}
|
||||
href={`${basePath}/${opportunity.id}/resources/${kind}/${r.id}/download`}
|
||||
>
|
||||
Consultar original autorizado
|
||||
</a>
|
||||
@@ -410,9 +411,12 @@ export default function OpportunityResources({
|
||||
</h2>
|
||||
{kind === 'locations' && (
|
||||
<div>
|
||||
<Field label="País da localização" error={errors.country_code}><select className={control} disabled={readOnly} value={String(payload.country_code ?? '')} onChange={e=>set('country_code',e.target.value)}><option value="">Por indicar</option>{countries.map(c=><option key={c.code} value={c.code}>{c.name}</option>)}</select></Field>
|
||||
{payload.country_code !== 'MZ' && <p>A hierarquia abaixo corresponde a Moçambique. Uma localização noutro país requer o seu próprio cadastro; não use territórios moçambicanos para a representar.</p>}
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={path.at(-1)?.id}
|
||||
management
|
||||
endpoint={basePath === '/minhas-oportunidades' ? '/minhas-oportunidades/territory-geometries' : undefined}
|
||||
/>
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<TerritoryLevel
|
||||
@@ -420,6 +424,7 @@ export default function OpportunityResources({
|
||||
index={index}
|
||||
parent={path[index - 1]?.id ?? ''}
|
||||
selected={path[index]}
|
||||
endpoint={basePath === '/minhas-oportunidades' ? '/minhas-oportunidades/territory-options' : '/management/territory-options'}
|
||||
onChange={(value) =>
|
||||
setPath((previous) =>
|
||||
value
|
||||
@@ -638,7 +643,7 @@ export default function OpportunityResources({
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={
|
||||
busy || unavailable || !imageProcessingAvailable
|
||||
busy || readOnly || unavailable || !imageProcessingAvailable
|
||||
}
|
||||
onClick={() => void execute('prepare')}
|
||||
>
|
||||
@@ -647,7 +652,7 @@ export default function OpportunityResources({
|
||||
)}
|
||||
<button
|
||||
className={button}
|
||||
disabled={busy || unavailable || !!editing?.deleted_at}
|
||||
disabled={busy || readOnly || unavailable || !!editing?.deleted_at}
|
||||
>
|
||||
Guardar recurso
|
||||
</button>
|
||||
@@ -655,7 +660,7 @@ export default function OpportunityResources({
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy || unavailable}
|
||||
disabled={busy || readOnly || unavailable}
|
||||
onClick={() =>
|
||||
void execute(
|
||||
editing.deleted_at ? 'restore' : 'delete',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ProfileNavigation from '@/components/profile-navigation';
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cloneElement, isValidElement, useId } from 'react';
|
||||
@@ -46,7 +47,9 @@ export function Management({
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { flash, educationEnabled, aiEnabled } = usePage<{
|
||||
const { flash, educationEnabled, aiEnabled, auth, basePath } = usePage<{
|
||||
auth?: {canManagePortal?: boolean};
|
||||
basePath?: string;
|
||||
flash?: { success?: string };
|
||||
educationEnabled?: boolean;
|
||||
aiEnabled?: boolean;
|
||||
@@ -54,7 +57,7 @@ export function Management({
|
||||
return (
|
||||
<main className="space-y-6 p-4 md:p-8">
|
||||
<Head title={title} />
|
||||
<nav
|
||||
{auth?.canManagePortal && basePath !== '/minhas-oportunidades' ? <nav
|
||||
className="flex flex-wrap gap-4 text-sm underline"
|
||||
aria-label="Gestão do portal"
|
||||
>
|
||||
@@ -102,7 +105,7 @@ export function Management({
|
||||
Referências e formulários
|
||||
</Link>
|
||||
<a href="/">Portal público</a>
|
||||
</nav>
|
||||
</nav> : <ProfileNavigation />}
|
||||
<h1 className="text-2xl font-semibold break-words">{title}</h1>
|
||||
{flash?.success && (
|
||||
<p
|
||||
|
||||
@@ -10,12 +10,14 @@ type Plan = {
|
||||
hash: string;
|
||||
scan_ready: boolean;
|
||||
source_sha256: string;
|
||||
correspondence_version: string;
|
||||
rows: {
|
||||
external_id: string;
|
||||
name: string;
|
||||
parent_name: string | null;
|
||||
location_id: string | null;
|
||||
status: string;
|
||||
correspondence: { target_name: string; target_source_code: string; basis: string } | null;
|
||||
}[];
|
||||
};
|
||||
export default function GeometryImport({ plan }: { plan: Plan }) {
|
||||
@@ -73,7 +75,8 @@ export default function GeometryImport({ plan }: { plan: Plan }) {
|
||||
<Management title="Importação cartográfica">
|
||||
<p>
|
||||
Exemplar INE de 07/06/2019 aceite. Correspondência exata de nome
|
||||
e pai; sem alterações de classificações territoriais,
|
||||
e pai, ou correspondência explícita fundamentada abaixo;
|
||||
sem alterações de classificações territoriais,
|
||||
disponibilidade fundiária ou cálculos.
|
||||
</p>
|
||||
<Field label="Nível">
|
||||
@@ -105,6 +108,12 @@ export default function GeometryImport({ plan }: { plan: Plan }) {
|
||||
<p className="break-all">
|
||||
SHA-256 do original: {plan.source_sha256}
|
||||
</p>
|
||||
<p>Versão das correspondências: {plan.correspondence_version}.</p>
|
||||
<button className={button} disabled={busy} onClick={() => {
|
||||
setApplyKey(crypto.randomUUID());
|
||||
setConfirmed(false);
|
||||
router.reload({ only: ["plan"] });
|
||||
}}>Atualizar pré-visualização</button>
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -113,6 +122,7 @@ export default function GeometryImport({ plan }: { plan: Plan }) {
|
||||
<th>Nome</th>
|
||||
<th>Pai</th>
|
||||
<th>Correspondência</th>
|
||||
<th>Fundamento</th>
|
||||
<th>Ação prevista</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -123,6 +133,10 @@ export default function GeometryImport({ plan }: { plan: Plan }) {
|
||||
<td>{r.name}</td>
|
||||
<td>{r.parent_name ?? "—"}</td>
|
||||
<td>{r.location_id ?? "Pendente"}</td>
|
||||
<td>{r.correspondence ? <>
|
||||
{r.correspondence.target_name} (código {r.correspondence.target_source_code})
|
||||
<p>{r.correspondence.basis}</p>
|
||||
</> : "Nome único sob o pai correspondido"}</td>
|
||||
<td>{r.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import ProfileNavigation from '@/components/profile-navigation';
|
||||
import { profileJson } from '@/lib/profile-json';
|
||||
import { button, control } from '@/pages/management/shared';
|
||||
|
||||
type Entry = {id: string; user_id: number; status: string; version: number; opportunity_types: string; document_ids: string; reason: string};
|
||||
type SecurityEntry = {id: string; kind: string; requested_by: number; subject_user_id: number; scope: string; reason: string; approved_by: number | null; consumed_at: string | null; expires_at: string};
|
||||
type Pair = {first_user_id: number; second_user_id: number; version: number};
|
||||
function Grant({types, organizations}: {types: string[]; organizations: {id: string; name: string}[]}) {
|
||||
const [user, setUser] = useState(''); const [role, setRole] = useState('validator'); const [organization, setOrganization] = useState('');
|
||||
const [selected, setSelected] = useState<string[]>([]); const [responsibilities, setResponsibilities] = useState<string[]>([]);
|
||||
const [reason, setReason] = useState(''); const [expires, setExpires] = useState(''); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false);
|
||||
const available = role === 'validator' ? ['validation_coordinator','technical_reviewer','legal_reviewer','appeal_reviewer'] : role === 'publisher' ? ['publication_coordinator'] : [];
|
||||
const toggle = (values: string[], value: string) => values.includes(value) ? values.filter(v => v !== value) : [...values,value];
|
||||
async function send() { setBusy(true); try { await profileJson('/perfis/atribuicoes', {user_id: Number(user), role, organization_id: organization || null, opportunity_types: selected, responsibilities, expires_at: expires || null, reason}); setMessage('Atribuição registada.'); router.reload(); } catch(error) {setMessage(error instanceof Error ? error.message : 'Resposta indisponível.');} finally {setBusy(false);} }
|
||||
return <section className="space-y-3"><h2 className="text-xl font-semibold">Atribuir papel operacional</h2><p>Promotor exige pedido documental independente. Superutilizador é gerido pela dupla, sem terceira atribuição.</p>
|
||||
<form className="max-w-2xl space-y-3" onSubmit={e => {e.preventDefault(); void send();}}>
|
||||
<label className="block">ID da conta existente<input className={control} type="number" min={1} required value={user} onChange={e => setUser(e.target.value)} /></label>
|
||||
<label className="block">Papel<select className={control} value={role} onChange={e => {setRole(e.target.value); setResponsibilities([]);}}>{['validator','publisher','auditor'].map(v => <option key={v}>{v}</option>)}</select></label>
|
||||
<label className="block">Âmbito organizacional<select className={control} value={organization} onChange={e => setOrganization(e.target.value)}><option value="">Todos os âmbitos para os tipos selecionados</option>{organizations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}</select></label>
|
||||
<fieldset><legend>Tipos autorizados</legend>{types.map(v => <label className="block" key={v}><input type="checkbox" checked={selected.includes(v)} onChange={() => setSelected(toggle(selected,v))} /> {v}</label>)}</fieldset>
|
||||
<fieldset><legend>Responsabilidades adicionais</legend>{available.map(v => <label className="block" key={v}><input type="checkbox" checked={responsibilities.includes(v)} onChange={() => setResponsibilities(toggle(responsibilities,v))} /> {v}</label>)}<p>Atribuir a responsabilidade não conclui filas, recursos ou escalamentos ainda em desenvolvimento.</p></fieldset>
|
||||
<label className="block">Validade opcional<input className={control} type="datetime-local" value={expires} onChange={e => setExpires(e.target.value)} /></label>
|
||||
<label className="block">Fundamento<textarea required className={control} minLength={20} maxLength={1000} value={reason} onChange={e => setReason(e.target.value)} /></label>
|
||||
<button className={button} disabled={busy}>Atribuir com auditoria</button><p role="status">{message}</p>
|
||||
</form></section>;
|
||||
}
|
||||
function Security({pair, entries, actorId}: {pair: Pair; entries: SecurityEntry[]; actorId: number}) {
|
||||
const [old, setOld] = useState(String(pair.second_user_id)); const [candidate, setCandidate] = useState(''); const [reason, setReason] = useState('');
|
||||
const [action, setAction] = useState('edit'); const [record, setRecord] = useState(''); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false);
|
||||
async function send(path: string, data: object) {setBusy(true); try {const result = await profileJson(path, data); setMessage(result.message ?? `Pedido registado: ${result.id}`); router.reload();} catch(error) {setMessage(error instanceof Error ? error.message : 'Resposta indisponível.');} finally {setBusy(false);}}
|
||||
return <section className="space-y-3"><h2 className="text-xl font-semibold">Operações críticas</h2>
|
||||
<label className="block">Fundamento<textarea className={control} minLength={20} maxLength={1000} value={reason} onChange={e => setReason(e.target.value)} /></label>
|
||||
<form className="max-w-2xl space-y-3" onSubmit={e => {e.preventDefault(); void send('/perfis/substituicoes', {old_user_id: Number(old), new_user_id: Number(candidate), reason});}}>
|
||||
<label className="block">Titular a substituir<select className={control} value={old} onChange={e => setOld(e.target.value)}>{[pair.first_user_id,pair.second_user_id].map(v => <option key={v}>{v}</option>)}</select></label>
|
||||
<label className="block">ID do candidato com MFA confirmado<input className={control} type="number" min={1} required value={candidate} onChange={e => setCandidate(e.target.value)} /></label><button className={button} disabled={busy}>Preparar substituição atómica</button>
|
||||
</form>
|
||||
<form className="max-w-2xl space-y-3" onSubmit={e => {e.preventDefault(); void send('/perfis/elevacoes', {action, record_id: record ? Number(record) : null, reason});}}>
|
||||
<label className="block">Operação a elevar<select className={control} value={action} onChange={e => setAction(e.target.value)}>{['create','edit','validate','publish','archive'].map(v => <option key={v}>{v}</option>)}</select></label>
|
||||
<label className="block">ID da oportunidade<input className={control} type="number" min={1} required={action !== 'create'} value={record} onChange={e => setRecord(e.target.value)} /></label><button className={button} disabled={busy}>Pedir elevação temporária</button><p>Exige aprovação do outro titular e papel operacional válido. Não remove impedimentos de autoria ou Auditor.</p>
|
||||
</form>
|
||||
{entries.map(entry => <article key={entry.id} className="space-y-2 border p-3"><p>{entry.kind} — {entry.id} — solicitante {entry.requested_by}, destinatário {entry.subject_user_id}</p><p>{entry.reason}; validade: {entry.expires_at}</p><p>Âmbito: {entry.scope}</p>
|
||||
{!entry.consumed_at && !entry.approved_by && entry.requested_by !== actorId && entry.subject_user_id !== actorId && <button disabled={busy} className={button} onClick={() => void send(`/perfis/seguranca/${entry.id}/aprovar`, {})}>Aprovar independentemente</button>}
|
||||
{!entry.consumed_at && entry.approved_by && entry.requested_by === actorId && entry.kind === 'replacement' && <button disabled={busy} className={button} onClick={() => void send(`/perfis/substituicoes/${entry.id}/aplicar`, {})}>Aplicar substituição aprovada</button>}
|
||||
<p>{entry.consumed_at ? 'Aplicado' : entry.approved_by ? `Aprovado pela conta ${entry.approved_by}` : 'Aguarda aprovação independente'}</p>
|
||||
</article>)}<p role="status">{message}</p></section>;
|
||||
}
|
||||
function Decision({ entry }: {entry: Entry}) {
|
||||
const [reason, setReason] = useState(''); const [verified, setVerified] = useState(false); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false);
|
||||
const types: string[] = JSON.parse(entry.opportunity_types);
|
||||
const [approved, setApproved] = useState<string[]>([]);
|
||||
async function send(decision: string) {
|
||||
setBusy(true); try { await profileJson(`/perfis/pedidos/${entry.id}/decisao`, {version: entry.version, decision, reason, approved_types: approved,
|
||||
verified_identity: verified, verified_representation: verified, verified_documents: verified}); router.reload(); }
|
||||
catch(error) { setMessage(error instanceof Error ? error.message : 'Resposta indisponível.'); } finally { setBusy(false); }
|
||||
}
|
||||
return <article className="space-y-2 rounded border p-4"><h3>Pedido {entry.id} — conta {entry.user_id} — {entry.status}</h3><p>{entry.reason}</p><p>Comprovativos canónicos: {(JSON.parse(entry.document_ids) as string[]).map(id => <Link className="mr-3 underline" key={id} href={`/perfis/pedidos/${entry.id}/documentos/${id}`} target="_blank">Consultar {id}</Link>)}</p>
|
||||
{entry.status === 'pending' && <><fieldset><legend>Tipos efetivamente aprovados</legend>{types.map(type => <label className="block" key={type}><input type="checkbox" checked={approved.includes(type)} onChange={() => setApproved(v => v.includes(type) ? v.filter(x => x !== type) : [...v,type])} /> {type}</label>)}</fieldset>
|
||||
<label className="block"><input type="checkbox" checked={verified} onChange={e => setVerified(e.target.checked)} /> Conferi identidade, representação e comprovativos; esta declaração fica auditada.</label>
|
||||
<label className="block">Fundamento<textarea className={control} minLength={20} maxLength={1000} value={reason} onChange={e => setReason(e.target.value)} /></label>
|
||||
<button className={button} disabled={busy} onClick={() => void send('approved')}>Aprovar tipos selecionados</button> <button className={button} disabled={busy} onClick={() => void send('rejected')}>Rejeitar pedido</button></>}
|
||||
<p role="status">{message}</p></article>;
|
||||
}
|
||||
export default function Administration({requests, pair, assignments, securityAlerts, actorId, types, organizations, securityRequests}: {actorId: number; types: string[]; organizations: {id: string; name: string}[]; securityRequests: SecurityEntry[]; securityAlerts: {id: string; action: string; actor_user_id: number; occurred_at: string}[]; requests: Entry[]; pair: Pair; assignments: {id: string; user_id: number; role: string; revoked_at: string | null}[]}) {
|
||||
const [message, setMessage] = useState(''); const [reason, setReason] = useState('');
|
||||
async function revoke(id: string) { try { await profileJson(`/perfis/atribuicoes/${id}/revogar`, {reason}); router.reload(); } catch(error) { setMessage(error instanceof Error ? error.message : 'Resposta indisponível.'); } }
|
||||
return <main className="space-y-6 p-6"><Head title="Gestão de perfis" /><ProfileNavigation /><h1 className="text-2xl font-semibold">Gestão de perfis</h1>
|
||||
<p>Dupla de superutilizadores: contas {pair.first_user_id} e {pair.second_user_id}; versão {pair.version}. Substituição exige operação atómica e aprovação independente. Não há remoção isolada.</p>
|
||||
<section aria-label="Alertas de segurança"><h2 className="text-xl font-semibold">Alertas de segurança</h2>{securityAlerts.map(a => <p key={a.id}>{a.occurred_at}: {a.action} — conta {a.actor_user_id}</p>)}</section>
|
||||
<section><h2 className="text-xl font-semibold">Pedidos de Promotor</h2>{requests.map(r => <Decision key={r.id} entry={r} />)}</section>
|
||||
<Grant types={types} organizations={organizations} /><Security pair={pair} entries={securityRequests} actorId={actorId} />
|
||||
<section><h2 className="text-xl font-semibold">Atribuições e revogação</h2><label>Motivo de revogação<textarea className={control} minLength={20} maxLength={1000} value={reason} onChange={e => setReason(e.target.value)} /></label>
|
||||
{assignments.map(a => <p key={a.id}>Conta {a.user_id}: {a.role} — {a.revoked_at ? 'revogado' : 'atribuído'} {!a.revoked_at && <button className={button} onClick={() => void revoke(a.id)}>Revogar e invalidar sessões</button>}</p>)}<p role="status">{message}</p></section>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import ProfileNavigation from '@/components/profile-navigation';
|
||||
import { button, control, Pagination, type Paginated } from '@/pages/management/shared';
|
||||
|
||||
type Event = {id: string; action: string; actor_user_id: number | null; entity_type: string; source_system: string; occurred_at: string};
|
||||
export default function Audit({records, filters}: {records: Paginated<Event>; filters: Record<string,string>}) {
|
||||
const form = useForm({actor_id: filters.actor_id ?? '', action: filters.action ?? '', record_id: filters.record_id ?? '', from: filters.from ?? '', until: filters.until ?? ''});
|
||||
const search = new URLSearchParams(Object.entries(form.data).filter(([,value]) => value !== ''));
|
||||
search.set('format', 'json');
|
||||
return <main className="space-y-6 p-6"><Head title="Auditoria de acessos" /><ProfileNavigation /><h1 className="text-2xl font-semibold">Auditoria de acessos e decisões</h1>
|
||||
<p>Consulta somente de leitura. A exportação contém metadados; não inclui credenciais, documentos nem snapshots privados decifrados. Cada exportação apresenta o limite aplicado.</p>
|
||||
<form className="grid gap-3 sm:grid-cols-3" onSubmit={e => {e.preventDefault(); form.get('/perfis/auditoria');}}>
|
||||
{(['actor_id','action','record_id','from','until'] as const).map(field => <label key={field}>{field}<input className={control} type={field === 'from' || field === 'until' ? 'datetime-local' : 'text'} value={form.data[field]} onChange={e => form.setData(field,e.target.value)} /></label>)}
|
||||
<button className={button}>Filtrar</button>
|
||||
</form>
|
||||
{Object.values(form.errors).map(error => <p role="alert" key={error}>{error}</p>)}
|
||||
<a className="underline" href={`/perfis/auditoria?${search}`}>Exportar metadados JSON, até 1.000 eventos</a>
|
||||
<div className="overflow-auto"><table className="w-full"><thead><tr><th>Data UTC</th><th>Conta</th><th>Ação</th><th>Recurso</th><th>Origem</th></tr></thead><tbody>{records.data.map(e => <tr key={e.id}><td>{e.occurred_at}</td><td>{e.actor_user_id}</td><td>{e.action}</td><td>{e.entity_type}</td><td>{e.source_system}</td></tr>)}</tbody></table></div>
|
||||
{!records.data.length && <p>Nenhum evento corresponde aos filtros.</p>}<Pagination page={records} />
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState } from 'react';
|
||||
import { Management, button, control } from '@/pages/management/shared';
|
||||
import { profileJson } from '@/lib/profile-json';
|
||||
|
||||
type Plan = {
|
||||
installed: boolean;
|
||||
consistent: boolean;
|
||||
ready: boolean;
|
||||
target: { database: string; port: number };
|
||||
hash: string;
|
||||
dependencies: Record<string, boolean>;
|
||||
changes: string[];
|
||||
backupDate: string | null;
|
||||
rollback: string;
|
||||
bootstrap: {
|
||||
initialized: boolean;
|
||||
holders: number[];
|
||||
version: number | null;
|
||||
rule: string;
|
||||
};
|
||||
};
|
||||
export default function Installation(initial: Plan) {
|
||||
const [plan, setPlan] = useState(initial);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [first, setFirst] = useState('');
|
||||
const [second, setSecond] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
async function run(bootstrap = false) {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await profileJson(
|
||||
'/management/profile-install' + (bootstrap ? '/bootstrap' : ''),
|
||||
{
|
||||
hash: plan.hash,
|
||||
confirm: confirmed,
|
||||
reason,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
...(bootstrap
|
||||
? {
|
||||
first_user_id: Number(first),
|
||||
second_user_id: Number(second),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
setMessage(
|
||||
bootstrap
|
||||
? 'Dupla inicializada. Inicie uma nova sessão como um dos titulares designados; MFA permanece obrigatório.'
|
||||
: result.changed
|
||||
? 'Schema instalado. Perfis ainda não disponibilizados; nenhuma conta ou atribuição criada.'
|
||||
: 'Schema já instalado. Nenhuma reinstalação ou duplicação.',
|
||||
);
|
||||
if (!bootstrap)
|
||||
setPlan(
|
||||
(await profileJson(
|
||||
'/management/profile-install',
|
||||
{},
|
||||
'GET',
|
||||
)) as Plan,
|
||||
);
|
||||
setConfirmed(false);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Resposta indisponível; consulte o estado antes de repetir.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Management title="Instalação dos perfis — 040000">
|
||||
<p>
|
||||
Base {plan.target.database}:{plan.target.port} ·{' '}
|
||||
{plan.installed ? 'Schema instalado' : 'Schema por instalar'}
|
||||
</p>
|
||||
<p>
|
||||
Preparação:{' '}
|
||||
{plan.ready
|
||||
? 'Provas atuais verificadas'
|
||||
: 'Dependências ou provas por completar'}
|
||||
. Preparação não significa instalação ou disponibilização dos
|
||||
perfis.
|
||||
</p>
|
||||
{!plan.consistent && (
|
||||
<p role="alert">
|
||||
Ledger e schema divergem. Recuperar a consistência antes de
|
||||
instalar.
|
||||
</p>
|
||||
)}
|
||||
<ul>
|
||||
{Object.entries(plan.dependencies).map(([key, ok]) => (
|
||||
<li key={key}>
|
||||
{key}: {ok ? 'presente' : 'pendente'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul>
|
||||
{plan.changes.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>Backup/restauro: {plan.backupDate ?? 'Prova atual pendente'}</p>
|
||||
<p>{plan.rollback}</p>
|
||||
<label className="block">
|
||||
Fundamento
|
||||
<textarea
|
||||
className={control}
|
||||
value={reason}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>{' '}
|
||||
Confirmo exclusivamente a ação indicada nesta base
|
||||
</label>
|
||||
<button
|
||||
className={button}
|
||||
disabled={
|
||||
busy ||
|
||||
!confirmed ||
|
||||
!plan.ready ||
|
||||
plan.installed ||
|
||||
!plan.consistent ||
|
||||
reason.length < 20
|
||||
}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
Instalar schema 040000
|
||||
</button>
|
||||
<section className="space-y-3 rounded border p-4">
|
||||
<h2>Inicialização separada dos dois superutilizadores</h2>
|
||||
<p>{plan.bootstrap.rule}</p>
|
||||
{plan.bootstrap.initialized ? (
|
||||
<p>
|
||||
Dupla já inicializada:{' '}
|
||||
{plan.bootstrap.holders.join(' e ')}; versão{' '}
|
||||
{plan.bootstrap.version}. Use a substituição atómica
|
||||
autorizada; não reinicialize.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="block">
|
||||
ID da primeira conta explicitamente designada
|
||||
<input
|
||||
className={control}
|
||||
type="number"
|
||||
min={1}
|
||||
value={first}
|
||||
onChange={(e) => setFirst(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
ID da segunda conta explicitamente designada
|
||||
<input
|
||||
className={control}
|
||||
type="number"
|
||||
min={1}
|
||||
value={second}
|
||||
onChange={(e) => setSecond(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className={button}
|
||||
disabled={
|
||||
busy ||
|
||||
!confirmed ||
|
||||
!plan.installed ||
|
||||
!first ||
|
||||
!second ||
|
||||
first === second ||
|
||||
reason.length < 20
|
||||
}
|
||||
onClick={() => void run(true)}
|
||||
>
|
||||
Inicializar apenas estas duas contas
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<p role="status">{message}</p>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import ProfileNavigation from '@/components/profile-navigation';
|
||||
import { profileJson } from '@/lib/profile-json';
|
||||
import { button, control } from '@/pages/management/shared';
|
||||
|
||||
type RequestEntry = { id: string; status: string; reason: string; decision_reason: string | null; created_at: string };
|
||||
export default function RequestProfile({ requests, types, organizations, documents }: {
|
||||
requests: RequestEntry[]; types: string[]; organizations: {id: string; name: string}[]; documents: {id: string; file_name: string}[];
|
||||
}) {
|
||||
const [representation, setRepresentation] = useState('individual');
|
||||
const [organization, setOrganization] = useState('');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [proofs, setProofs] = useState<string[]>([]);
|
||||
const [reason, setReason] = useState('');
|
||||
const [key, setKey] = useState(() => crypto.randomUUID());
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function send() {
|
||||
setBusy(true); setMessage('');
|
||||
try {
|
||||
const result = await profileJson('/perfil/promotor', { creation_key: key, representation_type: representation, organization_id: representation === 'individual' ? null : organization,
|
||||
opportunity_types: selected, document_ids: proofs, reason });
|
||||
setMessage(result.message); setKey(crypto.randomUUID()); router.reload();
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : 'Resposta indisponível. Confira o acompanhamento antes de repetir.'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const toggle = (values: string[], id: string) => values.includes(id) ? values.filter(v => v !== id) : [...values, id];
|
||||
return <main className="space-y-6 p-6"><Head title="Perfil de Promotor" /><ProfileNavigation />
|
||||
<h1 className="text-2xl font-semibold">Solicitar perfil de Promotor</h1>
|
||||
<p>A aprovação confirma identidade, representação e comprovativos. Só os tipos aprovados ficam disponíveis; o pedido não ativa o perfil.</p>
|
||||
<form className="max-w-2xl space-y-4" onSubmit={e => { e.preventDefault(); void send(); }}>
|
||||
<label className="block">Representação<select className={control} value={representation} onChange={e => setRepresentation(e.target.value)}>
|
||||
{['individual','company','public_entity','association','partner'].map(v => <option key={v}>{v}</option>)}
|
||||
</select></label>
|
||||
{representation !== 'individual' && <label className="block">Organização representada<select required className={control} value={organization} onChange={e => setOrganization(e.target.value)}>
|
||||
<option value="">Selecionar</option>{organizations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select></label>}
|
||||
<fieldset><legend>Tipos pretendidos</legend>{types.map(type => <label key={type} className="block"><input type="checkbox" checked={selected.includes(type)} onChange={() => setSelected(toggle(selected,type))} /> {type}</label>)}</fieldset>
|
||||
<fieldset><legend>Comprovativos próprios analisados</legend>{documents.map(d => <label key={d.id} className="block"><input type="checkbox" checked={proofs.includes(d.id)} onChange={() => setProofs(toggle(proofs,d.id))} /> {d.file_name}</label>)}</fieldset>
|
||||
{!documents.length && <p>Sem comprovativos próprios disponíveis. Pode anexar ao seu dossier no <Link className="underline" href="/balcao-virtual/painel">Balcão</Link>; o ficheiro precisa de análise antimalware concluída.</p>}
|
||||
<label className="block">Fundamentação<textarea required minLength={20} maxLength={1000} className={control} value={reason} onChange={e => setReason(e.target.value)} /></label>
|
||||
<button disabled={busy} className={button}>Submeter pedido de perfil</button>
|
||||
</form>
|
||||
<p role="status" aria-live="polite">{message}</p>
|
||||
<section><h2 className="text-xl font-semibold">Acompanhamento dos meus pedidos</h2>{requests.map(r => <article key={r.id} className="border-b py-3"><p>{r.id} — {r.status}</p><p>{r.decision_reason ?? 'Aguarda revisão independente.'}</p></article>)}{!requests.length && <p>Nenhum pedido recebido.</p>}</section>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type OpportunityManagementAccess = {
|
||||
installed: boolean;
|
||||
manage: { label: string; href: string };
|
||||
create: { label: string; href: string };
|
||||
installationHref: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export type ProfileAccess = {
|
||||
contexts: { organization_id: string | null; type: string }[];
|
||||
mine: string | null;
|
||||
create: string | null;
|
||||
request: string;
|
||||
review: string | null;
|
||||
audit?: string | null;
|
||||
administration: string | null;
|
||||
};
|
||||
@@ -26,6 +26,7 @@ use App\Http\Controllers\OpportunityManagementController;
|
||||
use App\Http\Controllers\OpportunityOriginReviewController;
|
||||
use App\Http\Controllers\OpportunityResourceController;
|
||||
use App\Http\Controllers\PortalManagementController;
|
||||
use App\Http\Controllers\PortalProfileController;
|
||||
use App\Http\Controllers\PortalServicesController;
|
||||
use App\Http\Controllers\PublicBusinessOpportunityController;
|
||||
use App\Http\Controllers\PublicGeographyController;
|
||||
@@ -33,6 +34,7 @@ use App\Http\Controllers\PublicPortalController;
|
||||
use App\Http\Controllers\TerritoryGeometryImportController;
|
||||
use App\Http\Middleware\AiEnabled;
|
||||
use App\Http\Middleware\EducationEnabled;
|
||||
use App\Http\Middleware\EnsurePortalProfiles;
|
||||
use App\Http\Middleware\RequireZoneMfa;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
@@ -108,6 +110,46 @@ Route::middleware(['auth', 'verified', 'can:manage-portal', AiEnabled::class])->
|
||||
Route::get('/api/v1/investment/territory-options', [PublicGeographyController::class, 'options'])->middleware('throttle:api');
|
||||
Route::get('/api/v1/investment/territory-geometries', [InvestmentTerritoryController::class, 'geometries'])->middleware('throttle:api');
|
||||
|
||||
// Installation must be reachable before the profile schema/feature is enabled.
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/management/profile-install', [\App\Http\Controllers\PortalProfileInstallationController::class, 'preview']);
|
||||
Route::post('/management/profile-install', [\App\Http\Controllers\PortalProfileInstallationController::class, 'apply']);
|
||||
Route::post('/management/profile-install/bootstrap', [\App\Http\Controllers\PortalProfileInstallationController::class, 'bootstrap']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(function () {
|
||||
$profiles = PortalProfileController::class;
|
||||
Route::get('/perfil/promotor', [$profiles, 'requests']);
|
||||
Route::post('/perfil/promotor', [$profiles, 'requestProfile']);
|
||||
Route::get('/perfil/promotor/{id}', [$profiles, 'requestStatus'])->whereUuid('id');
|
||||
Route::get('/perfis/gestao', [$profiles, 'administration']);
|
||||
Route::get('/perfis/auditoria', [$profiles, 'audit']);
|
||||
Route::post('/perfis/pedidos/{id}/decisao', [$profiles, 'decide'])->whereUuid('id');
|
||||
Route::get('/perfis/pedidos/{id}/documentos/{documentId}', [$profiles, 'requestDocument'])->whereUuid(['id', 'documentId']);
|
||||
Route::post('/perfis/atribuicoes', [$profiles, 'grant']);
|
||||
Route::post('/perfis/atribuicoes/{id}/revogar', [$profiles, 'revoke'])->whereUuid('id');
|
||||
Route::post('/perfis/substituicoes', [$profiles, 'replacement']);
|
||||
Route::post('/perfis/seguranca/{id}/aprovar', [$profiles, 'approveSecurity'])->whereUuid('id');
|
||||
Route::post('/perfis/substituicoes/{id}/aplicar', [$profiles, 'replace'])->whereUuid('id');
|
||||
Route::post('/perfis/elevacoes', [$profiles, 'elevation']);
|
||||
Route::get('/minhas-oportunidades', [$profiles, 'opportunities']);
|
||||
Route::get('/minhas-oportunidades/territory-options', [InvestmentTerritoryController::class, 'options']);
|
||||
Route::get('/minhas-oportunidades/territory-geometries', [InvestmentTerritoryController::class, 'geometries']);
|
||||
Route::get('/minhas-oportunidades/create', [$profiles, 'edit']);
|
||||
Route::get('/minhas-oportunidades/{id}/edit', [$profiles, 'edit'])->whereNumber('id');
|
||||
Route::get('/minhas-oportunidades/{id}/view', [$profiles, 'edit'])->whereNumber('id');
|
||||
Route::post('/minhas-oportunidades/drafts', [OpportunityManagementController::class, 'save']);
|
||||
Route::put('/minhas-oportunidades/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
|
||||
Route::post('/minhas-oportunidades/{id}/workflow', [$profiles, 'workflow'])->whereNumber('id');
|
||||
Route::get('/minhas-oportunidades/{id}/resources', [OpportunityResourceController::class, 'index'])->whereNumber('id');
|
||||
Route::post('/minhas-oportunidades/{id}/resources/{kind}', [OpportunityResourceController::class, 'save'])->whereNumber('id');
|
||||
Route::post('/minhas-oportunidades/{id}/resources/{kind}/{resourceId}', [OpportunityResourceController::class, 'save'])->whereNumber('id');
|
||||
Route::post('/minhas-oportunidades/{id}/resources/{kind}/{resourceId}/lifecycle', [OpportunityResourceController::class, 'lifecycle'])->whereNumber('id');
|
||||
Route::post('/minhas-oportunidades/{id}/resources/images/{resourceId}/prepare', [OpportunityResourceController::class, 'prepareImage'])->whereNumber('id');
|
||||
Route::get('/minhas-oportunidades/{id}/resources/{kind}/{resourceId}/download', [OpportunityResourceController::class, 'download'])->whereNumber('id');
|
||||
Route::get('/minhas-oportunidades/{id}/resources/images/{resourceId}/preview/{documentId}', [OpportunityResourceController::class, 'previewImage'])->whereNumber('id')->middleware('signed')->name('promoter.opportunity-image-preview');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management')->name('management.')->group(function () {
|
||||
Route::get('territory-geometry-import', [TerritoryGeometryImportController::class, 'preview']);
|
||||
Route::post('territory-geometry-import', [TerritoryGeometryImportController::class, 'apply'])->middleware(RequireZoneMfa::class);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityManagement;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardOpportunityAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_dashboard_exposes_authorized_destinations_and_complete_draft_archive_recovery(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email], 'portal.opportunity_dashboard_access_enabled' => true]);
|
||||
$this->actingAs($manager)->get('/dashboard')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('dashboard')->where('auth.canManagePortal', true)
|
||||
->where('opportunityManagement.installed', true)
|
||||
->where('opportunityManagement.manage.label', 'Gestão de Oportunidades')
|
||||
->where('opportunityManagement.create.label', 'Nova oportunidade'));
|
||||
// Discover destinations from the dashboard response, rather than assuming
|
||||
// a successful direct request proves dashboard navigation.
|
||||
$dashboard = $this->get('/dashboard')->viewData('page')['props']['opportunityManagement'];
|
||||
$this->get($dashboard['manage']['href'])->assertInertia(fn (Assert $page) => $page->component('management/opportunities'));
|
||||
$this->get($dashboard['create']['href'])->assertInertia(fn (Assert $page) => $page->component('management/opportunity-draft')->where('record', null));
|
||||
$draft = $this->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'title' => 'QA dashboard draft', 'reason' => 'Criação de rascunho pelo painel de gestão QA'])->assertCreated()->json();
|
||||
$id = $draft['id'];
|
||||
$this->get('/management/opportunities/'.$id.'/edit')->assertInertia(fn (Assert $page) => $page->where('values.title', 'QA dashboard draft'));
|
||||
$update = $this->putJson('/management/opportunities/'.$id.'/draft', ['etag' => $draft['etag'], 'title' => 'QA dashboard edited', 'reason' => 'Edição e recuperação autorizadas no ensaio QA'])->assertOk()->json();
|
||||
$request = ['etag' => $update['etag'], 'action' => 'archive', 'reason' => 'Arquivo explícito sem eliminar o rascunho QA', 'confirm' => true];
|
||||
$this->postJson('/management/opportunities/'.$id.'/lifecycle', [...$request, 'confirm' => false])->assertUnprocessable();
|
||||
$this->postJson('/management/opportunities/'.$id.'/lifecycle', $request)->assertOk();
|
||||
$this->assertDatabaseHas('opportunities', ['id' => $id, 'status' => 'archived', 'title' => 'QA dashboard edited', 'deleted_at' => null, 'first_submitted_at' => null, 'management_closed_once' => false]);
|
||||
$this->get($dashboard['manage']['href'].'?status=archived')->assertInertia(fn (Assert $page) => $page->where('records.data.0.id', $id));
|
||||
$this->putJson('/management/opportunities/'.$id.'/draft', ['etag' => $update['etag'], 'reason' => $request['reason']])->assertConflict();
|
||||
$this->postJson('/management/opportunities/'.$id.'/lifecycle', [...$request, 'action' => 'restore', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))])->assertOk();
|
||||
$this->get('/management/opportunities/'.$id.'/edit')->assertInertia(fn (Assert $page) => $page->where('record.status', 'draft')->where('values.title', 'QA dashboard edited'));
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->count());
|
||||
}
|
||||
|
||||
public function test_investor_unverified_manager_and_guest_do_not_gain_management_access(): void
|
||||
{
|
||||
$manager = User::factory()->unverified()->create();
|
||||
$investor = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email], 'portal.opportunity_dashboard_access_enabled' => true]);
|
||||
$this->actingAs($investor)->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('summary', null)->where('opportunityManagement', null)->where('auth.canManagePortal', false));
|
||||
$this->get('/management/opportunities')->assertForbidden();
|
||||
$this->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid()])->assertForbidden();
|
||||
$this->actingAs($manager)->get('/dashboard')->assertRedirect('/email/verify');
|
||||
$this->getJson('/management/opportunities')->assertForbidden();
|
||||
$this->assertSame(0, Opportunity::count());
|
||||
auth()->forgetGuards();
|
||||
$this->flushSession();
|
||||
$this->get('/dashboard')->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_missing_installation_is_explicit_and_disabled_increment_preserves_compatibility(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email], 'portal.opportunity_dashboard_access_enabled' => false]);
|
||||
$this->actingAs($manager)->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('opportunityManagement', null));
|
||||
$this->postJson('/management/opportunities/1/lifecycle', ['etag' => str_repeat('a', 64), 'action' => 'archive', 'reason' => 'Incremento ainda não disponibilizado nesta configuração', 'confirm' => true])->assertUnprocessable();
|
||||
config(['portal.opportunity_dashboard_access_enabled' => true]);
|
||||
$migration = require database_path('migrations/2026_10_09_020000_extend_opportunity_management.php');
|
||||
$migration->down();
|
||||
$this->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('opportunityManagement.installed', false)->where('opportunityManagement.installationHref', '/management/opportunity-management-install'));
|
||||
$this->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid()])->assertServiceUnavailable();
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ namespace Tests\Feature;
|
||||
use App\Console\Commands\ImportDocumentBatch;
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\User;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\SyntheticDocument;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ImportDocumentBatchTest extends TestCase
|
||||
@@ -50,6 +52,7 @@ class ImportDocumentBatchTest extends TestCase
|
||||
];
|
||||
file_put_contents($this->staging.'/manifest.json', json_encode(['documents' => [$record, $record]], JSON_THROW_ON_ERROR));
|
||||
$actor = User::factory()->create();
|
||||
config(['portal.managers' => [$actor->email]]);
|
||||
|
||||
$this->artisan(new ImportDocumentBatch, ['manifest' => $this->batchName.'/manifest.json'])->assertSuccessful();
|
||||
$this->assertDatabaseCount('documents', 0);
|
||||
@@ -87,4 +90,51 @@ class ImportDocumentBatchTest extends TestCase
|
||||
$this->artisan(new ImportDocumentBatch, ['manifest' => $this->batchName.'/manifest.json'])->assertFailed();
|
||||
$this->assertDatabaseCount('documents', 0);
|
||||
}
|
||||
|
||||
public function test_qa_batch_real_scan_preserves_original_replay_identity_conflict_and_profile_guards(): void
|
||||
{
|
||||
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
||||
$scanner = app(MalwareScanner::class);
|
||||
if (! $scanner->health()['available']) {
|
||||
$this->markTestSkipped('Real ClamD required for batch evidence.');
|
||||
}
|
||||
$bytes = SyntheticDocument::pdf('Synthetic QA batch - no official or legal value');
|
||||
file_put_contents($this->staging.'/source.pdf', $bytes);
|
||||
$record = ['external_id' => 'qa-batch-v1', 'relative_path' => $this->batchName.'/source.pdf', 'title' => 'Synthetic QA batch',
|
||||
'issuer' => 'Synthetic QA issuer', 'source_url' => 'https://example.invalid/qa', 'version_label' => 'QA-1'];
|
||||
file_put_contents($this->staging.'/manifest.json', json_encode(['documents' => [$record, $record]], JSON_THROW_ON_ERROR));
|
||||
$actor = User::factory()->create();
|
||||
$options = ['manifest' => $this->batchName.'/manifest.json', '--apply' => true, '--actor' => (string) $actor->id];
|
||||
$this->artisan(new ImportDocumentBatch, $options)->assertFailed();
|
||||
$this->assertDatabaseCount('documents', 0);
|
||||
$this->assertSame([], Storage::disk('local')->allFiles('portal-documents'));
|
||||
config(['portal.managers' => [$actor->email]]);
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
$this->artisan(new ImportDocumentBatch, $options)->assertFailed();
|
||||
$this->app->detectEnvironment(fn () => 'testing');
|
||||
$this->assertDatabaseCount('documents', 0);
|
||||
$this->artisan(new ImportDocumentBatch, $options)->assertSuccessful();
|
||||
$document = DB::table('documents')->firstOrFail();
|
||||
$this->assertSame($bytes, Storage::disk('local')->get($document->storage_key));
|
||||
$this->assertDatabaseCount('documents', 1);
|
||||
$this->assertDatabaseCount('portal_resources', 1);
|
||||
(new ScanDocumentForMalware($document->id))->handle($scanner);
|
||||
$scanned = DB::table('documents')->where('id', $document->id)->first();
|
||||
$this->assertSame('clean', $scanned->malware_scan_status);
|
||||
$this->assertSame('ClamAV', $scanned->malware_engine);
|
||||
$this->assertSame(hash('sha256', $bytes), $scanned->malware_scanned_sha256);
|
||||
$response = $this->actingAs($actor)->get('/management/documents/'.$document->id.'/download')->assertOk();
|
||||
$this->assertSame($bytes, file_get_contents($response->baseResponse->getFile()->getPathname()));
|
||||
$resource = DB::table('portal_resources')->first();
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
|
||||
$this->artisan(new ImportDocumentBatch, $options)->assertSuccessful();
|
||||
file_put_contents($this->staging.'/source.pdf', SyntheticDocument::pdf('Different QA bytes - same identity'));
|
||||
$this->artisan(new ImportDocumentBatch, $options)->assertSuccessful();
|
||||
$this->assertDatabaseCount('documents', 1);
|
||||
$this->assertDatabaseCount('portal_resources', 1);
|
||||
$this->assertSame($bytes, Storage::disk('local')->get($document->storage_key));
|
||||
$this->assertDatabaseHas('portal_resources', ['id' => $resource->id, 'published' => false]);
|
||||
$this->assertSame(1, DB::table('audit_events')->where('action', 'private_document_batch_imported')->count());
|
||||
$this->actingAs(User::factory()->create())->get('/management/documents/'.$document->id.'/download')->assertForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\LandAssistanceCase;
|
||||
use App\Models\User;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use App\Services\PartnershipExchange;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\SyntheticDocument;
|
||||
use Tests\TestCase;
|
||||
|
||||
class IndependentDocumentOperationsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('local');
|
||||
Queue::fake();
|
||||
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
||||
if (! app(MalwareScanner::class)->health()['available']) {
|
||||
$this->markTestSkipped('Real ClamD required; no fabricated scan proof.');
|
||||
}
|
||||
}
|
||||
|
||||
private function files(): array
|
||||
{
|
||||
$image = imagecreatetruecolor(16, 16);
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
$png = ob_get_clean();
|
||||
ob_start();
|
||||
imagejpeg($image);
|
||||
$jpeg = ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
return ['qa.pdf' => SyntheticDocument::pdf(), 'qa.png' => $png, 'qa.jpg' => $jpeg];
|
||||
}
|
||||
|
||||
private function scan(string $id): object
|
||||
{
|
||||
(new ScanDocumentForMalware($id))->handle(app(MalwareScanner::class));
|
||||
$document = DB::table('documents')->where('id', $id)->first();
|
||||
$this->assertSame('active', $document->status);
|
||||
$this->assertSame('clean', $document->malware_scan_status);
|
||||
$this->assertSame('ClamAV', $document->malware_engine);
|
||||
$this->assertSame(trim($document->sha256), $document->malware_scanned_sha256);
|
||||
$this->assertNotNull($document->malware_signatures_at);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
public function test_land_pdf_png_jpeg_scan_recovery_print_and_profile_permissions_preserve_originals(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($owner)->postJson('/investidor/terra-duat', ['payload' => ['full_name' => 'Synthetic QA investor']])->assertCreated();
|
||||
$case = LandAssistanceCase::firstOrFail();
|
||||
$base = '/investidor/terra-duat/'.$case->id;
|
||||
foreach ($this->files() as $name => $bytes) {
|
||||
$this->post($base.'/documentos', ['version' => $case->fresh()->version, 'category' => str_ends_with($name, '.pdf') ? 'prior_right' : 'photo',
|
||||
'file' => UploadedFile::fake()->createWithContent($name, $bytes)], ['Accept' => 'application/json'])->assertCreated();
|
||||
$documentId = DB::table('documents')->where('file_name', $name)->value('id');
|
||||
$link = DB::table('land_assistance_documents')->where('case_id', $case->id)->where('document_id', $documentId)->first();
|
||||
$url = $base.'/documentos/'.$link->id;
|
||||
$this->get($url)->assertNotFound();
|
||||
$document = $this->scan($link->document_id);
|
||||
$response = $this->get($url)->assertOk()->assertDownload($name);
|
||||
$this->assertSame($bytes, file_get_contents($response->baseResponse->getFile()->getPathname()));
|
||||
// Existing canonical encrypted originals must remain readable without making plaintext copies.
|
||||
Storage::disk('local')->put($document->storage_key, Crypt::encryptString($bytes));
|
||||
DB::table('documents')->where('id', $document->id)->update(['storage_encryption' => 'laravel']);
|
||||
$this->get($url)->assertOk()->assertContent($bytes);
|
||||
$this->assertSame($owner->id, $document->uploaded_by);
|
||||
$this->assertSame(strlen($bytes), $document->size_bytes);
|
||||
$this->actingAs($manager)->get($url)->assertNotFound();
|
||||
$this->actingAs(User::factory()->create())->get($url)->assertNotFound();
|
||||
$this->actingAs($owner)->getJson($base)->assertOk();
|
||||
$this->assertSame($bytes, Crypt::decryptString(Storage::disk('local')->get($document->storage_key)));
|
||||
Storage::disk('local')->put($document->storage_key, Crypt::encryptString('Tampered QA only'));
|
||||
$this->get($url)->assertNotFound();
|
||||
}
|
||||
$this->get($base.'/imprimir')->assertOk(); // Printable response; browser PDF rendering remains pending.
|
||||
$this->assertSame(3, DB::table('documents')->count());
|
||||
$this->assertSame(3, DB::table('document_malware_scans')->where('result', 'clean')->count());
|
||||
}
|
||||
|
||||
public function test_partnership_real_scans_sharing_revocation_new_session_and_encrypted_originals(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$candidate = User::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
$outsider = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$exchange = app(PartnershipExchange::class);
|
||||
$record = $exchange->save($owner, ['client_key' => (string) Str::uuid(), 'title' => 'Synthetic document QA partnership', 'kind' => 'seeking', 'partnership_type' => 'joint_venture',
|
||||
'offer' => 'Synthetic QA capacity', 'seeking' => 'Synthetic QA contribution', 'payload' => ['summary' => 'Synthetic QA only', 'consent' => true], 'origins' => [], 'locations' => []]);
|
||||
$record = $exchange->transition($owner, $record->id, $record->version, 'submit', 'QA private document test');
|
||||
$record = $exchange->transition($manager, $record->id, $record->version, 'publish', 'QA fixture only; no active disclosure');
|
||||
$interest = $exchange->interest($candidate, $record->id, ['proposal' => 'Synthetic QA proposal', 'payload' => ['consent' => true]]);
|
||||
$interest = $exchange->decideInterest($owner, $interest->id, $interest->version, 'shortlist', 'QA room preparation');
|
||||
$exchange->decideInterest($owner, $interest->id, $interest->version, 'connect', 'QA private room access');
|
||||
$room = DB::table('partnership_rooms')->where('interest_id', $interest->id)->first();
|
||||
$base = '/parcerias/salas/'.$room->id;
|
||||
foreach ($this->files() as $name => $bytes) {
|
||||
$this->actingAs($owner)->post($base.'/documentos', ['shared' => false, 'file' => UploadedFile::fake()->createWithContent($name, $bytes)], ['Accept' => 'application/json'])->assertOk();
|
||||
$link = DB::table('partnership_documents')->where('room_id', $room->id)->orderByDesc('id')->first();
|
||||
$url = $base.'/documentos/'.$link->id;
|
||||
$this->get($url)->assertNotFound();
|
||||
$document = $this->scan($link->document_id);
|
||||
$response = $this->get($url)->assertOk()->assertDownload($name);
|
||||
$this->assertSame($bytes, file_get_contents($response->baseResponse->getFile()->getPathname()));
|
||||
$this->actingAs($candidate)->get($url)->assertNotFound();
|
||||
$this->postJson($url.'/partilha', ['shared' => true])->assertForbidden();
|
||||
$this->actingAs($owner)->postJson($url.'/partilha', ['shared' => true])->assertOk();
|
||||
Storage::disk('local')->put($document->storage_key, Crypt::encryptString($bytes));
|
||||
DB::table('documents')->where('id', $document->id)->update(['storage_encryption' => 'laravel']);
|
||||
$this->actingAs($candidate)->get($url)->assertOk()->assertContent($bytes);
|
||||
$this->assertSame($owner->id, $document->uploaded_by);
|
||||
$this->assertSame(strlen($bytes), $document->size_bytes);
|
||||
// A new authentication context recovers shared documents from the repository.
|
||||
auth()->logout();
|
||||
$this->actingAs($candidate->fresh())->getJson($base)->assertOk()->assertJsonCount(1, 'documents');
|
||||
$this->get($url)->assertOk()->assertContent($bytes);
|
||||
$this->actingAs($manager)->get($url)->assertForbidden();
|
||||
$this->actingAs($outsider)->get($url)->assertForbidden();
|
||||
$this->actingAs($owner)->postJson($url.'/partilha', ['shared' => false])->assertOk();
|
||||
$this->actingAs($candidate)->get($url)->assertNotFound();
|
||||
$this->getJson($base)->assertOk()->assertJsonCount(0, 'documents');
|
||||
$this->actingAs($owner)->get($url)->assertOk()->assertContent($bytes);
|
||||
$this->assertSame($bytes, Crypt::decryptString(Storage::disk('local')->get($document->storage_key)));
|
||||
Storage::disk('local')->put($document->storage_key, Crypt::encryptString('Tampered QA only'));
|
||||
$this->get($url)->assertNotFound();
|
||||
}
|
||||
$this->assertSame(3, DB::table('documents')->count());
|
||||
$this->assertSame(6, DB::table('partnership_events')->where('event_type', 'partnership.document_sharing_changed.v1')->count());
|
||||
$this->assertSame(3, DB::table('document_malware_scans')->where('result', 'clean')->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OpportunityCanonicalWorkflowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const REASON = 'Ensaio sintético de contratos e workflow independente em QA';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true]);
|
||||
}
|
||||
|
||||
/** @param list<string> $responsibilities */
|
||||
private function actor(string $role, string $org, array $responsibilities = []): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
||||
'user_id' => $user->id, 'role' => $role, 'organization_id' => $role === 'promoter' ? $org : null,
|
||||
'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => $responsibilities, 'source' => 'synthetic-policy-test-fixture']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function loginFixture(User $user): static
|
||||
{
|
||||
// Policy fixture only. Real Fortify/ClamD evidence comes from the QA HTTP scenario.
|
||||
return $this->actingAs($user)->withSession(['portal.mfa_actor' => $user->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $user->portal_access_revision]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $extra
|
||||
* @return array<string,mixed> */
|
||||
private function action(User $user, int $id, string $action, array $extra = [], int $status = 200): array
|
||||
{
|
||||
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
|
||||
if ($action === 'publish') {
|
||||
$extra = ['confirm_publication' => true, 'preview_hash' => Opportunity::findOrFail($id)->workflow_context['approval']['hash'] ?? str_repeat('0', 64), ...$extra];
|
||||
}
|
||||
|
||||
return $this->loginFixture($user)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => $action, 'reason' => self::REASON, ...$extra])->assertStatus($status)->json();
|
||||
}
|
||||
|
||||
public function test_incomplete_drafts_preserve_category_rules_and_block_client_certification(): void
|
||||
{
|
||||
$service = app(OpportunityCanonicalContract::class);
|
||||
foreach (OpportunityManagement::CATEGORIES as $category) {
|
||||
$this->assertSame(['opportunity_category' => $category], $service->draft(['opportunity_category' => $category]));
|
||||
}
|
||||
$this->assertSame(['real_estate' => ['transaction_type' => 'sale', 'valuation' => []]], $service->draft(['real_estate' => ['transaction_type' => 'sale', 'valuation' => []]]));
|
||||
$this->assertSame(['real_estate' => ['transaction_type' => null, 'land_area' => null, 'property_type' => null]], $service->draft(['real_estate' => ['transaction_type' => null, 'land_area' => null, 'property_type' => null]]));
|
||||
foreach ([['land_rights' => ['operation_type' => 'land_sale']], ['business_transaction' => ['percentage_offered' => 101]], ['real_estate' => ['land_area' => -1]], ['approved_by' => 99], ['evidences' => [['verified' => true]]]] as $invalid) {
|
||||
try {
|
||||
$service->draft($invalid);
|
||||
$this->fail('Invalid provided data or response metadata must be rejected.');
|
||||
} catch (ValidationException $e) {
|
||||
$this->assertNotEmpty($e->errors());
|
||||
}
|
||||
}
|
||||
$schema = $service->schema();
|
||||
$this->assertNotEmpty($schema['$defs']['opportunityCreate']['allOf']);
|
||||
$this->assertNotEmpty($schema['$defs']['realEstateCreate']['allOf']);
|
||||
$this->assertNotEmpty($schema['$defs']['landRightsCreate']['allOf']);
|
||||
$this->assertNotEmpty($schema['$defs']['businessTransactionCreate']['allOf']);
|
||||
}
|
||||
|
||||
public function test_correction_approval_publication_material_revision_and_suspension_preserve_authorization(): void
|
||||
{
|
||||
[$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture();
|
||||
$this->action($promoter, $id, 'submit');
|
||||
$this->action($validator, $id, 'approve', [], 422);
|
||||
$this->action($validator, $id, 'start_review');
|
||||
$firstReview = Opportunity::findOrFail($id)->workflow_context['review_task_id'];
|
||||
$this->action($validator, $id, 'request_correction', ['correction_items' => ['canonical.executive_summary']]);
|
||||
$this->assertDatabaseHas('tasks', ['id' => $firstReview, 'status' => 'done']);
|
||||
$payload['executive_summary'] .= ' Corrigido em QA.';
|
||||
// Primary is deliberately second: array order must not change the public territory.
|
||||
$primary = DB::table('opportunity_location_drafts')->where('id', $payload['locations'][0])->first();
|
||||
$primaryData = json_decode($primary->payload, true);
|
||||
$secondaryId = (string) Str::uuid();
|
||||
$secondaryDistrict = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $secondaryDistrict, 'code' => $secondaryDistrict, 'name' => 'Non-primary QA district', 'location_type' => 'district', 'parent_id' => $primaryData['province_id'], 'data_classification' => 'internal']);
|
||||
DB::table('opportunity_location_drafts')->insert(['id' => $secondaryId, 'opportunity_id' => $id, 'version' => 1, 'created_by' => $promoter->id, 'payload' => json_encode([...$primaryData, 'district_id' => $secondaryDistrict, 'is_primary' => false])]);
|
||||
$payload['locations'] = [$secondaryId, $payload['locations'][0]];
|
||||
$payload['location_mode'] = 'multiple';
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
|
||||
$this->action($promoter, $id, 'submit');
|
||||
$this->assertArrayNotHasKey('assignment', Opportunity::findOrFail($id)->workflow_context);
|
||||
$this->action($validator, $id, 'start_review');
|
||||
foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) {
|
||||
$this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
|
||||
}
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $promoter->id, 'role' => 'validator', 'opportunity_types' => ['real_estate'], 'responsibilities' => ['technical_reviewer', 'legal_reviewer']]);
|
||||
$this->action($promoter, $id, 'approve', [], 403);
|
||||
$this->action($validator, $id, 'approve');
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $validator->id, 'role' => 'publisher', 'opportunity_types' => ['real_estate']]);
|
||||
$this->action($validator, $id, 'publish', [], 403);
|
||||
$this->action($publisher, $id, 'assign_publication', ['assignee_user_id' => $publisher->id]);
|
||||
$otherPublisher = $this->actor('publisher', 'unused');
|
||||
$this->action($otherPublisher, $id, 'publish', [], 403);
|
||||
$grants = DB::table('portal_role_assignments')->where('user_id', $validator->id)->where('role', 'validator')->whereNull('revoked_at')->pluck('id');
|
||||
DB::table('portal_role_assignments')->whereIn('id', $grants)->update(['revoked_at' => now()]);
|
||||
$this->action($publisher, $id, 'publish', [], 403);
|
||||
DB::table('portal_role_assignments')->whereIn('id', $grants)->update(['revoked_at' => null]);
|
||||
$this->action($publisher, $id, 'publish');
|
||||
$this->assertSame($primaryData['district_id'], Opportunity::findOrFail($id)->primary_territory_id);
|
||||
$publicTitle = Opportunity::findOrFail($id)->title;
|
||||
$this->assertSame(0, Opportunity::published()->count(), 'Internal publication must not leak to public consumers.');
|
||||
$this->action($promoter, $id, 'request_revision');
|
||||
$payload['official_title'] = 'Título materialmente alterado, ainda privado';
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
|
||||
$record = Opportunity::findOrFail($id);
|
||||
$this->assertSame('published', $record->status);
|
||||
$this->assertSame($publicTitle, $record->title);
|
||||
$this->assertArrayNotHasKey('approval', $record->workflow_context);
|
||||
$this->assertSame('needs_information', $record->workflow_context['working_state']);
|
||||
$this->get('/minhas-oportunidades?status=needs_information&q=materialmente')->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.draft_title', 'Título materialmente alterado, ainda privado')->where('counts.needs_information', 1));
|
||||
$this->action($publisher, $id, 'publish', [], 409);
|
||||
$this->action($publisher, $id, 'suspend');
|
||||
$this->assertSame('suspended', Opportunity::findOrFail($id)->status);
|
||||
$this->assertDatabaseHas('tasks', ['source_system' => 'opportunity-workflow', 'task_type' => 'opportunity_review', 'assignee_user_id' => $validator->id, 'due_at' => null, 'status' => 'done']);
|
||||
$this->assertSame('clean', DB::table('documents')->where('id', DB::table('opportunity_documents')->where('id', $document)->value('document_id'))->value('malware_scan_status'));
|
||||
}
|
||||
|
||||
public function test_invalid_binding_reason_and_original_integrity_cannot_be_bypassed(): void
|
||||
{
|
||||
[$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture();
|
||||
$bad = $payload;
|
||||
$bad['real_estate']['price'] = null;
|
||||
$bad['documents'] = [(string) Str::uuid()];
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $bad, 'reason' => self::REASON])->assertOk();
|
||||
$this->action($promoter, $id, 'submit', [], 422);
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
|
||||
$this->action($promoter, $id, 'submit');
|
||||
$this->action($validator, $id, 'reject', ['reason' => 'curto'], 422);
|
||||
$this->action($validator, $id, 'reject');
|
||||
$this->assertDatabaseHas('tasks', ['id' => Opportunity::findOrFail($id)->workflow_context['review_task_id'], 'status' => 'done']);
|
||||
$this->assertSame(self::REASON, Opportunity::findOrFail($id)->workflow_context['rejection']['reason']);
|
||||
$this->action($promoter, $id, 'appeal');
|
||||
$this->action($validator, $id, 'appeal_reopen', [], 403);
|
||||
$appeal = $this->actor('validator', $promoter->id.'unused', ['appeal_reviewer']);
|
||||
$this->action($appeal, $id, 'appeal_reopen');
|
||||
$this->assertFalse(Opportunity::findOrFail($id)->workflow_context['appeal']['pending']);
|
||||
$storageKey = DB::table('documents')->where('id', DB::table('opportunity_documents')->where('id', $document)->value('document_id'))->value('storage_key');
|
||||
Storage::disk('local')->put($storageKey, 'Invalid ciphertext - synthetic QA only');
|
||||
$this->action($promoter, $id, 'submit', [], 404);
|
||||
$this->loginFixture($promoter)->get('/minhas-oportunidades/'.$id.'/resources/documents/'.$document.'/download')->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_semantic_errors_are_keyed_to_fields_and_incomplete_drafts_remain_recoverable(): void
|
||||
{
|
||||
[$id,$promoter,$validator,$publisher,$payload,$document] = $this->fixture();
|
||||
foreach (['utilities', 'title_document_id', 'occupancy_document_id', 'valuation.value'] as $field) {
|
||||
$bad = $payload;
|
||||
if ($field === 'utilities') {
|
||||
$bad['real_estate']['utilities'] = [];
|
||||
} elseif ($field === 'title_document_id') {
|
||||
unset($bad['real_estate'][$field]);
|
||||
} elseif ($field === 'valuation.value') {
|
||||
$bad['real_estate']['valuation']['value'] = 0;
|
||||
} else {
|
||||
$bad['real_estate']['occupancy_status'] = 'tenant_occupied';
|
||||
}
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $bad, 'reason' => self::REASON])->assertOk();
|
||||
$this->get('/minhas-oportunidades/'.$id.'/edit')->assertInertia(fn (Assert $page) => $page->where('values.canonical', $bad));
|
||||
$this->loginFixture($promoter)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'action' => 'submit', 'reason' => self::REASON])->assertUnprocessable()->assertJsonValidationErrors('canonical.real_estate.'.$field);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_scoped_opinion_tasks_require_the_recipient_and_do_not_approve_a_gate(): void
|
||||
{
|
||||
[$id,$promoter,$validator,$publisher,$payload,$document] = $this->fixture();
|
||||
$opinionReviewer = $this->actor('validator', 'unused', ['technical_reviewer']);
|
||||
$this->action($promoter, $id, 'submit');
|
||||
$this->action($validator, $id, 'start_review');
|
||||
$this->action($validator, $id, 'pending_opinion', ['gate' => 'property_review', 'recipient_user_id' => $opinionReviewer->id]);
|
||||
$context = Opportunity::findOrFail($id)->workflow_context;
|
||||
$opinion = array_key_first($context['opinions']);
|
||||
$this->action($validator, $id, 'approve', [], 422);
|
||||
$this->action($validator, $id, 'respond_opinion', ['opinion_id' => $opinion, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]], 403);
|
||||
$this->loginFixture($opinionReviewer)->get('/minhas-oportunidades?queue=assigned')->assertInertia(fn (Assert $p) => $p->where('records.total', 1));
|
||||
$this->action($opinionReviewer, $id, 'respond_opinion', ['opinion_id' => $opinion, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
|
||||
$context = Opportunity::findOrFail($id)->workflow_context;
|
||||
$this->assertSame('answered', $context['opinions'][$opinion]['status']);
|
||||
$this->assertArrayNotHasKey('reviews', $context);
|
||||
$this->assertDatabaseHas('tasks', ['id' => $context['opinions'][$opinion]['task_id'], 'status' => 'done']);
|
||||
$this->action($validator, $id, 'approve', [], 422);
|
||||
}
|
||||
|
||||
/** @return array{int,User,User,User,array<string,mixed>,string} */
|
||||
private function fixture(): array
|
||||
{
|
||||
$org = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $org, 'name' => 'Synthetic QA organization', 'organization_type' => 'private']);
|
||||
$promoter = $this->actor('promoter', $org);
|
||||
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $promoter->id, 'organization_id' => $org, 'role' => 'officer']);
|
||||
$validator = $this->actor('validator', $org, ['technical_reviewer', 'legal_reviewer', 'validation_coordinator']);
|
||||
$publisher = $this->actor('publisher', $org, ['publication_coordinator']);
|
||||
$version = (string) Str::uuid();
|
||||
DB::table('reference_catalog_versions')->insert(['id' => $version, 'catalog_code' => 'qa-only', 'version_no' => 1, 'source_system' => 'synthetic-policy-fixture', 'source_version' => 'qa', 'source_reference' => 'Not official', 'status' => 'published']);
|
||||
$ids = [];
|
||||
$parent = null;
|
||||
foreach (range(1, 3) as $i) {
|
||||
$sector = DB::table('sectors')->insertGetId(['name' => 'QA '.$i, 'slug' => 'qa-'.$i, 'is_active' => true]);
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('economic_activities')->insert(['id' => $id, 'parent_id' => $parent, 'sector_id' => $sector, 'catalog_version_id' => $version, 'code' => 'qa-'.$i, 'label_pt_mz' => 'QA '.$i, 'status' => 'published', 'valid_from' => today()]);
|
||||
$ids[] = [$id, $sector];
|
||||
$parent = $id;
|
||||
}
|
||||
$contact = (string) Str::uuid();
|
||||
$investor = (string) Str::uuid();
|
||||
DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'Synthetic QA contact']);
|
||||
DB::table('investors')->insert(['id' => $investor, 'organization_id' => $org, 'legal_name' => 'Synthetic QA entity', 'investor_type' => 'institutional', 'status' => 'active']);
|
||||
DB::table('investor_contacts')->insert(['investor_id' => $investor, 'contact_id' => $contact]);
|
||||
$draft = $this->loginFixture($promoter)->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => $org, 'opportunity_category' => 'real_estate', 'reason' => self::REASON])->assertCreated()->json();
|
||||
$id = $draft['id'];
|
||||
$location = (string) Str::uuid();
|
||||
$provinceId = (string) Str::uuid();
|
||||
$districtId = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert([['id' => $provinceId, 'code' => $provinceId, 'name' => 'Synthetic QA province', 'location_type' => 'province', 'parent_id' => null, 'data_classification' => 'internal'], ['id' => $districtId, 'code' => $districtId, 'name' => 'Synthetic QA district', 'location_type' => 'district', 'parent_id' => $provinceId, 'data_classification' => 'internal']]);
|
||||
$province = DB::table('geo_locations')->where('id', $provinceId)->first();
|
||||
$district = DB::table('geo_locations')->where('id', $districtId)->first();
|
||||
DB::table('opportunity_location_drafts')->insert(['id' => $location, 'opportunity_id' => $id, 'version' => 1, 'created_by' => $promoter->id, 'payload' => json_encode(['country_code' => 'MZ', 'province_id' => $province->id, 'district_id' => $district->id, 'role' => 'headquarters', 'is_primary' => true])]);
|
||||
$document = (string) Str::uuid();
|
||||
$file = (string) Str::uuid();
|
||||
$bytes = 'Synthetic policy fixture, not a real scanner result.';
|
||||
$key = 'opportunity-contract-test/'.$file;
|
||||
Storage::disk('local')->put($key, Crypt::encryptString($bytes));
|
||||
DB::table('documents')->insert(['id' => $file, 'file_name' => 'qa-fixture.txt', 'storage_key' => $key, 'storage_encryption' => 'laravel', 'media_type' => 'text/plain', 'sha256' => hash('sha256', $bytes), 'status' => 'active', 'malware_scan_status' => 'clean', 'malware_engine' => 'synthetic-policy-fixture', 'malware_scanned_sha256' => hash('sha256', $bytes), 'uploaded_by' => $promoter->id]);
|
||||
DB::table('opportunity_documents')->insert(['id' => $document, 'opportunity_id' => $id, 'document_id' => $file, 'version' => 1, 'created_by' => $promoter->id, 'payload' => json_encode(['title' => 'QA', 'category' => 'qa', 'visibility' => 'internal'])]);
|
||||
$payload = ['opportunity_category' => 'real_estate', 'official_title' => 'Imóvel sintético para ensaio QA', 'promoter_id' => $org, 'promoter_type' => 'private', 'internal_contact_id' => $contact, 'sector_id' => $ids[0][1], 'subsector_id' => $ids[1][0], 'economic_activity_id' => $ids[2][0], 'location_mode' => 'single', 'geographic_scope' => 'district', 'locations' => [$location], 'executive_summary' => trim(str_repeat('Resumo sintético QA. ', 20)), 'demand_statement' => trim(str_repeat('Necessidade sintética QA. ', 6)), 'documents' => [$document], 'evidences' => [['title' => 'QA', 'evidence_type' => 'qa', 'document_id' => $document]], 'publication_languages' => [['language_code' => 'pt-MZ', 'title' => 'QA', 'summary' => 'QA']], 'visibility' => 'internal', 'currency' => 'MZN', 'truth_declaration' => true, 'real_estate' => ['transaction_type' => 'sale', 'property_type' => 'warehouse', 'construction_status' => 'completed', 'property_condition' => 'good', 'land_area' => 1000, 'built_area' => 500, 'area_unit' => 'sqm', 'utilities' => [['type' => 'water', 'status' => 'existing']], 'occupancy_status' => 'vacant', 'title_type' => 'QA', 'registration_number' => 'QA-'.$id, 'encumbrances' => [['type' => 'none']], 'valuation' => ['value' => 1000, 'date' => '2026-10-10', 'method' => 'qa', 'appraiser' => 'QA', 'document_id' => $document], 'price' => 1000, 'use_license_document_id' => $document]];
|
||||
$authority = (string) Str::uuid();
|
||||
DB::table('authorities')->insert(['id' => $authority, 'catalog_version_id' => $version, 'organization_id' => $org, 'code' => 'QA-'.$id, 'authority_type' => 'qa-synthetic', 'label_pt_mz' => 'Entidade sintética QA', 'source_reference' => 'Synthetic policy fixture; not official', 'valid_from' => '2026-01-01', 'status' => 'published']);
|
||||
$payload['real_estate']['utilities'] = array_map(fn ($type) => ['type' => $type, 'status' => 'existing'], ['electricity', 'water', 'sanitation', 'telecommunications', 'access']);
|
||||
$payload['real_estate'] += ['title_document_id' => $document, 'title_holder_name' => 'Synthetic QA organization', 'issuing_authority_id' => $authority];
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $draft['etag'], 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
|
||||
|
||||
return [$id, $promoter, $validator, $publisher, $payload, $document];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OpportunityPublicationScopeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_approval_restricted_and_public_labels_follow_actual_catalog_policy(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$id = DB::table('opportunities')->insertGetId(['reference' => 'QA-publication-scope', 'slug' => 'qa-publication-scope', 'status' => 'approved',
|
||||
'owner_user_id' => $owner->id, 'workflow_context' => json_encode(['publication' => ['visibility' => 'public']])]);
|
||||
$record = Opportunity::findOrFail($id);
|
||||
$this->assertSame('unpublished', $record->publicationPresentation()['scope']);
|
||||
$record->forceFill(['status' => 'published', 'published_at' => now(), 'workflow_context' => ['publication' => ['visibility' => 'internal']]])->save();
|
||||
$this->assertSame('restricted', $record->publicationPresentation()['scope']);
|
||||
$this->assertFalse($record->publicationPresentation()['public_catalog']);
|
||||
$this->assertFalse(Opportunity::published()->whereKey($id)->exists());
|
||||
$record->forceFill(['workflow_context' => ['publication' => ['visibility' => 'public']]])->save();
|
||||
$this->assertTrue($record->publicationPresentation()['public_catalog']);
|
||||
$this->assertSame('public', $record->publicationPresentation()['scope']);
|
||||
$record->forceFill(['status' => 'suspended'])->save();
|
||||
$this->assertSame('unpublished', $record->publicationPresentation()['scope']);
|
||||
$record->forceFill(['status' => 'published', 'published_at' => now()->addDay()])->save();
|
||||
$this->assertFalse($record->publicationPresentation()['public_catalog']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityManagement;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OpportunityRealEstateDraftTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_partial_real_estate_draft_is_private_recoverable_and_patch_preserves_other_fields(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email], 'portal.opportunity_specialized_drafts_enabled' => true]);
|
||||
$payload = ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'real_estate', 'title' => 'QA imóvel incompleto', 'real_estate' => ['transaction_type' => 'lease', 'land_area' => 250.5, 'area_unit' => 'sqm'], 'reason' => 'Rascunho imobiliário incompleto sem decisão externa'];
|
||||
$draft = $this->actingAs($manager)->postJson('/management/opportunities/drafts', $payload)->assertCreated()->json();
|
||||
$this->postJson('/management/opportunities/drafts', $payload)->assertCreated()->assertJsonPath('id', $draft['id']);
|
||||
$this->assertSame(1, Opportunity::count());
|
||||
$path = '/management/opportunities/'.$draft['id'].'/edit';
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('values.real_estate.land_area', 250.5)->where('record.status', 'draft'));
|
||||
$this->putJson('/management/opportunities/'.$draft['id'].'/draft', ['etag' => $draft['etag'], 'real_estate' => ['rent' => 1000, 'rent_periodicity' => 'monthly'], 'reason' => 'Complemento de renda no mesmo rascunho imobiliário'])->assertOk();
|
||||
$this->get($path)->assertInertia(fn (Assert $page) => $page->where('values.real_estate.land_area', 250.5)->where('values.real_estate.rent', 1000));
|
||||
$record = Opportunity::findOrFail($draft['id']);
|
||||
$before = $record->getRawOriginal();
|
||||
foreach ([['price' => 0], ['price' => '100'], ['transaction_type' => 'sell_duat'], ['id' => (string) Str::uuid()], ['utilities' => ['invalid scalar']], ['planning_permit_document_id' => 'not-uuid']] as $invalid) {
|
||||
$this->putJson('/management/opportunities/'.$draft['id'].'/draft', ['etag' => app(OpportunityManagement::class)->etag($record), 'real_estate' => $invalid, 'reason' => 'Negativo contratual que deve conservar o rascunho'])->assertUnprocessable();
|
||||
$this->assertSame($before, $record->fresh()->getRawOriginal());
|
||||
}
|
||||
$this->actingAs(User::factory()->create())->get($path)->assertForbidden();
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
}
|
||||
|
||||
public function test_specialized_draft_never_changes_published_version_and_is_disabled_by_default(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($manager)->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'real_estate', 'real_estate' => ['transaction_type' => 'sale'], 'reason' => 'Incremento desativado mantém compatibilidade ativa'])->assertUnprocessable();
|
||||
$this->assertSame(0, Opportunity::count());
|
||||
config(['portal.opportunity_specialized_drafts_enabled' => true]);
|
||||
$sector = Sector::create(['name' => 'QA imóveis', 'slug' => 'qa-real-estate', 'is_active' => true, 'sort_order' => 1]);
|
||||
$record = Opportunity::create(['title' => 'QA published original', 'reference' => 'QA-RE-PUBLISHED', 'slug' => 'qa-re-published', 'sector_id' => $sector->id, 'status' => 'published', 'published_at' => now(), 'opportunity_category' => 'real_estate', 'management_version' => 1]);
|
||||
$record->forceFill(['opportunity_category' => 'real_estate'])->save();
|
||||
$this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($record), 'real_estate' => ['transaction_type' => 'sale', 'price' => 50000], 'reason' => 'Nova proposta privada sem alteração da ficha publicada'])->assertOk();
|
||||
$this->assertSame('QA published original', $record->fresh()->title);
|
||||
$this->assertSame('published', $record->fresh()->status);
|
||||
$this->getJson('/api/v1/opportunities/qa-re-published')->assertOk()->assertJsonMissingPath('data.real_estate')->assertJsonMissingPath('data.management_draft');
|
||||
$this->assertSame(50000, json_decode($record->fresh()->management_draft, true)['real_estate']['price']);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class OpportunityResourcesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @return array{User,Opportunity} */
|
||||
private function setupDraft(): array
|
||||
{
|
||||
Storage::fake('local');
|
||||
@@ -28,10 +29,15 @@ class OpportunityResourcesTest extends TestCase
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [$user->email]]);
|
||||
$id = $this->actingAs($user)->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'reason' => 'Rascunho de recursos em QA isolada'])->assertCreated()->json('id');
|
||||
if (! is_int($id)) {
|
||||
throw new \RuntimeException('O cadastro QA deve devolver um ID inteiro de oportunidade.');
|
||||
}
|
||||
|
||||
return [$user, Opportunity::findOrFail($id)];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload
|
||||
* @return array<string,mixed> */
|
||||
private function payload(Opportunity $opportunity, array $payload = []): array
|
||||
{
|
||||
return ['etag' => app(OpportunityManagement::class)->etag($opportunity), 'creation_key' => (string) Str::uuid(), 'payload' => $payload, 'reason' => 'Preparação e conferência em QA isolada'];
|
||||
@@ -64,6 +70,7 @@ class OpportunityResourcesTest extends TestCase
|
||||
$life = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 2, 'action' => 'delete', 'confirm' => true, 'reason' => 'Exclusão lógica de anexo apenas QA'];
|
||||
$this->postJson('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/lifecycle', $life)->assertOk();
|
||||
$this->assertSoftDeleted('opportunity_documents', ['id' => $r->id]);
|
||||
$this->get('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/download')->assertNotFound();
|
||||
$this->postJson('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/lifecycle', [...$life, 'etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 3, 'action' => 'restore'])->assertOk();
|
||||
$this->assertDatabaseHas('opportunity_documents', ['id' => $r->id, 'deleted_at' => null, 'version' => 4]);
|
||||
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'opportunity-resources')->count());
|
||||
@@ -122,7 +129,11 @@ class OpportunityResourcesTest extends TestCase
|
||||
$this->assertSame('pending', $document->malware_scan_status);
|
||||
$webp = Crypt::decryptString(Storage::disk('local')->get($document->storage_key));
|
||||
$this->assertStringNotContainsString('EXIF', $webp);
|
||||
$this->assertSame('image/webp', getimagesizefromstring($webp)['mime']);
|
||||
$size = getimagesizefromstring($webp);
|
||||
if ($size === false) {
|
||||
throw new \RuntimeException('Derivada QA não contém uma imagem válida.');
|
||||
}
|
||||
$this->assertSame('image/webp', $size['mime']);
|
||||
(new ScanDocumentForMalware($document->id))->handle(new CleanScannerFixture);
|
||||
}
|
||||
$preview = URL::temporarySignedRoute('management.opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $image->id, 'documentId' => $payload['derivatives'][0]['document_id']]);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\PortalSuperusers;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalProfileAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const REASON = 'Verificação sintética de permissões na base QA isolada';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['portal.profile_access_enabled' => true]);
|
||||
}
|
||||
|
||||
private function actor(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function organization(User ...$users): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $id, 'name' => 'QA synthetic organization', 'organization_type' => 'private']);
|
||||
foreach ($users as $user) {
|
||||
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $user->id, 'organization_id' => $id, 'role' => 'officer']);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function grant(User $user, string $role, ?string $organization = null, array $types = ['investment_project']): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
app(PortalProfileAdministration::class)->insertGrant($id, $this->actor(), ['user_id' => $user->id, 'role' => $role, 'organization_id' => $organization,
|
||||
'opportunity_types' => $types, 'source' => 'synthetic-test-fixture']);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function mfa(User $user): static
|
||||
{
|
||||
return $this->actingAs($user)->withSession(['portal.mfa_actor' => $user->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $user->fresh()->portal_access_revision]);
|
||||
}
|
||||
|
||||
private function draft(User $user, ?string $organization = null): array
|
||||
{
|
||||
return $this->actingAs($user)->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => $organization,
|
||||
'opportunity_category' => 'investment_project', 'title' => 'QA private opportunity', 'reason' => self::REASON])->assertCreated()->json();
|
||||
}
|
||||
|
||||
public function test_public_profile_requests_are_owned_idempotent_and_not_self_activating(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$this->actingAs($user)->get('/dashboard')->assertInertia(fn (Assert $p) => $p->where('profileAccess.mine', null)->where('profileAccess.request', '/perfil/promotor'));
|
||||
$this->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'investment_project'])->assertForbidden();
|
||||
$doc = (string) Str::uuid();
|
||||
// This is an authorization fixture, not evidence of a real antimalware scan.
|
||||
DB::table('documents')->insert(['id' => $doc, 'file_name' => 'synthetic.txt', 'storage_key' => 'synthetic/'.$doc, 'media_type' => 'text/plain',
|
||||
'sha256' => str_repeat('a', 64), 'malware_scanned_sha256' => str_repeat('a', 64), 'status' => 'active', 'malware_scan_status' => 'clean', 'uploaded_by' => $user->id]);
|
||||
$payload = ['creation_key' => (string) Str::uuid(), 'representation_type' => 'individual', 'opportunity_types' => ['investment_project'], 'document_ids' => [$doc], 'reason' => self::REASON];
|
||||
$id = $this->postJson('/perfil/promotor', $payload)->assertCreated()->json('id');
|
||||
$this->postJson('/perfil/promotor', $payload)->assertCreated()->assertJsonPath('id', $id);
|
||||
$this->postJson('/perfil/promotor', [...$payload, 'reason' => self::REASON.' changed'])->assertConflict();
|
||||
$this->getJson('/perfil/promotor/'.$id)->assertOk()->assertJsonPath('status', 'pending');
|
||||
$this->assertFalse(app(PortalProfileAccess::class)->has($user, 'promoter'));
|
||||
$this->getJson('/minhas-oportunidades/territory-options?type=province')->assertForbidden();
|
||||
$this->actingAs($other)->getJson('/perfil/promotor/'.$id)->assertForbidden();
|
||||
$this->postJson('/perfil/promotor', [...$payload, 'creation_key' => (string) Str::uuid()])->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_promoters_share_authorized_organization_drafts_but_not_other_scopes_or_types(): void
|
||||
{
|
||||
$author = $this->actor();
|
||||
$colleague = $this->actor();
|
||||
$outsider = $this->actor();
|
||||
$organization = $this->organization($author, $colleague);
|
||||
$otherOrganization = $this->organization($outsider);
|
||||
$this->grant($author, 'promoter', $organization);
|
||||
$this->grant($colleague, 'promoter', $organization);
|
||||
$this->grant($outsider, 'promoter', $otherOrganization);
|
||||
$draft = $this->draft($author, $organization);
|
||||
$id = $draft['id'];
|
||||
$this->get('/dashboard')->assertInertia(fn (Assert $p) => $p->where('profileAccess.mine', '/minhas-oportunidades')->where('profileAccess.create', '/minhas-oportunidades/create'));
|
||||
$this->actingAs($colleague)->get('/minhas-oportunidades/'.$id.'/edit')->assertInertia(fn (Assert $p) => $p->where('values.title', 'QA private opportunity')->where('readOnly', false));
|
||||
$update = $this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $draft['etag'], 'title' => 'QA shared edit', 'reason' => self::REASON])->assertOk()->json();
|
||||
$this->assertSame($author->id, (int) Opportunity::findOrFail($id)->owner_user_id);
|
||||
$this->assertSame([$author->id, $colleague->id], Opportunity::findOrFail($id)->workflow_context['contributors']);
|
||||
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $update['etag'], 'organization_id' => $otherOrganization, 'reason' => self::REASON])->assertForbidden();
|
||||
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $update['etag'], 'opportunity_category' => 'financing', 'reason' => self::REASON])->assertForbidden();
|
||||
$this->actingAs($outsider)->get('/minhas-oportunidades/'.$id.'/view')->assertForbidden();
|
||||
$this->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => $organization, 'opportunity_category' => 'investment_project', 'reason' => self::REASON])->assertForbidden();
|
||||
$this->get('/minhas-oportunidades')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
DB::table('memberships')->where('user_id', $colleague->id)->update(['deleted_at' => now()]);
|
||||
$this->actingAs($colleague)->get('/minhas-oportunidades/'.$id.'/view')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_profile_decisions_do_not_fabricate_checks_and_privileged_grants_require_confirmed_mfa(): void
|
||||
{
|
||||
$first = $this->actor();
|
||||
$second = $this->actor();
|
||||
$bootstrap = $this->actor();
|
||||
config(['portal_profiles.bootstrap_authorized' => true]);
|
||||
$this->mfa($bootstrap);
|
||||
app(PortalSuperusers::class)->initialize($first, $second, $bootstrap);
|
||||
$doc = (string) Str::uuid();
|
||||
DB::table('documents')->insert(['id' => $doc, 'file_name' => 'synthetic-authorization-fixture.txt', 'storage_key' => 'synthetic/'.$doc,
|
||||
'media_type' => 'text/plain', 'sha256' => str_repeat('b', 64), 'malware_scanned_sha256' => str_repeat('b', 64),
|
||||
'status' => 'active', 'malware_scan_status' => 'clean', 'uploaded_by' => $first->id]);
|
||||
$request = $this->mfa($first)->postJson('/perfil/promotor', ['creation_key' => (string) Str::uuid(), 'representation_type' => 'individual',
|
||||
'opportunity_types' => ['investment_project'], 'document_ids' => [$doc], 'reason' => self::REASON])->assertCreated()->json('id');
|
||||
$decision = ['version' => 1, 'decision' => 'rejected', 'reason' => self::REASON, 'approved_types' => [],
|
||||
'verified_identity' => false, 'verified_representation' => false, 'verified_documents' => false];
|
||||
$this->postJson('/perfis/pedidos/'.$request.'/decisao', $decision)->assertForbidden();
|
||||
$this->mfa($second)->postJson('/perfis/pedidos/'.$request.'/decisao', $decision)->assertOk();
|
||||
$this->assertDatabaseHas('portal_profile_requests', ['id' => $request, 'status' => 'rejected']);
|
||||
$this->assertFalse(app(PortalProfileAccess::class)->has($first, 'promoter'));
|
||||
$event = json_decode(DB::table('audit_events')->where('action', 'promoter_request_decided')->value('after_data'), true);
|
||||
$this->assertFalse($event['checks']['verified_documents']);
|
||||
$unenrolled = User::factory()->create();
|
||||
$this->postJson('/perfis/atribuicoes', ['user_id' => $unenrolled->id, 'role' => 'validator',
|
||||
'opportunity_types' => ['investment_project'], 'reason' => self::REASON])->assertUnprocessable();
|
||||
$this->postJson('/perfis/atribuicoes', ['user_id' => $bootstrap->id, 'role' => 'auditor', 'responsibilities' => ['validation_coordinator'],
|
||||
'opportunity_types' => ['investment_project'], 'reason' => self::REASON])->assertUnprocessable();
|
||||
$expired = $this->grant($bootstrap, 'validator');
|
||||
DB::table('portal_role_assignments')->where('id', $expired)->update(['starts_at' => now()->subDays(2), 'expires_at' => now()->subDay()]);
|
||||
$this->assertFalse(app(PortalProfileAccess::class)->has($bootstrap, 'validator'));
|
||||
}
|
||||
|
||||
public function test_role_accumulation_does_not_override_author_auditor_or_state_denials(): void
|
||||
{
|
||||
$user = $this->actor();
|
||||
$this->grant($user, 'promoter');
|
||||
$draft = $this->draft($user);
|
||||
$id = $draft['id'];
|
||||
$this->grant($user, 'validator');
|
||||
$this->grant($user, 'publisher');
|
||||
DB::table('opportunities')->where('id', $id)->update(['status' => 'submitted']);
|
||||
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
|
||||
$this->mfa($user)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => 'approve', 'reason' => self::REASON])->assertForbidden();
|
||||
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $etag, 'reason' => self::REASON])->assertForbidden();
|
||||
DB::table('opportunities')->where('id', $id)->update(['status' => 'draft']);
|
||||
config(['portal.managers' => [$user->email], 'portal.opportunity_dashboard_access_enabled' => true]);
|
||||
$this->get('/management/opportunities/create')->assertForbidden();
|
||||
$this->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'title' => 'Legacy bypass'])->assertForbidden();
|
||||
$this->get('/dashboard')->assertInertia(fn (Assert $p) => $p->where('opportunityManagement', null)->where('summary.canCreateOpportunity', false));
|
||||
$this->grant($user, 'auditor');
|
||||
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
|
||||
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $etag, 'title' => 'forbidden', 'reason' => self::REASON])->assertForbidden();
|
||||
$this->get('/minhas-oportunidades/'.$id.'/view')->assertOk();
|
||||
$this->postJson('/management/sectors', ['name' => 'Auditor bypass'])->assertForbidden();
|
||||
$this->assertSame('QA private opportunity', Opportunity::findOrFail($id)->title);
|
||||
}
|
||||
|
||||
public function test_independent_review_preserves_versions_and_normative_gates_remain_closed(): void
|
||||
{
|
||||
$promoter = $this->actor();
|
||||
$validator = $this->actor();
|
||||
$publisher = $this->actor();
|
||||
$this->grant($promoter, 'promoter');
|
||||
$this->grant($validator, 'validator');
|
||||
$this->grant($publisher, 'publisher');
|
||||
$draft = $this->draft($promoter);
|
||||
$id = $draft['id'];
|
||||
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $draft['etag'], 'action' => 'submit', 'reason' => self::REASON])->assertUnprocessable();
|
||||
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $draft['etag'], 'action' => 'request_archive', 'reason' => self::REASON])->assertOk()->assertJsonPath('status', 'draft');
|
||||
$this->assertNotNull(Opportunity::findOrFail($id)->workflow_context['request_archive']);
|
||||
// Explicit review-state fixture exercises the developing workflow, not a complete submission.
|
||||
$context = Opportunity::findOrFail($id)->workflow_context;
|
||||
$context['working_state'] = 'submitted';
|
||||
DB::table('opportunities')->where('id', $id)->update(['status' => 'submitted', 'workflow_context' => json_encode($context)]);
|
||||
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
|
||||
$this->actingAs($validator)->withSession(['portal.mfa_actor' => null])->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => 'start_review', 'reason' => self::REASON])->assertForbidden();
|
||||
$review = $this->mfa($validator)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => 'start_review', 'reason' => self::REASON])->assertOk()->json();
|
||||
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $review['etag'], 'action' => 'approve', 'reason' => self::REASON])->assertUnprocessable();
|
||||
$correction = $this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $review['etag'], 'action' => 'request_correction', 'reason' => self::REASON, 'correction_items' => ['summary']])->assertOk()->json();
|
||||
$this->actingAs($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $correction['etag'], 'summary' => 'QA correction', 'reason' => self::REASON])->assertOk();
|
||||
$etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id));
|
||||
$this->mfa($publisher)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => 'publish', 'reason' => self::REASON])->assertConflict();
|
||||
$this->assertSame(0, Opportunity::published()->count());
|
||||
}
|
||||
|
||||
public function test_revocation_invalidates_existing_session_and_legacy_manager_cannot_bypass_scope(): void
|
||||
{
|
||||
$first = $this->actor();
|
||||
$second = $this->actor();
|
||||
$approver = $this->actor();
|
||||
config(['portal_profiles.bootstrap_authorized' => true]);
|
||||
$this->mfa($approver);
|
||||
app(PortalSuperusers::class)->initialize($first, $second, $approver);
|
||||
$promoter = $this->actor();
|
||||
$assignment = $this->grant($promoter, 'promoter');
|
||||
$draft = $this->draft($promoter);
|
||||
$manager = $this->actor();
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
$this->actingAs($manager)->get('/management/opportunities/'.$draft['id'].'/edit')->assertForbidden();
|
||||
$this->get('/management/opportunities')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
$this->putJson('/api/v1/investment/admin/opportunities/'.$draft['id'].'/context', [])->assertForbidden();
|
||||
$this->getJson('/management/territories/opportunities/'.$draft['id'])->assertForbidden();
|
||||
$this->getJson('/management/territory-subjects?kind=opportunities')->assertJsonCount(0, 'data');
|
||||
$this->postJson('/management/opportunity-origin/preview', ['id' => $draft['id'], 'classification' => 'real', 'reason' => self::REASON])->assertForbidden();
|
||||
$this->mfa($first)->postJson('/perfis/atribuicoes/'.$assignment.'/revogar', ['reason' => self::REASON])->assertOk();
|
||||
$this->actingAs($promoter)->withSession(['portal.access_revision' => 0])->getJson('/minhas-oportunidades/'.$draft['id'].'/view')->assertUnauthorized();
|
||||
$this->assertDatabaseHas('portal_role_assignments', ['id' => $assignment]);
|
||||
$this->assertFalse(app(PortalProfileAccess::class)->has($promoter, 'promoter'));
|
||||
}
|
||||
|
||||
public function test_superuser_pair_is_initialized_once_and_replaced_atomically_with_independent_approval(): void
|
||||
{
|
||||
$first = $this->actor();
|
||||
$second = $this->actor();
|
||||
$approver = $this->actor();
|
||||
$candidate = $this->actor();
|
||||
config(['portal_profiles.bootstrap_authorized' => true]);
|
||||
$this->mfa($approver);
|
||||
$service = app(PortalSuperusers::class);
|
||||
$service->initialize($first, $second, $approver);
|
||||
$service->initialize($first, $second, $approver);
|
||||
$this->assertSame(1, DB::table('portal_superuser_pair')->count());
|
||||
$this->mfa($first)->deleteJson('/user/two-factor-authentication')->assertForbidden();
|
||||
$this->deleteJson('/settings/profile')->assertForbidden();
|
||||
$this->patchJson('/settings/profile', ['email' => 'changed-qa@example.invalid'])->assertForbidden();
|
||||
$this->assertNotNull($first->fresh()->two_factor_secret);
|
||||
try {
|
||||
DB::transaction(fn () => DB::table('portal_superuser_pair')->delete());
|
||||
$this->fail('Removal of the pair must be blocked by PostgreSQL.');
|
||||
} catch (QueryException $error) {
|
||||
$this->assertStringContainsString('Superuser pair cannot be removed', $error->getMessage());
|
||||
}
|
||||
$this->assertSame(1, DB::table('portal_superuser_pair')->count());
|
||||
$this->mfa($first)->postJson('/perfis/atribuicoes', ['user_id' => $candidate->id, 'role' => 'superuser', 'opportunity_types' => ['investment_project'], 'reason' => self::REASON])->assertUnprocessable();
|
||||
$id = $this->postJson('/perfis/substituicoes', ['old_user_id' => $second->id, 'new_user_id' => $candidate->id, 'reason' => self::REASON])->assertCreated()->json('id');
|
||||
$this->postJson('/perfis/seguranca/'.$id.'/aprovar')->assertForbidden();
|
||||
$this->postJson('/perfis/substituicoes/'.$id.'/aplicar')->assertConflict();
|
||||
$this->mfa($second)->postJson('/perfis/seguranca/'.$id.'/aprovar')->assertOk();
|
||||
$this->mfa($first)->postJson('/perfis/substituicoes/'.$id.'/aplicar')->assertOk();
|
||||
$pair = DB::table('portal_superuser_pair')->first();
|
||||
$this->assertSame([$first->id, $candidate->id], [(int) $pair->first_user_id, (int) $pair->second_user_id]);
|
||||
$this->assertFalse(app(PortalProfileAccess::class)->superuser($second));
|
||||
$this->assertSame(1, (int) $second->fresh()->portal_access_revision);
|
||||
$this->postJson('/perfis/substituicoes/'.$id.'/aplicar')->assertConflict();
|
||||
$this->assertSame(1, DB::table('audit_events')->where('action', 'superuser_replaced')->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\PortalProfileInstallation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalProfileInstallationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $proofDirectory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->proofDirectory = storage_path('framework/testing/profile-install-'.Str::uuid());
|
||||
mkdir($this->proofDirectory, 0700, true);
|
||||
config(['portal_profiles.proof_directory' => $this->proofDirectory, 'portal.profile_access_enabled' => false]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->proofDirectory.'/*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
rmdir($this->proofDirectory);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function manager(): User
|
||||
{
|
||||
$user = $this->actor();
|
||||
config(['portal.managers' => [$user->email]]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function actor(): User
|
||||
{
|
||||
return User::factory()->create(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()]);
|
||||
}
|
||||
|
||||
private function login(User $user, bool $mfa = true): static
|
||||
{
|
||||
return $this->actingAs($user)->withSession(['portal.mfa_actor' => $mfa ? $user->id : null, 'portal.mfa_at' => $mfa ? time() : 0]);
|
||||
}
|
||||
|
||||
private function emptySchema(): void
|
||||
{
|
||||
(require database_path('migrations/'.PortalProfileInstallation::MIGRATION.'.php'))->down();
|
||||
DB::table('migrations')->where('migration', PortalProfileInstallation::MIGRATION)->delete();
|
||||
}
|
||||
|
||||
private function data(): array
|
||||
{
|
||||
return ['hash' => $this->getJson('/management/profile-install')->assertOk()->json('hash'), 'confirm' => true,
|
||||
'idempotency_key' => (string) Str::uuid(), 'reason' => 'Instalação sintética em base isolada de testes'];
|
||||
}
|
||||
|
||||
private function readinessFixture(): void
|
||||
{
|
||||
// Synthetic files exercise validation; they are never deployment backup evidence.
|
||||
file_put_contents($this->proofDirectory.'/dump', 'unit-fixture-not-a-real-dump');
|
||||
file_put_contents($this->proofDirectory.'/files', 'unit-fixture-not-real-files');
|
||||
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
file_put_contents($this->proofDirectory.'/readiness.json', json_encode(['verified_at' => gmdate('c'), 'target' => $target,
|
||||
'code_hash' => app(PortalProfileInstallation::class)->fingerprint(), 'restore_verified' => true, 'files_verified' => true,
|
||||
'qa_passed' => true, 'compatibility_verified' => true, 'dump_path' => $this->proofDirectory.'/dump', 'dump_sha256' => hash_file('sha256', $this->proofDirectory.'/dump'),
|
||||
'files_path' => $this->proofDirectory.'/files', 'files_sha256' => hash_file('sha256', $this->proofDirectory.'/files')], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function test_cold_install_repetition_empty_reversal_and_reinstallation_preserve_accounts(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$this->emptySchema();
|
||||
$this->assertFalse(Schema::hasColumn('users', 'portal_access_revision'));
|
||||
$this->login($actor);
|
||||
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('installed', false)->assertJsonPath('consistent', true)->assertJsonPath('ready', false);
|
||||
$this->postJson('/management/profile-install', $this->data())->assertUnprocessable();
|
||||
$this->readinessFixture();
|
||||
$users = DB::table('users')->count();
|
||||
$data = $this->data();
|
||||
$result = $this->postJson('/management/profile-install', $data)->assertOk()->assertJsonPath('changed', true)->json();
|
||||
$this->postJson('/management/profile-install', $data)->assertOk()->assertExactJson($result);
|
||||
$this->postJson('/management/profile-install', [...$data, 'reason' => $data['reason'].' different'])->assertConflict();
|
||||
$this->postJson('/management/profile-install', $this->data())->assertOk()->assertJsonPath('changed', false);
|
||||
$this->assertSame($users, DB::table('users')->count());
|
||||
$this->assertSame(0, DB::table('portal_role_assignments')->count());
|
||||
$this->assertSame(0, DB::table('portal_superuser_pair')->count());
|
||||
$this->assertFalse(config('portal.profile_access_enabled'));
|
||||
$this->assertFalse(config('portal_profiles.authenticated_installation'));
|
||||
$this->assertSame(2, DB::table('audit_events')->where('source_system', 'portal-profile-installation')->count());
|
||||
$this->emptySchema();
|
||||
$this->postJson('/management/profile-install', $this->data())->assertOk()->assertJsonPath('changed', true);
|
||||
}
|
||||
|
||||
public function test_dependencies_integrity_stale_preview_and_partial_schema_fail_closed(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$this->emptySchema();
|
||||
$this->login($actor);
|
||||
$this->readinessFixture();
|
||||
$data = $this->data();
|
||||
file_put_contents($this->proofDirectory.'/dump', 'tampered');
|
||||
$this->postJson('/management/profile-install', $data)->assertConflict();
|
||||
$this->postJson('/management/profile-install', $this->data())->assertUnprocessable();
|
||||
$this->readinessFixture();
|
||||
DB::table('migrations')->where('migration', '2026_10_09_030000_extend_opportunity_resources')->delete();
|
||||
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('dependencies.resources_030000', false);
|
||||
$this->postJson('/management/profile-install', $this->data())->assertUnprocessable();
|
||||
Schema::table('users', fn ($t) => $t->unsignedBigInteger('portal_access_revision')->default(0));
|
||||
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('consistent', false);
|
||||
$this->postJson('/management/profile-install', $this->data())->assertConflict();
|
||||
}
|
||||
|
||||
public function test_failed_audit_rolls_back_schema_and_ledger_and_can_retry(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$this->emptySchema();
|
||||
$this->login($actor);
|
||||
$this->readinessFixture();
|
||||
DB::statement("ALTER TABLE audit_events ADD CONSTRAINT qa_reject_profile_install CHECK (source_system <> 'portal-profile-installation')");
|
||||
$this->postJson('/management/profile-install', $this->data())->assertStatus(503);
|
||||
$this->assertFalse(Schema::hasTable('portal_role_assignments'));
|
||||
$this->assertFalse(Schema::hasColumn('users', 'portal_access_revision'));
|
||||
$this->assertFalse(DB::table('migrations')->where('migration', PortalProfileInstallation::MIGRATION)->exists());
|
||||
DB::statement('ALTER TABLE audit_events DROP CONSTRAINT qa_reject_profile_install');
|
||||
$this->postJson('/management/profile-install', $this->data())->assertOk()->assertJsonPath('changed', true);
|
||||
}
|
||||
|
||||
public function test_bootstrap_is_explicit_independent_separate_and_cannot_replace_existing_pair(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$first = $this->actor();
|
||||
$second = $this->actor();
|
||||
$this->login($actor, false);
|
||||
$this->postJson('/management/profile-install', $this->data())->assertForbidden();
|
||||
$this->login($actor);
|
||||
$this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $actor->id, 'second_user_id' => $second->id])->assertUnprocessable();
|
||||
$this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $first->id, 'second_user_id' => $second->id])->assertOk()->assertJsonPath('holders', [$first->id, $second->id]);
|
||||
$this->getJson('/management/profile-install')->assertForbidden();
|
||||
$this->login($first);
|
||||
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('bootstrap.initialized', true);
|
||||
$this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $first->id, 'second_user_id' => $actor->id])->assertConflict();
|
||||
$this->assertSame(1, DB::table('portal_superuser_pair')->count());
|
||||
$this->assertSame(0, DB::table('portal_role_assignments')->count());
|
||||
$this->assertFalse(config('portal_profiles.bootstrap_authorized'));
|
||||
}
|
||||
|
||||
public function test_auditor_restriction_and_history_prevent_privilege_or_destructive_rollback(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->actor(), [
|
||||
'user_id' => $actor->id, 'role' => 'auditor', 'opportunity_types' => [], 'source' => 'unit-synthetic-fixture']);
|
||||
$this->login($actor);
|
||||
$this->getJson('/management/profile-install')->assertForbidden();
|
||||
$this->postJson('/management/profile-install', ['hash' => str_repeat('a', 64), 'confirm' => true, 'idempotency_key' => (string) Str::uuid(), 'reason' => 'Instalação sintética vedada ao Auditor'])->assertForbidden();
|
||||
try {
|
||||
(require database_path('migrations/'.PortalProfileInstallation::MIGRATION.'.php'))->down();
|
||||
$this->fail('Used authorization schema must not be dropped.');
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('Preserve authorization history', $e->getMessage());
|
||||
}
|
||||
$this->assertSame(1, DB::table('portal_role_assignments')->count());
|
||||
$this->assertTrue(Schema::hasColumn('users', 'portal_access_revision'));
|
||||
}
|
||||
|
||||
public function test_rollback_preserves_context_even_without_author_or_grants(): void
|
||||
{
|
||||
DB::table('opportunities')->insert(['reference' => 'QA-rollback-context', 'slug' => 'qa-rollback-context', 'workflow_context' => '{"history":["synthetic-preserve"]}']);
|
||||
try {
|
||||
(require database_path('migrations/'.PortalProfileInstallation::MIGRATION.'.php'))->down();
|
||||
$this->fail('Context must survive rollback even without an owner.');
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->assertSame('Preserve scoped opportunities', $e->getMessage());
|
||||
}
|
||||
$this->assertTrue(Schema::hasColumn('opportunities', 'workflow_context'));
|
||||
$this->assertSame(1, DB::table('opportunities')->whereNotNull('workflow_context')->count());
|
||||
}
|
||||
|
||||
public function test_bootstrap_rejects_unreadable_mfa_and_auditor_candidate_without_creating_pair(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
$first = $this->actor();
|
||||
$second = $this->actor();
|
||||
$first->forceFill(['two_factor_secret' => 'synthetic-invalid-ciphertext'])->save();
|
||||
$this->login($manager);
|
||||
$this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $first->id, 'second_user_id' => $second->id])->assertUnprocessable();
|
||||
$this->assertSame(0, DB::table('portal_superuser_pair')->count());
|
||||
$first->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP')])->save();
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $manager, [
|
||||
'user_id' => $first->id, 'role' => 'auditor', 'opportunity_types' => [], 'source' => 'unit-synthetic-fixture']);
|
||||
$this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $first->id, 'second_user_id' => $second->id])->assertUnprocessable();
|
||||
$this->assertSame(0, DB::table('portal_superuser_pair')->count());
|
||||
$this->assertFalse(config('portal_profiles.bootstrap_authorized'));
|
||||
}
|
||||
|
||||
public function test_partial_schema_cannot_disable_identity_write_controls_but_recovery_is_accessible(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
$before = $manager->two_factor_secret;
|
||||
$this->login($manager);
|
||||
config(['portal.profile_access_enabled' => true]);
|
||||
Schema::table('users', fn ($t) => $t->dropColumn('portal_access_revision'));
|
||||
$this->deleteJson('/user/two-factor-authentication')->assertStatus(503);
|
||||
$this->deleteJson('/settings/profile', ['password' => 'not-a-credential'])->assertStatus(503);
|
||||
$this->assertSame($before, $manager->fresh()->two_factor_secret);
|
||||
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('consistent', false);
|
||||
$this->postJson('/management/profile-install', $this->data())->assertConflict();
|
||||
$this->post('/logout')->assertRedirect('/');
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
@@ -145,4 +145,79 @@ class TerritoryGeometryImportTest extends TestCase
|
||||
$this->assertSame(2, DB::table('geo_geometries')->whereNull('deleted_at')->count());
|
||||
$this->assertSame(0, DB::table('audit_events')->where('action', 'geometry_import_reversed')->count());
|
||||
}
|
||||
|
||||
public function test_explicit_maputo_correspondences_preserve_the_catalogue_and_are_bound_to_parent_and_exemplar(): void
|
||||
{
|
||||
$this->setupImport();
|
||||
$country = DB::table('geo_locations')->where('code', 'MZ')->value('id');
|
||||
$hash = config('geometry-correspondences.catalogue_sha256');
|
||||
foreach (config('geometry-correspondences.rows.1') as $entry) {
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'code' => 'QA-'.$id, 'source_system' => 'cartografia-workbook', 'external_id' => $hash.':'.$id,
|
||||
'source_code' => $entry['target_source_code'], 'name' => $entry['target_name'], 'location_type' => 'province', 'parent_id' => $country, 'data_classification' => 'internal']);
|
||||
}
|
||||
$before = DB::table('geo_locations')->orderBy('id')->get()->toJson();
|
||||
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('resolved', 4)->json();
|
||||
$rows = collect($plan['rows'])->filter(fn ($row) => $row['correspondence'] !== null);
|
||||
$this->assertCount(2, $rows);
|
||||
$this->assertSame(2, $rows->where('match_basis', 'explicit_versioned_correspondence')->count());
|
||||
// Change the mapping, not the source: the original preview can no longer be applied.
|
||||
config(['geometry-correspondences.version' => 'changed-qa-version']);
|
||||
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertConflict();
|
||||
config(['geometry-correspondences.version' => $plan['correspondence_version']]);
|
||||
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->assertJsonPath('inserted', 4)->json();
|
||||
$this->assertSame($before, DB::table('geo_locations')->orderBy('id')->get()->toJson());
|
||||
foreach ($rows as $row) {
|
||||
$geometry = DB::table('geo_geometries')->where('external_id', $row['external_id'])->first();
|
||||
$properties = json_decode($geometry->properties, true);
|
||||
$this->assertSame('internal', $geometry->data_classification);
|
||||
$this->assertSame($row['location_id'], $geometry->geo_location_id);
|
||||
$this->assertEquals($row['correspondence'], $properties['correspondence']);
|
||||
$this->assertSame($plan['correspondence_sha256'], $properties['correspondence_sha256']);
|
||||
}
|
||||
$this->assertCount(4, $result['created']);
|
||||
// A matching display name under a foreign parent must never acquire MZ06.
|
||||
DB::table('geo_locations')->where('source_code', '11')->update(['parent_id' => DB::table('geo_locations')->where('code', 'QA-FOREIGN')->value('id')]);
|
||||
$new = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->json();
|
||||
$city = collect($new['rows'])->firstWhere('external_id', '2019-06-07:adm1:MZ06');
|
||||
$this->assertNull($city['location_id']);
|
||||
$this->assertSame('name_unresolved', $city['status']);
|
||||
config(['geometry-correspondences.source_sha256.1' => str_repeat('a', 64)]);
|
||||
$this->getJson('/management/territory-geometry-import?level=1')->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_ambiguous_names_remain_pending_without_blocking_independent_unique_matches(): void
|
||||
{
|
||||
$this->setupImport();
|
||||
$location = DB::table('geo_locations')->where('location_type', 'province')->where('parent_id', DB::table('geo_locations')->where('code', 'MZ')->value('id'))->first();
|
||||
DB::table('geo_locations')->insert(['id' => (string) Str::uuid(), 'code' => 'QA-DUPLICATE-NAME', 'name' => $location->name,
|
||||
'parent_id' => $location->parent_id, 'location_type' => 'province', 'data_classification' => 'internal']);
|
||||
$before = DB::table('geo_locations')->orderBy('id')->get()->toJson();
|
||||
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('resolved', 1)->json();
|
||||
$ambiguous = collect($plan['rows'])->firstWhere('status', 'ambiguous_name');
|
||||
$this->assertNull($ambiguous['location_id']);
|
||||
$this->assertCount(2, $ambiguous['candidates']);
|
||||
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->assertJsonPath('inserted', 1)->assertJsonPath('pending', 10);
|
||||
$this->assertSame($before, DB::table('geo_locations')->orderBy('id')->get()->toJson());
|
||||
$this->assertFalse(DB::table('geo_geometries')->where('geo_location_id', $location->id)->exists());
|
||||
}
|
||||
|
||||
public function test_reversal_preserves_parent_correspondences_needed_by_active_descendant_boundaries(): void
|
||||
{
|
||||
$this->setupImport();
|
||||
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->json();
|
||||
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->json();
|
||||
$parent = DB::table('geo_geometries')->where('id', $result['created'][0]['id'])->first();
|
||||
$properties = json_decode($parent->properties, true);
|
||||
$provinceCode = $properties['source_properties']['ADM1_PCODE'];
|
||||
$child = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $child, 'code' => 'QA-'.$child, 'name' => 'Synthetic QA district', 'location_type' => 'district', 'parent_id' => $parent->geo_location_id, 'data_classification' => 'internal']);
|
||||
DB::insert('INSERT INTO geo_geometries(id,source_system,external_id,geo_location_id,geometry_type,geom,properties,data_classification) SELECT ?,?,?,?,geometry_type,geom,?::jsonb,? FROM geo_geometries WHERE id=?',
|
||||
[(string) Str::uuid(), 'ine-cod-ab', '2019-06-07:adm2:'.$provinceCode.'99', $child,
|
||||
json_encode(['source_version' => '2019-06-07', 'source_properties' => ['ADM1_PCODE' => $provinceCode]], JSON_THROW_ON_ERROR), 'internal', $parent->id]);
|
||||
$this->postJson('/management/territory-geometry-import/'.$result['batch_id'].'/reverse',
|
||||
['idempotency_key' => (string) Str::uuid(), 'reason' => 'Recusar reversão do pai enquanto descendentes QA dependem dele', 'confirm' => true])->assertConflict();
|
||||
$this->assertSame(3, DB::table('geo_geometries')->whereNull('deleted_at')->count());
|
||||
$this->assertSame(0, DB::table('audit_events')->where('action', 'geometry_import_reversed')->count());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
/** Valid original bytes for QA only; never an official/legal document. */
|
||||
final class SyntheticDocument
|
||||
{
|
||||
public static function pdf(string $text = 'Synthetic QA document - no official or legal value'): string
|
||||
{
|
||||
$text = str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text);
|
||||
$stream = 'BT /F1 12 Tf 12 60 Td ('.$text.') Tj ET';
|
||||
$objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
'<< /Length '.strlen($stream)." >>\nstream\n".$stream."\nendstream",
|
||||
];
|
||||
$pdf = "%PDF-1.4\n% Synthetic QA original\n";
|
||||
$offsets = [];
|
||||
foreach ($objects as $index => $object) {
|
||||
$offsets[] = strlen($pdf);
|
||||
$pdf .= ($index + 1)." 0 obj\n".$object."\nendobj\n";
|
||||
}
|
||||
$xref = strlen($pdf);
|
||||
$pdf .= "xref\n0 6\n0000000000 65535 f \n";
|
||||
foreach ($offsets as $offset) {
|
||||
$pdf .= sprintf('%010d 00000 n ', $offset)."\n";
|
||||
}
|
||||
|
||||
return $pdf."trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n".$xref."\n%%EOF\n";
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
"""Land originals and explicit room sharing via QA18097, with real MFA and scan."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from portal_profiles_http_session import Session, PRIVATE, EXPECTED, REASON, actors, events, PHP, ROOT
|
||||
|
||||
actors['legacy_manager'] = json.loads((PRIVATE / 'resource-http-actors-private.json').read_text(encoding='utf-8-sig'))['manager']
|
||||
path = PRIVATE / 'independent-documents-http-qa.json'
|
||||
receipt_path = PRIVATE / 'independent-documents-http-receipts-qa.json'
|
||||
receipts = json.loads(receipt_path.read_text(encoding='utf-8-sig')) if receipt_path.exists() else {}
|
||||
proof = {'target': EXPECTED, 'started_at': datetime.now(timezone.utc).isoformat(), 'active_writes': 0, 'interface_observed': False, 'land': [], 'room': []}
|
||||
|
||||
def save():
|
||||
receipt_path.write_text(json.dumps(receipts, indent=2), encoding='utf-8')
|
||||
|
||||
def originals():
|
||||
expression = "require 'tests/Support/SyntheticDocument.php';$i=imagecreatetruecolor(16,16);ob_start();imagepng($i);$p=ob_get_clean();ob_start();imagejpeg($i);$j=ob_get_clean();echo json_encode(['pdf'=>base64_encode(Tests\\Support\\SyntheticDocument::pdf()),'png'=>base64_encode($p),'jpg'=>base64_encode($j)]);"
|
||||
value = subprocess.check_output([PHP, '-d', 'extension=C:/Users/macbook/.config/investmoz/php-8.5.11/ext/php_gd.dll', '-r', expression], cwd=ROOT)
|
||||
return [(kind, mime, base64.b64decode(data)) for (kind, data), mime in zip(json.loads(value).items(), ['application/pdf', 'image/png', 'image/jpeg'])]
|
||||
|
||||
try:
|
||||
owner = Session('promoter').login()
|
||||
candidate = Session('colleague').login()
|
||||
outsider = Session('outsider').login()
|
||||
manager = Session('legacy_manager').login()
|
||||
files = originals()
|
||||
if 'case_id' not in receipts:
|
||||
result = owner.call('POST', '/investidor/terra-duat', {'payload': {'full_name': 'Synthetic QA document consumer', 'country': 'MZ', 'declared_origin_country_code': 'PT'}}, status=201)
|
||||
receipts['case_id'] = result['id']; save()
|
||||
base = '/investidor/terra-duat/' + receipts['case_id']
|
||||
def case():
|
||||
response = owner.call('GET', base, inertia=True)
|
||||
return response.get('props', response)
|
||||
for kind, mime, data in files:
|
||||
name = 'qa-consumer-original.' + kind
|
||||
page = case()
|
||||
existing = next((d for d in page['documents'] if d['file_name'] == name), None)
|
||||
if existing is None:
|
||||
owner.call('POST', base + '/documentos', {'version': page['case']['version'], 'category': 'other'}, multipart=(name, mime, data), status=201)
|
||||
existing = next(d for d in case()['documents'] if d['file_name'] == name)
|
||||
url = base + '/documentos/' + existing['id']
|
||||
original = owner.call('GET', url, binary=True)
|
||||
assert original == data
|
||||
manager.call('GET', url, status=404, binary=True)
|
||||
outsider.call('GET', url, status=404, binary=True)
|
||||
proof['land'].append({'type': kind, 'attachment_id': existing['id'], 'sha256': hashlib.sha256(data).hexdigest(), 'owner': 21, 'manager_private_route_denied': 404, 'other_owner_denied': 404})
|
||||
print_page = owner.call('GET', base + '/imprimir', inertia=True)
|
||||
assert print_page['component'] == 'land-assistance/print' and 'Não constitui' in print_page['props']['disclaimer']
|
||||
fresh = Session('promoter').login()
|
||||
response = fresh.call('GET', base, inertia=True)
|
||||
restored = response.get('props', response)
|
||||
assert restored['case']['payload']['country'] == 'MZ'
|
||||
assert restored['case']['payload']['declared_origin_country_code'] == 'PT'
|
||||
for row, (_, _, data) in zip(proof['land'], files):
|
||||
assert fresh.call('GET', base + '/documentos/' + row['attachment_id'], binary=True) == data
|
||||
proof['land_print'] = {'inertia_template_recovered': True, 'pdf_generated_by_browser': False, 'new_mfa_recovery': True, 'country_origin_recovered': ['MZ', 'PT']}
|
||||
if 'offer_id' not in receipts:
|
||||
draft = owner.call('POST', '/parcerias/rascunhos', {'client_key': str(uuid.uuid4()), 'title': 'Synthetic QA document sharing only', 'kind': 'seeking', 'partnership_type': 'joint_venture', 'offer': 'Synthetic QA capacity', 'seeking': 'Synthetic QA contribution', 'payload': {'summary': 'Synthetic QA; no actual undertaking', 'consent': True}, 'origins': [], 'locations': []})
|
||||
receipts['offer_id'] = draft['id']; save()
|
||||
offer_base = '/parcerias/' + str(receipts['offer_id'])
|
||||
offer = owner.call('GET', offer_base)['record']
|
||||
if offer['status'] == 'draft':
|
||||
owner.call('POST', offer_base + '/estado', {'version': offer['version'], 'action': 'submit', 'reason': REASON})
|
||||
offer = owner.call('GET', offer_base)['record']
|
||||
if offer['status'] != 'published':
|
||||
manager.call('POST', offer_base + '/estado', {'version': offer['version'], 'action': 'publish', 'reason': REASON})
|
||||
if 'interest_id' not in receipts:
|
||||
interest = candidate.call('POST', offer_base + '/interesses', {'proposal': 'Synthetic QA sharing request', 'payload': {'consent': True}})
|
||||
receipts['interest_id'] = interest['id']; save()
|
||||
interest = next(i for i in owner.call('GET', offer_base)['interests'] if i['id'] == receipts['interest_id'])
|
||||
for action in ['shortlist', 'connect']:
|
||||
if interest.get('room_id'):
|
||||
break
|
||||
if action == 'shortlist' and interest['status'] not in ['submitted', 'clarification']:
|
||||
continue
|
||||
owner.call('POST', '/parcerias/candidaturas/' + str(interest['id']), {'version': interest['version'], 'action': action, 'reason': REASON})
|
||||
interest = next(i for i in owner.call('GET', offer_base)['interests'] if i['id'] == receipts['interest_id'])
|
||||
receipts['room_id'] = interest['room_id']; save()
|
||||
room_base = '/parcerias/salas/' + str(receipts['room_id'])
|
||||
for kind, mime, data in files:
|
||||
name = 'qa-room-original.' + kind
|
||||
current = owner.call('GET', room_base)
|
||||
row = next((d for d in current['documents'] if d['file_name'] == name), None)
|
||||
if row is None:
|
||||
owner.call('POST', room_base + '/documentos', {'shared': 0}, multipart=(name, mime, data))
|
||||
row = next(d for d in owner.call('GET', room_base)['documents'] if d['file_name'] == name)
|
||||
url = room_base + '/documentos/' + str(row['id'])
|
||||
if row['shared']:
|
||||
owner.call('POST', url + '/partilha', {'shared': False})
|
||||
assert owner.call('GET', url, binary=True) == data
|
||||
candidate.call('GET', url, status=404, binary=True)
|
||||
candidate.call('POST', url + '/partilha', {'shared': True}, status=403)
|
||||
owner.call('POST', url + '/partilha', {'shared': True})
|
||||
assert candidate.call('GET', url, binary=True) == data
|
||||
manager.call('GET', url, status=403, binary=True)
|
||||
outsider.call('GET', url, status=403, binary=True)
|
||||
fresh_candidate = Session('colleague').login()
|
||||
assert fresh_candidate.call('GET', url, binary=True) == data
|
||||
owner.call('POST', url + '/partilha', {'shared': False})
|
||||
fresh_candidate.call('GET', url, status=404, binary=True)
|
||||
assert all(d['id'] != row['id'] for d in fresh_candidate.call('GET', room_base)['documents'])
|
||||
assert owner.call('GET', url, binary=True) == data
|
||||
proof['room'].append({'type': kind, 'attachment_id': row['id'], 'sha256': hashlib.sha256(data).hexdigest(), 'private_candidate': 404,
|
||||
'share_change_by_non_author': 403, 'explicit_share_recovered': True, 'new_mfa_session': True, 'share_revoked': 404,
|
||||
'manager_non_participant': 403, 'outsider': 403, 'original_preserved': True})
|
||||
proof['result'] = 'passed'
|
||||
except Exception as error:
|
||||
proof.update({'result': 'failed', 'failure': str(error)})
|
||||
raise
|
||||
finally:
|
||||
proof['events'] = events
|
||||
proof['receipts'] = receipts
|
||||
proof['completed_at'] = datetime.now(timezone.utc).isoformat()
|
||||
if path.exists():
|
||||
path.rename(PRIVATE / ('independent-documents-http-qa-' + datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + '.json'))
|
||||
path.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'result': proof['result'], 'target': EXPECTED, 'land_types': len(proof['land']), 'room_types': len(proof['room']), 'events': len(events), 'active_writes': 0}))
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
require __DIR__.'/portal-qa-environment.php';
|
||||
$target = portalQaEnvironment();
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
portalQaAssertTarget($target);
|
||||
$private = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
$http = json_decode(file_get_contents($private.'/independent-documents-http-qa.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
if ($http['result'] !== 'passed' || $http['target'] !== $target['database'].':'.$target['port']) {
|
||||
throw new RuntimeException('Successful HTTP proof for this QA target required.');
|
||||
}
|
||||
$proof = DB::transaction(function () use ($http, $target): array {
|
||||
DB::statement('SET TRANSACTION READ ONLY');
|
||||
$documents = [];
|
||||
foreach (['land' => 'land_assistance_documents', 'room' => 'partnership_documents'] as $scope => $table) {
|
||||
foreach ($http[$scope] as $row) {
|
||||
$link = DB::table($table)->where('id', $row['attachment_id'])->first();
|
||||
$document = DB::table('documents')->where('id', $link->document_id)->first();
|
||||
$bytes = Storage::disk('local')->get($document->storage_key);
|
||||
if ($document->storage_encryption === 'laravel') {
|
||||
$bytes = Crypt::decryptString($bytes);
|
||||
}
|
||||
$scan = DB::table('document_malware_scans')->where('document_id', $document->id)->where('result', 'clean')->exists();
|
||||
$valid = $document->status === 'active' && $document->malware_scan_status === 'clean'
|
||||
&& $document->malware_engine === 'ClamAV' && $document->malware_signatures_at !== null && $scan
|
||||
&& trim($document->sha256) === $row['sha256'] && $document->malware_scanned_sha256 === $row['sha256']
|
||||
&& hash('sha256', $bytes) === $row['sha256'] && strlen($bytes) === $document->size_bytes
|
||||
&& $document->uploaded_by === 21 && ($scope !== 'room' || ! $link->shared);
|
||||
if (! $valid) {
|
||||
throw new RuntimeException('Original/scan/permission evidence mismatch: '.$scope.'/'.$row['type']);
|
||||
}
|
||||
$documents[] = ['scope' => $scope, 'type' => $row['type'], 'document_id' => $document->id,
|
||||
'sha256' => $row['sha256'], 'engine' => $document->malware_engine, 'signatures_at' => $document->malware_signatures_at,
|
||||
'scanned_hash_matches_original' => true, 'original_bytes_preserved' => true, 'encrypted' => $document->storage_encryption === 'laravel'];
|
||||
}
|
||||
}
|
||||
$events = DB::table('partnership_events')->where('partnership_id', $http['receipts']['offer_id'])
|
||||
->where('event_type', 'partnership.document_sharing_changed.v1')->count();
|
||||
if (count($documents) !== 6 || $events < 6) {
|
||||
throw new RuntimeException('Expected six originals and six explicit sharing transitions.');
|
||||
}
|
||||
|
||||
return ['result' => 'passed', 'target' => $target, 'captured_at' => gmdate('c'), 'documents' => $documents,
|
||||
'sharing_audit_events' => $events, 'active_writes' => 0, 'interface_observed' => false];
|
||||
});
|
||||
file_put_contents($private.'/independent-documents-verification-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['result' => $proof['result'], 'documents' => count($proof['documents']), 'sharing_audit_events' => $proof['sharing_audit_events'], 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require $root.'/vendor/autoload.php';
|
||||
require __DIR__.'/portal-qa-environment.php';
|
||||
$target = portalQaEnvironment();
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = portalQaAssertTarget($target);
|
||||
DB::beginTransaction();
|
||||
DB::statement('SET TRANSACTION READ ONLY');
|
||||
$result = ['target' => $identity];
|
||||
foreach (['sectors', 'economic_activities', 'reference_catalog_versions', 'contacts', 'investors', 'organizations', 'tasks', 'documents', 'opportunity_location_drafts'] as $table) {
|
||||
$result[$table] = ['columns' => Schema::getColumnListing($table), 'count' => DB::table($table)->count()];
|
||||
}
|
||||
$result['activities'] = DB::table('economic_activities')->orderBy('code')->get(['id', 'parent_id', 'sector_id', 'code', 'label_pt_mz', 'status', 'catalog_version_id'])->all();
|
||||
file_put_contents($root.'/storage/app/private/investmoz-v2/portal-audit/opportunity-contract-inspect-qa.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
DB::rollBack();
|
||||
echo json_encode(['target' => $identity, 'counts' => array_map(fn ($v) => is_array($v) ? ($v['count'] ?? count($v)) : null, $result), 'active_writes' => 0]).PHP_EOL;
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Synthetic OOXML originals through the guarded QA web server; no secrets in proof."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import uuid
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from portal_profiles_http_session import Session, PRIVATE, EXPECTED, REASON, events
|
||||
|
||||
source = json.loads((PRIVATE / 'opportunity-semantic-http-qa.json').read_text(encoding='utf-8-sig'))
|
||||
assert source['result'] == 'passed' and source['target'] == EXPECTED
|
||||
record = next(c['id'] for c in source['cases'] if c['category'] == 'concession')
|
||||
base = f'/minhas-oportunidades/{record}'
|
||||
proof = {'target': EXPECTED, 'id': record, 'started_at': datetime.now(timezone.utc).isoformat(), 'active_writes': 0, 'interface_observed': False, 'types': []}
|
||||
|
||||
def file_bytes(kind):
|
||||
mime = 'application/vnd.openxmlformats-officedocument.' + ('wordprocessingml.document' if kind == 'docx' else 'spreadsheetml.sheet')
|
||||
part = 'word/document.xml' if kind == 'docx' else 'xl/workbook.xml'
|
||||
content = ('<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Synthetic QA no official value</w:t></w:r></w:p></w:body></w:document>' if kind == 'docx' else '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Synthetic QA" sheetId="1" r:id="rId1"/></sheets></workbook>')
|
||||
files = {
|
||||
'[Content_Types].xml': f'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/{part}" ContentType="{mime}.main+xml"/>' + ('<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' if kind == 'xlsx' else '') + '</Types>',
|
||||
'_rels/.rels': f'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="{part}"/></Relationships>',
|
||||
part: content,
|
||||
}
|
||||
if kind == 'xlsx':
|
||||
files['xl/_rels/workbook.xml.rels'] = '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>'
|
||||
files['xl/worksheets/sheet1.xml'] = '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Synthetic QA no official value</t></is></c></row></sheetData></worksheet>'
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
for name, text in files.items():
|
||||
ET.fromstring(text)
|
||||
z.writestr(name, text)
|
||||
with zipfile.ZipFile(io.BytesIO(buffer.getvalue())) as z:
|
||||
assert z.testzip() is None and z.read(part).decode() == content
|
||||
return mime, buffer.getvalue()
|
||||
|
||||
def page(s):
|
||||
return s.call('GET', base + '/edit', inertia=True)['props']
|
||||
|
||||
def resources(s):
|
||||
return s.call('GET', base + '/resources', inertia=True)['props']['resources']['documents']
|
||||
|
||||
try:
|
||||
owner = Session('promoter').login()
|
||||
reviewer = Session('validator').login()
|
||||
publisher = Session('publisher').login()
|
||||
outsider = Session('outsider').login()
|
||||
for kind in ['docx', 'xlsx']:
|
||||
mime, data = file_bytes(kind)
|
||||
title = 'Original sintético QA OOXML ' + kind
|
||||
old = next((r for r in resources(owner) if r['payload'].get('title') == title), None)
|
||||
if old:
|
||||
assert old['deleted_at'] is None
|
||||
resource_id = old['id']
|
||||
data = owner.call('GET', base + f'/resources/documents/{resource_id}/download', binary=True)
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
||||
assert z.testzip() is None
|
||||
for name in z.namelist():
|
||||
ET.fromstring(z.read(name))
|
||||
else:
|
||||
resource_id = owner.call('POST', base + '/resources/documents', {'etag': page(owner)['etag'], 'creation_key': str(uuid.uuid4()), 'reason': REASON,
|
||||
'payload': json.dumps({'title': title, 'category': 'qa-synthetic', 'classification': 'restricted', 'visibility': 'internal'})},
|
||||
multipart=('synthetic-qa.' + kind, mime, data), status=201)['id']
|
||||
row = next(r for r in resources(owner) if r['id'] == resource_id)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
assert row['file']['media_type'] == mime and row['file']['malware_scan_status'] == 'clean' and row['file']['sha256'].strip() == digest
|
||||
url = base + f'/resources/documents/{resource_id}/download'
|
||||
for actor in [owner, reviewer, publisher]:
|
||||
assert actor.call('GET', url, binary=True) == data
|
||||
outsider.call('GET', url, status=403, binary=True)
|
||||
for action in ['delete', 'restore']:
|
||||
current = next(r for r in resources(owner) if r['id'] == resource_id)
|
||||
owner.call('POST', base + f'/resources/documents/{resource_id}/lifecycle', {'etag': page(owner)['etag'], 'version': current['version'], 'action': action, 'reason': REASON, 'confirm': True})
|
||||
if action == 'delete':
|
||||
owner.call('GET', url, status=404, binary=True)
|
||||
assert resource_id not in [d['id'] for d in page(owner)['canonicalOptions']['resources']['documents']]
|
||||
fresh = Session('promoter').login()
|
||||
assert fresh.call('GET', url, binary=True) == data
|
||||
proof['types'].append({'type': kind, 'resource_id': resource_id, 'sha256': digest, 'package_xml_crc_valid': True, 'clamd_real_clean': True,
|
||||
'allowed': [21, 24, 25], 'foreign_organization': 403, 'deleted_download': 404, 'original_restored_identical_new_session': True})
|
||||
proof['result'] = 'passed'
|
||||
except Exception as error:
|
||||
proof.update({'result': 'failed', 'failure': str(error)})
|
||||
raise
|
||||
finally:
|
||||
proof['events'] = events
|
||||
proof['completed_at'] = datetime.now(timezone.utc).isoformat()
|
||||
(PRIVATE / 'opportunity-office-documents-http-qa.json').write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'result': proof['result'], 'target': EXPECTED, 'types': [r['type'] for r in proof['types']], 'events': len(events), 'active_writes': 0}))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user