@@ -254,7 +254,7 @@ class ImportDocumentBatch extends Command
|
||||
throw new RuntimeException('File size is outside the 1 byte to 10 MiB import limit.');
|
||||
}
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path);
|
||||
$allowed = ['application/pdf', 'text/plain', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
$allowed = ['application/pdf', 'application/json', 'text/plain', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg', 'image/webp'];
|
||||
if (! is_string($mime) || ! in_array($mime, $allowed, true)) {
|
||||
throw new RuntimeException('Detected media type is not supported by the document library.');
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\LegacyContent;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -21,7 +23,20 @@ class InvestorPathController extends Controller
|
||||
|
||||
public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
|
||||
{
|
||||
$id = $path->create($request->user(), $request->all());
|
||||
$source = $request->input('content_resource_id');
|
||||
if ($source !== null) {
|
||||
abort_unless(filter_var($source, FILTER_VALIDATE_INT), 422);
|
||||
app(LegacyContent::class)->articleForResource((int) $source);
|
||||
}
|
||||
$id = DB::transaction(function () use ($path, $request, $source): string {
|
||||
$id = $path->create($request->user(), $request->all());
|
||||
if ($source !== null) {
|
||||
$case = $path->view($request->user(), $id)['case'];
|
||||
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
|
||||
}
|
||||
|
||||
return $id;
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $id], 201) : redirect('/investimentos/'.$id)->with('success', 'Investimento guardado no seu dossier.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\LegacyContent;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class LegacyContentController extends Controller
|
||||
{
|
||||
public function landing(Request $request, LegacyContent $legacy, string $sectorSlug = 'agronegocio'): Response
|
||||
{
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:120']);
|
||||
$articles = $legacy->articlesForGroup($sectorSlug);
|
||||
$root = config('legacy_content.groups.'.$sectorSlug.'.root');
|
||||
$article = $legacy->article($root);
|
||||
$q = $filters['q'] ?? '';
|
||||
$articles = array_values(array_filter($articles, fn ($item) => $q === ''
|
||||
|| str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q))));
|
||||
$match = $articles !== [];
|
||||
|
||||
return Inertia::render('portal/legacy', ['article' => $article, 'articles' => $articles, 'view' => 'landing', 'query' => $q, 'match' => $match]);
|
||||
}
|
||||
|
||||
public function article(Request $request, LegacyContent $legacy, string $legacySlug = 'agronegocio'): Response
|
||||
{
|
||||
return Inertia::render('portal/legacy', ['article' => $legacy->article($legacySlug), 'view' => 'article', 'contentContext' => $this->context($request)]);
|
||||
}
|
||||
|
||||
public function start(Request $request, LegacyContent $legacy, InvestorPath $path, string $legacySlug = 'agronegocio'): Response
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
$article = $legacy->article($legacySlug);
|
||||
|
||||
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article]);
|
||||
}
|
||||
|
||||
public function attach(Request $request, string $id, LegacyContent $legacy): JsonResponse|RedirectResponse
|
||||
{
|
||||
$legacy->attach($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['saved' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência guardada no dossier.');
|
||||
}
|
||||
|
||||
/** @return array{id: string, title: string}|null */
|
||||
private function context(Request $request): ?array
|
||||
{
|
||||
$data = $request->validate(['context' => 'nullable|uuid']);
|
||||
if (! $request->user() || empty($data['context'])) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$case = app(VirtualDesk::class)->case($request->user(), $data['context'], true);
|
||||
|
||||
return $case->closed_at ? null : ['id' => $case->id, 'title' => $case->title];
|
||||
} catch (HttpException $error) {
|
||||
if (! in_array($error->getStatusCode(), [403, 404], true)) {
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return null; // Consultation stays public; an unauthorized private context is never returned.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityCanonicalResponse;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityManagementInstallation;
|
||||
use App\Services\OpportunityMaturity;
|
||||
@@ -126,6 +127,7 @@ class OpportunityManagementController extends Controller
|
||||
'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(),
|
||||
'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
|
||||
'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) : [],
|
||||
@@ -151,6 +153,12 @@ class OpportunityManagementController extends Controller
|
||||
return response()->json(['id' => $record->id, 'etag' => $service->etag($record), 'message' => 'Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.'], $id ? 200 : 201)->header('ETag', '"'.$service->etag($record).'"');
|
||||
}
|
||||
|
||||
public function canonicalResponse(Request $request, int $id, OpportunityCanonicalResponse $response): JsonResponse
|
||||
{
|
||||
return response()->json($response->read($request->user(), Opportunity::withTrashed()->findOrFail($id)))
|
||||
->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function lifecycle(Request $request, int $id, OpportunityManagement $service): JsonResponse
|
||||
{
|
||||
abort_unless($service->installed(), 503);
|
||||
|
||||
@@ -138,13 +138,20 @@ class OpportunityResourceController extends Controller
|
||||
$districts = [];
|
||||
foreach ($locations as $location) {
|
||||
$p = $location->payload;
|
||||
if (empty($p['province_id']) || empty($p['district_id']) || empty($p['role'])) {
|
||||
$issues[] = 'Uma localização tem província, distrito ou papel pendente.';
|
||||
$mozambican = empty($p['country_code']) || $p['country_code'] === 'MZ';
|
||||
if ($mozambican && (empty($p['province_id']) || empty($p['district_id']))) {
|
||||
$issues[] = 'Uma localização em Moçambique tem província ou distrito pendente.';
|
||||
}
|
||||
if (! empty($p['district_id'])) {
|
||||
if (! $mozambican && array_filter(array_intersect_key($p, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id'])))) {
|
||||
$issues[] = 'Uma localização noutro país conserva uma seleção territorial de Moçambique incompatível; corrigir explicitamente sem converter o histórico.';
|
||||
}
|
||||
if (empty($p['role'])) {
|
||||
$issues[] = 'Uma localização tem papel pendente.';
|
||||
}
|
||||
if ($mozambican && ! empty($p['district_id'])) {
|
||||
$districts[$p['district_id']] = true;
|
||||
}
|
||||
if (! empty($p['province_id'])) {
|
||||
if ($mozambican && ! empty($p['province_id'])) {
|
||||
$provinces[$p['province_id']] = true;
|
||||
}
|
||||
if (($p['role'] ?? null) === 'other' && empty($p['role_description'])) {
|
||||
@@ -164,7 +171,7 @@ class OpportunityResourceController extends Controller
|
||||
$issues[] = 'Multidistrito/interprovincial exige fundamento operacional de 50–1000 caracteres.';
|
||||
}
|
||||
if ($locations) {
|
||||
$issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : 'district')).'. Sem conversão de contexto em implantação.';
|
||||
$issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito administrativo moçambicano derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : ($districts ? 'district' : 'sem seleção moçambicana'))).'. Sem conversão de contexto em implantação.';
|
||||
}
|
||||
foreach (['images', 'documents'] as $kind) {
|
||||
foreach ($resources[$kind] as $r) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Sector;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\IdempotencyConflict;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\LegacyContent;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use App\Services\VirtualDesk;
|
||||
@@ -366,7 +367,7 @@ class PortalServicesController extends Controller
|
||||
|
||||
public function upload(Request $request): RedirectResponse
|
||||
{
|
||||
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text', 'max:10240'],
|
||||
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text'.(app(LegacyContent::class)->available() ? ',application/json' : ''), 'max:10240'],
|
||||
'original_source' => ['nullable', 'url:https', 'max:2000'], 'issuer' => ['nullable', 'string', 'max:180']]);
|
||||
$file = $request->file('file');
|
||||
$checksum = hash_file('sha256', $file->getRealPath());
|
||||
@@ -635,7 +636,9 @@ class PortalServicesController extends Controller
|
||||
$data = $this->validateManagement($request, [
|
||||
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
|
||||
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
|
||||
'topic' => ['required', 'string', 'max:80'], 'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
|
||||
// QA legacy originals may have no theme (e.g. a shared image). Do not invent a sector to disclose an authorized original.
|
||||
'topic' => [app(\App\Services\LegacyContent::class)->available() ? 'nullable' : 'required', 'string', 'max:80'],
|
||||
'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
|
||||
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
|
||||
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
|
||||
'applicable_from' => ['nullable', 'date_format:Y-m-d'],
|
||||
@@ -656,6 +659,7 @@ class PortalServicesController extends Controller
|
||||
'associations.*.applicability' => ['required', 'in:required,conditional,support,optional_template,evidence'],
|
||||
'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
$data['topic'] = $data['topic'] ?? '';
|
||||
if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) {
|
||||
throw $this->managementValidationException(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']);
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class VirtualDeskController extends Controller
|
||||
}
|
||||
}
|
||||
$this->auditRead($request, 'desk_requests', $id);
|
||||
$data = ['countries' => app(CountryCatalogue::class)->all(), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
|
||||
$data = ['countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
|
||||
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths,
|
||||
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
|
||||
'owner' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'analyst' => (int) $row->analyst_id === $request->user()->id,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\LegacyContent;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -51,6 +52,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'name' => config('app.name'),
|
||||
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
|
||||
'portalOperator' => config('portal.operator_name'),
|
||||
'legacyContentEnabled' => app(LegacyContent::class)->available(),
|
||||
'educationEnabled' => (bool) config('education.enabled', false),
|
||||
'aiEnabled' => (bool) config('investmoz_ai.enabled', false),
|
||||
'existingBusinessEnabled' => (bool) config('existing-business.enabled', false),
|
||||
|
||||
@@ -131,6 +131,7 @@ final class InvestorPath
|
||||
'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at,
|
||||
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => '#'.$next['code']] : null,
|
||||
'results' => $results, 'requests' => $requests, 'documents' => $documents,
|
||||
'content_sources' => ($owner || $manager) ? ($path['content_sources'] ?? []) : [],
|
||||
'tasks' => DB::table('tasks')->where('desk_case_id', $id)
|
||||
->whereNull('deleted_at')
|
||||
->when(! $owner && ! $manager, fn ($q) => $q->where('assignee_user_id', $actor->id))
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/** A reading adapter over the existing library, never a second content/opportunity register. */
|
||||
final class LegacyContent
|
||||
{
|
||||
public function available(): bool
|
||||
{
|
||||
if (! config('portal.legacy_content_qa_enabled') || ! app()->environment('testing')
|
||||
|| config('database.connections.pgsql.host') !== '127.0.0.1'
|
||||
|| config('database.connections.pgsql.url') || (int) config('database.connections.pgsql.port') !== 55441) {
|
||||
return false;
|
||||
}
|
||||
$actual = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
|
||||
|
||||
return $actual && str_ends_with($actual->database, '_test') && (int) $actual->port === 55441
|
||||
&& $actual->database === config('database.connections.pgsql.database');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function article(string $slug = 'agronegocio'): array
|
||||
{
|
||||
abort_unless($this->available(), 404);
|
||||
$identity = config('legacy_content.articles.'.$slug);
|
||||
abort_unless(is_array($identity) && ($identity['publishable'] ?? false), 404);
|
||||
$row = $this->publicResources()->where('d.external_id', $identity['external_id'])->first();
|
||||
abort_unless($row !== null, 404);
|
||||
$binary = app(VerifiedDocumentBinary::class)->respond($row);
|
||||
$bytes = $binary instanceof BinaryFileResponse ? file_get_contents($binary->getFile()->getPathname()) : $binary->getContent();
|
||||
abort_unless(is_string($bytes) && strlen($bytes) <= 10485760 && hash_equals(trim($row->sha256), hash('sha256', $bytes)), 404);
|
||||
try {
|
||||
$source = json_decode($bytes, true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
abort(404);
|
||||
}
|
||||
abort_unless(is_array($source) && ($source['format'] ?? '') === 'investmoz-legacy-article-v1'
|
||||
&& ($source['source_url'] ?? '') === $row->source_url && $row->source_url === $identity['source_url']
|
||||
&& is_string($source['content_html'] ?? null) && trim(strip_tags($source['content_html'])) !== '', 404);
|
||||
foreach (['title', 'author', 'legacy_category', 'published_at', 'modified_at'] as $key) {
|
||||
abort_unless(is_string($source[$key] ?? null), 404);
|
||||
}
|
||||
abort_unless(is_int($source['legacy_id'] ?? null), 404);
|
||||
$image = $this->publicResources()->where('d.sha256', $source['image_sha256'] ?? '')->where('d.media_type', 'image/png')->first();
|
||||
$sector = Sector::where('slug', $identity['sector_slug'])->where('is_active', true)->first();
|
||||
// These are independently published records in the same sector, not claims of article/project linkage.
|
||||
$opportunities = $sector ? Opportunity::published()->where('sector_id', $sector->id)->latest('published_at')
|
||||
->limit(12)->get(['title', 'slug', 'province', 'summary']) : [];
|
||||
|
||||
return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
|
||||
'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'],
|
||||
'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'],
|
||||
'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'],
|
||||
'href' => '/'.$slug, 'html' => $this->localLinks($this->sanitize($source['content_html'])),
|
||||
'landing_href' => '/explorar/'.$identity['group'], 'start_href' => '/investimentos/a-partir-de/'.$slug,
|
||||
'group' => config('legacy_content.groups.'.$identity['group']),
|
||||
'related_articles' => $this->related($identity['group'], $slug),
|
||||
'image' => $image ? ['href' => '/documentos/'.$image->resource_id.'/view', 'sha256' => trim($image->sha256),
|
||||
'alt' => $slug === 'agronegocio' ? 'Campo agrícola ao pôr do sol; fotografia ilustrativa do artigo Agronegocio'
|
||||
: 'Imagem ilustrativa original do artigo '.$source['title'], 'source_url' => $image->source_url] : null,
|
||||
'sector' => $sector?->only(['id', 'name', 'slug']), 'opportunities' => $opportunities,
|
||||
'territorial_references' => array_values(array_filter($slug === 'agronegocio' ? ['Pemba–Lichinga', 'Nacala', 'Vale do Zambeze', 'Beira', 'Limpopo', 'Maputo'] : [],
|
||||
fn ($name) => str_contains(strip_tags($source['content_html']), $name))),
|
||||
'territorial_status' => 'Menções territoriais preservadas no artigo. Limites administrativos, parcelas e geometrias não associados automaticamente.',
|
||||
'library_href' => '/documentos/'.$row->resource_id,
|
||||
'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false];
|
||||
}
|
||||
|
||||
/** Resolve a library identity, preserving the already stored source URLs and versions. */
|
||||
public function articleForResource(int $resourceId): array
|
||||
{
|
||||
abort_unless($this->available(), 404);
|
||||
$externalId = $this->publicResources()->where('r.id', $resourceId)->first()?->external_id;
|
||||
foreach (config('legacy_content.articles', []) as $slug => $identity) {
|
||||
if ($identity['external_id'] === $externalId && $identity['publishable']) {
|
||||
return $this->article($slug);
|
||||
}
|
||||
}
|
||||
abort(422, 'Escolha um artigo disponível para consulta; a referência não foi guardada.');
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function articlesForGroup(string $group): array
|
||||
{
|
||||
abort_unless($this->available() && is_array(config('legacy_content.groups.'.$group)), 404);
|
||||
$articles = [];
|
||||
foreach ($this->related($group) as $item) {
|
||||
$articles[] = $this->article($item['slug']);
|
||||
}
|
||||
abort_unless($articles !== [], 404);
|
||||
return $articles;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function related(string $group, ?string $except = null): array
|
||||
{
|
||||
$identities = array_filter(config('legacy_content.articles', []), fn ($identity, $slug) =>
|
||||
$identity['group'] === $group && $identity['publishable'] && $slug !== $except, ARRAY_FILTER_USE_BOTH);
|
||||
$resources = $this->publicResources()->whereIn('d.external_id', array_column($identities, 'external_id'))->get()->keyBy('external_id');
|
||||
$result = [];
|
||||
foreach ($identities as $slug => $identity) {
|
||||
$row = $resources->get($identity['external_id']);
|
||||
if ($row && $row->source_url === $identity['source_url']) {
|
||||
$result[] = ['slug' => $slug, 'id' => (int) $row->resource_id, 'title' => $row->title, 'href' => '/'.$slug];
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function localLinks(string $html): string
|
||||
{
|
||||
$public = $this->publicResources()->whereIn('d.external_id', array_column(config('legacy_content.articles', []), 'external_id'))
|
||||
->get()->keyBy('external_id');
|
||||
$links = [];
|
||||
foreach (config('legacy_content.articles', []) as $slug => $identity) {
|
||||
if ($identity['publishable'] && $public->get($identity['external_id'])?->source_url === $identity['source_url']) {
|
||||
$links[rtrim($identity['source_url'], '/')] = '/'.$slug;
|
||||
}
|
||||
}
|
||||
$doc = new DOMDocument;
|
||||
$old = libxml_use_internal_errors(true);
|
||||
try {
|
||||
$doc->loadHTML('<?xml encoding="UTF-8"><html><body>'.$html.'</body></html>', LIBXML_NONET);
|
||||
foreach ($doc->getElementsByTagName('a') as $anchor) {
|
||||
$href = $anchor->getAttribute('href');
|
||||
if (isset($links[rtrim($href, '/')])) { $anchor->setAttribute('href', $links[rtrim($href, '/')]); }
|
||||
}
|
||||
$body = $doc->getElementsByTagName('body')->item(0);
|
||||
return $body ? implode('', array_map(fn ($child) => $doc->saveHTML($child), iterator_to_array($body->childNodes))) : '';
|
||||
} finally { libxml_clear_errors(); libxml_use_internal_errors($old); }
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
public function attach(User $actor, string $caseId, array $input): void
|
||||
{
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'resource_id' => 'required|integer|min:1'])->validate();
|
||||
$article = $this->articleForResource((int) $data['resource_id']);
|
||||
abort_unless(app(InvestorPath::class)->available(), 404);
|
||||
DB::transaction(function () use ($actor, $caseId, $data, $article): void {
|
||||
$desk = app(VirtualDesk::class);
|
||||
$case = $desk->case($actor, $caseId, true, true);
|
||||
abort_if($case->closed_at, 409);
|
||||
abort_unless(DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(),
|
||||
409, 'Este dossier conserva outro roteiro; não foi convertido automaticamente.');
|
||||
$payload = $desk->decode($case->payload);
|
||||
foreach ($payload['investor_path']['content_sources'] ?? [] as $existing) {
|
||||
if ($existing['resource_id'] === $article['id'] && $existing['sha256'] === $article['sha256']) {
|
||||
return; // Same source/version replay: neither another reference nor another event.
|
||||
}
|
||||
}
|
||||
abort_unless((int) $case->version === $data['version'], 409, 'O dossier foi alterado; recupere a versão atual.');
|
||||
$payload['investor_path']['content_sources'][] = ['resource_id' => $article['id'], 'document_id' => $article['document_id'],
|
||||
'sha256' => $article['sha256'], 'metadata_version' => $article['version'], 'title' => $article['title'],
|
||||
'source_url' => $article['source_url'], 'return_href' => $article['href'], 'actor_id' => $actor->id,
|
||||
'saved_at' => now()->toIso8601String()];
|
||||
DB::table('desk_cases')->where('id', $caseId)->update(['payload' => $desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]);
|
||||
$desk->event($desk->case($actor, $caseId), $actor, 'legacy_content_referenced', ['resource_id' => $article['id'], 'sha256' => $article['sha256']]);
|
||||
});
|
||||
}
|
||||
|
||||
private function publicResources(): Builder
|
||||
{
|
||||
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->select(['d.*', 'r.id as resource_id', 'r.metadata_version', 'r.source_url', 'r.title']);
|
||||
}
|
||||
|
||||
/** Allow presentation markup only. No scripts, styles, events, embeds or remote image execution. */
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
$doc = new DOMDocument;
|
||||
$old = libxml_use_internal_errors(true);
|
||||
try {
|
||||
$doc->loadHTML('<?xml encoding="UTF-8"><html><body>'.$html.'</body></html>', LIBXML_NONET);
|
||||
$body = $doc->getElementsByTagName('body')->item(0);
|
||||
// Preserve the source text and relative sections, but start the displayed
|
||||
// article below the page h1. Originals and their hashes remain untouched.
|
||||
$sourceLevels = [];
|
||||
foreach (['h1', 'h2', 'h3', 'h4'] as $heading) {
|
||||
if ($doc->getElementsByTagName($heading)->length > 0) {
|
||||
$sourceLevels[] = (int) substr($heading, 1);
|
||||
}
|
||||
}
|
||||
$headingOffset = 2 - (empty($sourceLevels) ? 2 : min($sourceLevels));
|
||||
$previousHeading = 1;
|
||||
$clean = function (DOMNode $node) use (&$clean, $doc, $headingOffset, &$previousHeading): void {
|
||||
foreach (iterator_to_array($node->childNodes) as $child) {
|
||||
if (! $child instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^h([1-4])$/i', $child->tagName, $level)) {
|
||||
$displayLevel = max(2, min(6, (int) $level[1] + $headingOffset, $previousHeading + 1));
|
||||
$previousHeading = $displayLevel;
|
||||
$section = $doc->createElement('h'.$displayLevel);
|
||||
while ($child->firstChild !== null) {
|
||||
$section->appendChild($child->firstChild);
|
||||
}
|
||||
$node->replaceChild($section, $child);
|
||||
$child = $section;
|
||||
}
|
||||
if (! in_array(strtolower($child->tagName), ['p', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'br', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'a'], true)) {
|
||||
$node->removeChild($child);
|
||||
|
||||
continue;
|
||||
}
|
||||
$href = $child->tagName === 'a' ? $child->getAttribute('href') : '';
|
||||
foreach (iterator_to_array($child->attributes) as $attribute) {
|
||||
$child->removeAttribute($attribute->nodeName);
|
||||
}
|
||||
if ($href !== '') {
|
||||
if (str_starts_with($href, '/') && ! str_starts_with($href, '//')) {
|
||||
$href = 'https://investemoz.tempo.co.mz'.$href;
|
||||
}
|
||||
if (filter_var($href, FILTER_VALIDATE_URL) && strtolower((string) parse_url($href, PHP_URL_SCHEME)) === 'https') {
|
||||
$child->setAttribute('href', $href);
|
||||
$child->setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
$clean($child);
|
||||
}
|
||||
};
|
||||
if (! $body) {
|
||||
return '';
|
||||
}
|
||||
$clean($body);
|
||||
|
||||
return implode('', array_map(fn ($child) => $doc->saveHTML($child), iterator_to_array($body->childNodes)));
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($old);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
use Opis\JsonSchema\CompliantValidator;
|
||||
use Opis\JsonSchema\Errors\ErrorFormatter;
|
||||
@@ -29,6 +30,7 @@ class OpportunityCanonicalContract
|
||||
$schema['$defs'][$name.'Create']['allOf'] = $schema['$defs'][$name]['allOf'] ?? [];
|
||||
}
|
||||
$schema = app(OpportunityCategoryFields::class)->enrich($schema);
|
||||
$schema = app(OpportunityModularProfiles::class)->extend($schema);
|
||||
$schema = $this->writeSchema($schema, $draft);
|
||||
$properties = &$schema['$defs']['opportunityCreate']['properties'];
|
||||
unset($properties['mutation_context']); // Actor, audit reason and revision are server-owned.
|
||||
@@ -240,6 +242,14 @@ class OpportunityCanonicalContract
|
||||
ksort($payload);
|
||||
$snapshot = ['adapter_version' => self::VERSION, 'schema_sha256' => hash_file('sha256', resource_path('data/investmoz-opportunity-modular.schema.json')),
|
||||
'payload' => $payload, 'resources' => $resources];
|
||||
if (isset(OpportunityModularProfiles::PROFILES[$record->opportunity_category]) || ($payload['module_profile_version'] ?? '') === OpportunityModularProfiles::HISTORY_VERSION) {
|
||||
$snapshot['qa_extension'] = ['version' => OpportunityModularProfiles::VERSION, 'profile_version' => $payload['module_profile_version'],
|
||||
'functional_decision' => 'User recommendations accepted for QA, 10/10/2026; no active approval or security-holder nomination',
|
||||
'gates' => $this->gates($record)];
|
||||
if ($record->opportunity_category === 'other') {
|
||||
$snapshot['qa_extension']['subtype'] = config('portal.opportunity_other_qa_subtypes.'.($payload['module_details']['subtype'] ?? ''), []);
|
||||
}
|
||||
}
|
||||
$snapshot['hash'] = hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
|
||||
|
||||
return $snapshot;
|
||||
@@ -253,9 +263,7 @@ class OpportunityCanonicalContract
|
||||
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.';
|
||||
}
|
||||
app(OpportunityModularProfiles::class)->validate($p, $resources, $errors);
|
||||
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']
|
||||
@@ -346,6 +354,7 @@ class OpportunityCanonicalContract
|
||||
$p = $snapshot['payload'];
|
||||
$errors = [];
|
||||
app(OpportunityCategoryFields::class)->publication($p, $errors);
|
||||
app(OpportunityModularProfiles::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.';
|
||||
@@ -400,7 +409,7 @@ class OpportunityCanonicalContract
|
||||
'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 => [],
|
||||
default => app(OpportunityModularProfiles::class)->gates($record),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -411,7 +420,17 @@ class OpportunityCanonicalContract
|
||||
{
|
||||
$business = isset($payload['business_transaction']) && ($payload['visibility'] ?? '') === 'public';
|
||||
|
||||
return ['title' => (string) ($payload['official_title'] ?? ''), 'summary' => (string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['executive_summary'] ?? '')),
|
||||
$label = match ($payload['module_details']['mode'] ?? '') {
|
||||
'intention_call' => 'Intenção/chamada em preparação — não é direito atribuído. ',
|
||||
'awarded_right' => 'Concessão com ato competente documentado. ',
|
||||
'seeking_funding' => 'Procura de financiamento. ', 'funding_offer' => 'Oferta de financiamento — sujeita a análise do beneficiário. ',
|
||||
'purchase_request' => 'Pedido de compra. ', 'supply_offer' => 'Oferta de fornecimento. ', default => '',
|
||||
};
|
||||
if (($payload['module_details']['conditions_status'] ?? '') === 'proposed') {
|
||||
$label .= 'Valores e condições propostos, sem compromisso assumido. ';
|
||||
}
|
||||
|
||||
return ['title' => (string) ($payload['official_title'] ?? ''), 'summary' => Str::limit($label.(string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['executive_summary'] ?? '')), 499, '…'),
|
||||
'description' => (string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['demand_statement'] ?? ''))];
|
||||
}
|
||||
|
||||
@@ -427,7 +446,9 @@ class OpportunityCanonicalContract
|
||||
->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'],
|
||||
return ['version' => self::VERSION, 'modularProfiles' => app(OpportunityModularProfiles::class)->enabled() ? OpportunityModularProfiles::PROFILES : [],
|
||||
'historyVersion' => app(OpportunityModularProfiles::class)->enabled() ? OpportunityModularProfiles::HISTORY_VERSION : null,
|
||||
'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(),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
|
||||
/** Read adapter only: preserve persisted identifiers and make unresolved metadata explicit. */
|
||||
class OpportunityCanonicalResponse
|
||||
{
|
||||
public const VERSION = 'opportunity-response-existing-ids-20261010-1';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function read(User $actor, Opportunity $record): array
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
$access->authorize($actor, 'view', $record);
|
||||
$payload = app(OpportunityManagement::class)->values($record)['canonical'] ?? [];
|
||||
$context = $record->workflow_context ?? [];
|
||||
$data = [...$payload, 'id' => $record->id, 'opportunity_code' => $record->reference,
|
||||
'opportunity_category' => $record->opportunity_category, 'status' => $access->state($record),
|
||||
'version' => (int) $record->management_version, 'is_deleted' => $record->trashed(),
|
||||
'deleted_at' => $record->deleted_at?->toIso8601String()];
|
||||
foreach (['submitted' => 'submission', 'approved' => 'approval', 'published' => 'publication'] as $prefix => $key) {
|
||||
$data[$prefix.'_by'] = $context[$key]['actor_id'] ?? null;
|
||||
$data[$prefix.'_at'] = $context[$key]['at'] ?? null;
|
||||
}
|
||||
$references = [];
|
||||
foreach (OpportunityResources::KINDS as $kind) {
|
||||
$references[$kind] = $payload[$kind] ?? [];
|
||||
$rows = [];
|
||||
if (app(OpportunityResources::class)->installed()) {
|
||||
foreach (app(OpportunityResources::class)->listing($record->id, $kind) as $row) {
|
||||
$rows[$row->id] = $row;
|
||||
}
|
||||
}
|
||||
$data[$kind] = array_map(function (string $id) use ($rows, $kind): array {
|
||||
$row = $rows[$id] ?? null;
|
||||
if ($row === null) {
|
||||
return ['id' => $id, 'availability' => 'unavailable'];
|
||||
}
|
||||
$value = [...$row->payload, 'id' => $row->id, 'version' => (int) $row->version,
|
||||
'is_deleted' => $row->deleted_at !== null, 'deleted_at' => $row->deleted_at,
|
||||
'created_by' => $row->created_by, 'created_at' => $row->created_at, 'updated_at' => $row->updated_at];
|
||||
if ($kind !== 'locations') {
|
||||
$value['original_name'] = $row->file?->file_name;
|
||||
$value['mime_type'] = $row->file?->media_type;
|
||||
$value['sha256'] = $row->file?->sha256;
|
||||
$value['scan_recorded_status'] = $row->file?->malware_scan_status;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}, $references[$kind]);
|
||||
}
|
||||
|
||||
return ['adapter_version' => self::VERSION,
|
||||
'source_schema_sha256' => hash_file('sha256', resource_path('data/investmoz-opportunity-modular.schema.json')),
|
||||
'data' => $data, 'resource_references' => $references,
|
||||
'validation_result' => ['scope' => 'recorded_submission_only', 'submission_hash' => $context['submission']['hash'] ?? null,
|
||||
'submitted_content_revision' => $context['submission']['content_revision'] ?? null,
|
||||
'current_content_revision' => $context['content_revision'] ?? 1,
|
||||
'revalidated_on_read' => false, 'external_decision' => 'not_inferred'],
|
||||
'publication' => $record->publicationPresentation(),
|
||||
'compatibility' => ['normative_response_complete' => false, 'opportunity_actor_sector_ids' => 'existing bigint, not converted to UUID',
|
||||
'opportunity_code' => 'existing reference preserved; no INV-MZ code invented',
|
||||
'states' => 'existing workflow values preserved; no implicit stage equivalence',
|
||||
'inline_modules' => 'management_draft canonical JSON with audited revisions; no parallel specialized tables',
|
||||
'metadata_gaps' => ['completion_percentage algorithm', 'inline item IDs and mutation metadata', 'resource updated_by/deleted_by recovery']]];
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,15 @@ class OpportunityCategoryFields
|
||||
}
|
||||
if (isset($p['land_rights'])) {
|
||||
$lr = $p['land_rights'];
|
||||
// §27: UNIQUE (opportunity_id, cadastral_number); absent numbers remain unknown.
|
||||
$cadastralNumbers = [];
|
||||
foreach ($lr['parcels'] ?? [] as $i => $parcel) {
|
||||
$number = $parcel['cadastral_number'] ?? null;
|
||||
if ($number !== null && $number !== '') {
|
||||
$fail(! in_array($number, $cadastralNumbers, true), "land_rights.parcels.$i.cadastral_number", 'Número cadastral repetido nesta oportunidade; recupere e reconcilie a parcela existente.');
|
||||
$cadastralNumbers[] = $number;
|
||||
}
|
||||
}
|
||||
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'])) {
|
||||
@@ -220,10 +229,20 @@ class OpportunityCategoryFields
|
||||
$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 => []
|
||||
'business_sale' => ['full_sale', 'partial_sale', 'succession'],'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');
|
||||
if (($bt['history_status'] ?? '') === 'no_completed_years') {
|
||||
$fail(app(OpportunityModularProfiles::class)->enabled() && ($p['module_profile_version'] ?? '') === OpportunityModularProfiles::HISTORY_VERSION,
|
||||
'module_profile_version', 'Ausência de histórico requer a extensão QA v2 explícita; preserve o contrato original.');
|
||||
$fail($bt['available_financial_years'] === 0 && $bt['financial_periods'] === [], 'business_transaction.financial_periods', 'Não misture projeções com exercícios financeiros realizados.');
|
||||
foreach (['history_declaration', 'history_declaration_document_id', 'projections'] as $field) {
|
||||
$fail(! empty($bt[$field]), 'business_transaction.'.$field, 'Entidade sem exercícios completos requer declaração e projeções identificadas.');
|
||||
}
|
||||
} else {
|
||||
$fail($bt['available_financial_years'] >= 1 && $bt['financial_periods'] !== [], 'business_transaction.financial_periods', 'Use os exercícios existentes ou a declaração versionada de ausência de histórico.');
|
||||
}
|
||||
$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.');
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Explicit QA-only extension of the accepted contract; existing draft/resources are the storage. */
|
||||
class OpportunityModularProfiles
|
||||
{
|
||||
public const VERSION = 'modular-qa-20261010-v1';
|
||||
|
||||
public const HISTORY_VERSION = 'business-history-qa-20261010-v2';
|
||||
|
||||
public const PROFILES = [
|
||||
'concession' => 'concession-qa-20261010-v1',
|
||||
'financing' => 'financing-qa-20261010-v1',
|
||||
'supply' => 'supply-qa-20261010-v1',
|
||||
'other' => 'other-qa-20261010-v1',
|
||||
];
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
if (! config('portal.opportunity_modular_profiles_qa_enabled') || ! app()->environment('testing')) {
|
||||
return false;
|
||||
}
|
||||
$connection = DB::connection();
|
||||
if ($connection->getDriverName() !== 'pgsql' || (string) $connection->getConfig('port') !== '55441'
|
||||
|| ! in_array($connection->getConfig('host'), ['127.0.0.1', 'localhost'], true)
|
||||
|| $connection->getConfig('url') || ! str_ends_with($connection->getDatabaseName(), '_test')) {
|
||||
return false;
|
||||
}
|
||||
$actual = $connection->selectOne('SELECT current_database() AS database, inet_server_port() AS port');
|
||||
|
||||
return $actual->database === $connection->getDatabaseName() && (int) $actual->port === 55441;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $schema
|
||||
* @return array<string,mixed> */
|
||||
public function extend(array $schema): array
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
return $schema;
|
||||
}
|
||||
$text = ['type' => 'string', 'minLength' => 1, 'maxLength' => 2000];
|
||||
$doc = ['type' => 'string', 'format' => 'uuid'];
|
||||
$positive = ['type' => 'number', 'exclusiveMinimum' => 0];
|
||||
$date = ['type' => 'string', 'format' => 'date'];
|
||||
$properties = [
|
||||
'mode' => ['enum' => ['intention_call', 'awarded_right', 'seeking_funding', 'funding_offer', 'purchase_request', 'supply_offer', 'classified_other']],
|
||||
'conditions_status' => ['enum' => ['proposed', 'binding']],
|
||||
'object' => $text, 'representation_document_id' => $doc, 'object_document_id' => $doc,
|
||||
'terms' => $text, 'source' => $text, 'scenario' => $text, 'terms_date' => $date,
|
||||
'commitment_document_id' => $doc, 'disclosure_authorization_document_id' => $doc,
|
||||
'granting_entity' => $text, 'procedure_regime' => $text, 'scope' => $text,
|
||||
'term_value' => $positive, 'term_unit' => ['enum' => ['days', 'months', 'years']],
|
||||
'remuneration_obligations' => $text, 'participation_conditions' => $text,
|
||||
'external_decision_document_id' => $doc, 'territorial_right_document_id' => $doc,
|
||||
'land_required' => ['type' => 'boolean'], 'construction_required' => ['type' => 'boolean'],
|
||||
'applicable_authorizations' => $text, 'construction_license_document_id' => $doc,
|
||||
'start_date' => $date, 'end_date' => $date,
|
||||
'instrument' => ['enum' => ['debt', 'equity', 'guarantee']],
|
||||
'use_of_funds' => $text, 'disbursement_calendar' => $text, 'repayment_periodicity' => $text,
|
||||
'financial_plan_document_id' => $doc, 'secured_evidence_document_id' => $doc,
|
||||
'offered_capacity' => $positive, 'eligibility_criteria' => $text, 'capacity_document_id' => $doc,
|
||||
'equity_percentage' => ['type' => 'number', 'exclusiveMinimum' => 0, 'maximum' => 100],
|
||||
'proposed_rights' => $text, 'quantity' => $positive, 'unit' => $text, 'destination' => $text,
|
||||
'acceptance_criteria' => $text, 'quotation_method' => $text, 'unit_price' => $positive, 'total_price' => $positive,
|
||||
'declared_taxes' => $text, 'advance_or_guarantee' => ['type' => 'boolean'],
|
||||
'subtype' => $text, 'purpose' => $text, 'recipients' => $text, 'expected_output' => $text, 'risks' => $text,
|
||||
];
|
||||
$schema['$defs']['opportunityCreate']['properties']['module_details'] = [
|
||||
'type' => 'object', 'properties' => $properties, 'additionalProperties' => false,
|
||||
];
|
||||
// Opt-in v2: a declaration/projection is not a fabricated completed financial year.
|
||||
$business = &$schema['$defs']['businessTransactionCreate'];
|
||||
$business['properties']['available_financial_years']['minimum'] = 0;
|
||||
$business['properties']['financial_periods']['minItems'] = 0;
|
||||
$business['properties'] += ['history_status' => ['enum' => ['existing_accounts', 'no_completed_years']],
|
||||
'history_declaration' => $text, 'history_declaration_document_id' => $doc,
|
||||
'projections' => ['type' => 'object', 'properties' => ['scenario' => $text, 'source' => $text, 'date' => $date, 'document_id' => $doc],
|
||||
'required' => ['scenario', 'source', 'date', 'document_id'], 'additionalProperties' => false]];
|
||||
$business['allOf'][] = ['if' => ['properties' => ['history_status' => ['const' => 'no_completed_years']], 'required' => ['history_status']],
|
||||
'else' => ['properties' => ['financial_periods' => ['minItems' => 1], 'available_financial_years' => ['minimum' => 1]]]];
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $p
|
||||
* @param array<string,mixed> $resources
|
||||
* @param array<string,string> $errors */
|
||||
public function validate(array $p, array $resources, array &$errors): void
|
||||
{
|
||||
$category = $p['opportunity_category'] ?? '';
|
||||
if (! isset(self::PROFILES[$category])) {
|
||||
if (isset($p['module_details'])) {
|
||||
$errors['canonical.module_details'] = 'Detalhe modular incompatível com esta categoria.';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $this->enabled() || ($p['module_profile_version'] ?? '') !== self::PROFILES[$category]) {
|
||||
$errors['canonical.module_profile_version'] = 'Use a versão QA explicitamente aprovada; versões anteriores permanecem rascunhos recuperáveis.';
|
||||
|
||||
return;
|
||||
}
|
||||
$m = $p['module_details'] ?? [];
|
||||
$fail = function (bool $ok, string $path, string $message) use (&$errors): void {
|
||||
if (! $ok) {
|
||||
$errors['canonical.'.$path] = $message;
|
||||
}
|
||||
};
|
||||
$require = function (array $fields) use ($m, $fail): void {
|
||||
foreach ($fields as $field) {
|
||||
$fail(isset($m[$field]) && $m[$field] !== '', 'module_details.'.$field, 'Preencha este campo aplicável antes de submeter.');
|
||||
}
|
||||
};
|
||||
$require(['mode', 'object', 'representation_document_id', 'object_document_id']);
|
||||
$modes = match ($category) {
|
||||
'concession' => ['intention_call', 'awarded_right'], 'financing' => ['seeking_funding', 'funding_offer'],
|
||||
'supply' => ['purchase_request', 'supply_offer'], default => ['classified_other'],
|
||||
};
|
||||
$fail(in_array($m['mode'] ?? '', $modes, true), 'module_details.mode', 'Selecione procura/oferta ou modalidade compatível com esta categoria.');
|
||||
// Only references included in the current opportunity's verified resource snapshot are usable.
|
||||
foreach ($m as $key => $value) {
|
||||
if (str_ends_with($key, '_document_id') && $value !== null && $value !== '') {
|
||||
$fail(isset($resources['documents'][$value]), 'module_details.'.$key, 'Associe documento ativo desta oportunidade incluído no contrato.');
|
||||
}
|
||||
}
|
||||
if (! empty($m['start_date']) && ! empty($m['end_date'])) {
|
||||
$fail($m['end_date'] >= $m['start_date'], 'module_details.end_date', 'O fim não pode anteceder o início.');
|
||||
}
|
||||
if ($category === 'concession') {
|
||||
$require(['granting_entity', 'procedure_regime', 'scope', 'term_value', 'term_unit', 'remuneration_obligations', 'participation_conditions', 'land_required', 'construction_required']);
|
||||
$fail(($m['term_value'] ?? 0) > 0, 'module_details.term_value', 'Declare duração positiva.');
|
||||
if (($m['mode'] ?? '') === 'awarded_right') {
|
||||
$require(['external_decision_document_id']);
|
||||
}
|
||||
if ($m['land_required'] ?? false) {
|
||||
$require(['applicable_authorizations']);
|
||||
if (($m['mode'] ?? '') === 'awarded_right') {
|
||||
$require(['territorial_right_document_id']);
|
||||
}
|
||||
}
|
||||
if ($m['construction_required'] ?? false) {
|
||||
$require(['applicable_authorizations']);
|
||||
}
|
||||
}
|
||||
if (in_array($category, ['financing', 'supply'], true)) {
|
||||
$require(['conditions_status', 'terms']);
|
||||
$fail(! empty($p['currency']), 'currency', 'Declare moeda sem converter valores implicitamente.');
|
||||
if (($m['conditions_status'] ?? '') === 'proposed') {
|
||||
$require(['scenario', 'source', 'terms_date']);
|
||||
$fail(empty($m['terms_date']) || $m['terms_date'] <= today()->toDateString(), 'module_details.terms_date', 'A data da fonte não pode ser futura.');
|
||||
} elseif (($m['conditions_status'] ?? '') === 'binding') {
|
||||
$require(['commitment_document_id']);
|
||||
}
|
||||
}
|
||||
if ($category === 'financing') {
|
||||
$require(['instrument', 'disbursement_calendar']);
|
||||
if (($m['mode'] ?? '') === 'seeking_funding') {
|
||||
$require(['use_of_funds', 'financial_plan_document_id']);
|
||||
foreach (['total_investment', 'investment_breakdown', 'secured_financing', 'funding_sought'] as $field) {
|
||||
$fail(array_key_exists($field, $p), $field, 'Complete o cenário do projeto para a procura de financiamento.');
|
||||
}
|
||||
$total = $p['total_investment'] ?? 0;
|
||||
$secured = array_sum(array_column($p['secured_financing'] ?? [], 'amount'));
|
||||
$sum = array_sum(array_column($p['investment_breakdown'] ?? [], 'amount'));
|
||||
$fail($total > 0 && abs($sum - $total) <= 0.010001, 'investment_breakdown', 'Rubricas devem reconciliar com o investimento total positivo.');
|
||||
$fail($secured <= $total, 'secured_financing', 'Financiamento assegurado não pode exceder o investimento.');
|
||||
$sought = $p['funding_sought'] ?? 0;
|
||||
$fail($sought > 0 && $sought <= $total - $secured, 'funding_sought', 'Pedido positivo não pode exceder a necessidade remanescente do projeto.');
|
||||
if ($secured > 0) {
|
||||
$require(['secured_evidence_document_id']);
|
||||
}
|
||||
foreach (['investment_breakdown', 'secured_financing'] as $field) {
|
||||
foreach ($p[$field] ?? [] as $i => $row) {
|
||||
$fail(($row['currency'] ?? '') === ($p['currency'] ?? ''), "$field.$i.currency", 'Moedas diferentes exigem cenário explícito; não somar automaticamente.');
|
||||
}
|
||||
}
|
||||
} elseif (($m['mode'] ?? '') === 'funding_offer') {
|
||||
$require(['offered_capacity', 'eligibility_criteria', 'capacity_document_id']);
|
||||
$fail(($m['offered_capacity'] ?? 0) > 0, 'module_details.offered_capacity', 'Declare capacidade oferecida positiva e documentada, sem aprovação automática de beneficiários.');
|
||||
foreach (['total_investment', 'investment_breakdown', 'secured_financing', 'funding_sought'] as $field) {
|
||||
$fail(! isset($p[$field]) || $p[$field] === [], $field, 'Oferta usa capacidade e critérios do financiador; preserve o cenário do projeto num percurso de procura.');
|
||||
}
|
||||
}
|
||||
if (($m['instrument'] ?? '') === 'debt') {
|
||||
$require(['term_value', 'term_unit', 'repayment_periodicity']);
|
||||
$fail(($m['term_value'] ?? 0) > 0, 'module_details.term_value', 'Dívida exige prazo positivo.');
|
||||
} elseif (($m['instrument'] ?? '') === 'equity') {
|
||||
$require(['equity_percentage', 'proposed_rights']);
|
||||
$fail(($m['equity_percentage'] ?? 0) > 0 && ($m['equity_percentage'] ?? 0) <= 100, 'module_details.equity_percentage', 'Percentagem deve estar entre zero exclusivo e 100.');
|
||||
}
|
||||
}
|
||||
if ($category === 'supply') {
|
||||
$require(['quantity', 'unit', 'destination', 'start_date', 'end_date', 'acceptance_criteria', 'advance_or_guarantee']);
|
||||
$fail(($m['quantity'] ?? 0) > 0, 'module_details.quantity', 'Declare quantidade positiva.');
|
||||
$fail(! empty($m['total_price']) || ! empty($m['quotation_method']), 'module_details.quotation_method', 'Declare preço total ou método de cotação.');
|
||||
if (isset($m['unit_price'], $m['total_price'], $m['quantity'])) {
|
||||
$fail(abs($m['quantity'] * $m['unit_price'] - $m['total_price']) <= 0.010001, 'module_details.total_price', 'Reconcilie quantidade × preço unitário, separando impostos declarados.');
|
||||
}
|
||||
if (($m['mode'] ?? '') === 'supply_offer') {
|
||||
$require(['capacity_document_id']);
|
||||
}
|
||||
}
|
||||
if ($category === 'other') {
|
||||
$require(['subtype', 'purpose', 'recipients', 'expected_output', 'risks']);
|
||||
$subtype = config('portal.opportunity_other_qa_subtypes.'.($m['subtype'] ?? ''));
|
||||
$fail(is_array($subtype) && ($subtype['approved'] ?? false) && ! empty($subtype['version'])
|
||||
&& ! empty($subtype['gates']) && ! empty($subtype['decision_reference']), 'module_details.subtype', 'Subtipo ainda não aprovado: guarde e desenvolva a ideia; a submissão aguarda classificação funcional.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $p
|
||||
* @param array<string,string> $errors */
|
||||
public function publication(array $p, array &$errors): void
|
||||
{
|
||||
$m = $p['module_details'] ?? [];
|
||||
if (($p['opportunity_category'] ?? '') === 'concession') {
|
||||
if (($m['mode'] ?? '') === 'intention_call' && empty($m['disclosure_authorization_document_id'])) {
|
||||
$errors['module_details.disclosure_authorization_document_id'] = 'Chamada/intenção exige autorização de divulgação; não representa direito atribuído.';
|
||||
}
|
||||
if (($m['mode'] ?? '') === 'awarded_right' && empty($m['external_decision_document_id'])) {
|
||||
$errors['module_details.external_decision_document_id'] = 'Direito atribuído exige o ato competente; aprovação interna não o substitui.';
|
||||
}
|
||||
if (($m['construction_required'] ?? false) && ($m['mode'] ?? '') === 'awarded_right' && empty($m['construction_license_document_id'])) {
|
||||
$errors['module_details.construction_license_document_id'] = 'Construção aplicável exige licença antes da disponibilização como direito atribuído.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function gates(Opportunity $record): array
|
||||
{
|
||||
$p = app(OpportunityManagement::class)->values($record)['canonical'] ?? [];
|
||||
$m = $p['module_details'] ?? [];
|
||||
|
||||
return match ($record->opportunity_category) {
|
||||
'concession' => ['technical_review', 'legal_territorial_review'],
|
||||
'financing' => ['technical_review', 'financial_review', 'legal_tax_due_diligence'],
|
||||
'supply' => ['technical_review', 'legal_territorial_review', ...(($m['advance_or_guarantee'] ?? false) ? ['financial_review'] : [])],
|
||||
'other' => config('portal.opportunity_other_qa_subtypes.'.($m['subtype'] ?? '').'.gates', []),
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,10 @@ class OpportunityResources
|
||||
}
|
||||
$data = Validator::make($payload, $rules)->validate();
|
||||
if ($kind === 'locations') {
|
||||
if (! empty($data['country_code']) && $data['country_code'] !== 'MZ'
|
||||
&& array_filter(array_intersect_key($data, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id'])))) {
|
||||
throw ValidationException::withMessages(['country_code' => 'Os territórios desta hierarquia pertencem a Moçambique. Escolha Moçambique ou retire a seleção territorial para guardar uma localização noutro país.']);
|
||||
}
|
||||
app(InvestmentGeography::class)->selection($data, true);
|
||||
if (! empty($data['geometry'])) {
|
||||
$geometry = $data['geometry'];
|
||||
|
||||
@@ -22,7 +22,7 @@ class PortalProfileAccess
|
||||
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'),
|
||||
app_path('Services/OpportunityCanonicalContract.php'), app_path('Services/OpportunityCanonicalResponse.php'), app_path('Services/OpportunityCategoryFields.php'), app_path('Services/OpportunityModularProfiles.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'),
|
||||
@@ -32,8 +32,14 @@ class PortalProfileAccess
|
||||
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/components/portal-grouped-menu.tsx'), resource_path('js/components/user-menu-content.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'),
|
||||
resource_path('js/pages/dashboard.tsx'), resource_path('js/pages/PortalInvestidor.tsx'),
|
||||
resource_path('js/layouts/portal-navigation.tsx'), resource_path('js/components/interactive-investment-map.tsx'),
|
||||
resource_path('js/components/simulation-form.tsx'), resource_path('js/lib/simulation-form.ts'), app_path('Services/LegacyContent.php'),
|
||||
resource_path('js/components/ui/sidebar.tsx'), resource_path('js/components/simulation-financial-tools.tsx'), resource_path('js/components/simulation-cashflow-planner.tsx'), resource_path('js/pages/investor-path/show.tsx'), resource_path('views/app.blade.php'),
|
||||
resource_path('js/pages/management/opportunity-resources.tsx'),
|
||||
app_path('Services/PortalProfileInstallation.php'), app_path('Http/Controllers/PortalProfileInstallationController.php'), config_path('portal_profiles.php'), resource_path('js/pages/profiles/installation.tsx')];
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,30 @@ class VirtualDesk
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function requestToolPrefill(User $actor, \stdClass $request): ?array
|
||||
{
|
||||
$case = $this->case($actor, $request->case_id);
|
||||
$payload = $this->decode($request->payload);
|
||||
if (! config('investor-path.enabled') || (int) $case->user_id !== $actor->id || ! $case->simulation_id || $case->closed_at
|
||||
|| ! in_array($request->status, ['draft', 'returned'], true) || trim((string) ($payload['description'] ?? '')) !== ''
|
||||
|| ($request->status === 'returned' && ! in_array('description', json_decode($request->editable_fields ?? '[]', true), true))
|
||||
|| ! isset($this->definition($request)['fields']['description'])) {
|
||||
return null;
|
||||
}
|
||||
$draftRequest = Request::create('/');
|
||||
$draftRequest->setUserResolver(fn () => $actor);
|
||||
$draft = app(SimulationFormDrafts::class)->find($case->simulation_id, $draftRequest);
|
||||
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
$description = (string) ($economic['project_summary'] ?? '');
|
||||
if (trim($description) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['description' => $description, 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
|
||||
'snapshot_sha256' => $draft->snapshot_sha256];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function definition(\stdClass $request): array
|
||||
{
|
||||
@@ -277,7 +301,9 @@ class VirtualDesk
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, string $id, array $input, bool $submit = false): \stdClass
|
||||
{
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'payload' => 'required|array', 'consent' => $submit ? 'accepted' : 'nullable|boolean', 'reason' => 'nullable|string|max:3000'])->validate();
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'payload' => 'required|array', 'consent' => $submit ? 'accepted' : 'nullable|boolean', 'reason' => 'nullable|string|max:3000',
|
||||
'tool_prefill' => 'nullable|array:draft_id,draft_version,snapshot_sha256', 'tool_prefill.draft_id' => 'required_with:tool_prefill|uuid',
|
||||
'tool_prefill.draft_version' => 'required_with:tool_prefill|integer|min:1', 'tool_prefill.snapshot_sha256' => 'required_with:tool_prefill|string|size:64'])->validate();
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data, $submit) {
|
||||
$request = $this->request($actor, $id, true, true);
|
||||
@@ -285,6 +311,16 @@ class VirtualDesk
|
||||
return $request;
|
||||
}
|
||||
abort_unless(in_array($request->status, ['draft', 'returned'], true) && $request->version === (int) $data['version'], 409, 'Pedido alterado ou já submetido.');
|
||||
$prefill = null;
|
||||
if (! empty($data['tool_prefill'])) {
|
||||
$prefill = $this->requestToolPrefill($actor, $request);
|
||||
$source = $data['tool_prefill'];
|
||||
if ($submit || ! $prefill || $source['draft_id'] !== $prefill['draft_id'] || (int) $source['draft_version'] !== $prefill['draft_version']
|
||||
|| ! hash_equals($prefill['snapshot_sha256'], $source['snapshot_sha256'])
|
||||
|| ($data['payload']['description'] ?? null) !== $prefill['description']) {
|
||||
throw ValidationException::withMessages(['payload.description' => 'Recupere a descrição atual deste dossier. A reutilização só preenche um campo vazio num rascunho e não submete o pedido.']);
|
||||
}
|
||||
}
|
||||
$payload = $this->validateFields($data['payload'], $this->definition($request), $submit);
|
||||
if ($request->status === 'returned') {
|
||||
$editable = json_decode($request->editable_fields ?? '[]', true);
|
||||
@@ -310,6 +346,10 @@ class VirtualDesk
|
||||
DB::table('desk_requests')->where('id', $id)->update($updates);
|
||||
$request = DB::table('desk_requests')->where('id', $id)->firstOrFail();
|
||||
$this->event($case, $actor, $submit ? 'submitted' : 'draft_saved', ['reason' => $data['reason'] ?? '', 'findings' => $findings], $request);
|
||||
if ($prefill) {
|
||||
$this->event($case, $actor, 'request_prefill_reused', ['draft_id' => $prefill['draft_id'], 'draft_version' => $prefill['draft_version'],
|
||||
'snapshot_sha256' => $prefill['snapshot_sha256'], 'fields' => ['description'], 'scope' => 'draft_preparation'], $request);
|
||||
}
|
||||
|
||||
return $request;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
// Route/identity mapping over the existing library. Bodies and originals remain in documents.
|
||||
return [
|
||||
'groups' => [
|
||||
'agronegocio' => ['root' => 'agronegocio', 'label' => 'Agronegócio', 'heading' => 'Investir no agronegócio'],
|
||||
'energia-mineracao' => ['root' => 'energia-mineracao', 'label' => 'Energia e Mineração', 'heading' => 'Explorar Energia e Mineração'],
|
||||
'turismo' => ['root' => 'turismo', 'label' => 'Turismo', 'heading' => 'Explorar Turismo'],
|
||||
'infraestruturas' => ['root' => 'infraestruturas', 'label' => 'Infraestruturas', 'heading' => 'Explorar Infraestruturas'],
|
||||
'mercado-financeiro' => ['root' => 'mercado-financeiro', 'label' => 'Mercado Financeiro', 'heading' => 'Explorar Mercado Financeiro'],
|
||||
],
|
||||
'articles' => [
|
||||
'agronegocio' => ['external_id' => 'legacy-tempo-post-171985-20260420', 'source_url' => 'https://investemoz.tempo.co.mz/agronegocio', 'group' => 'agronegocio', 'sector_slug' => 'agronegocio', 'publishable' => true],
|
||||
'energia-mineracao' => ['external_id' => 'legacy-tempo-post-171981-20260430', 'source_url' => 'https://investemoz.tempo.co.mz/energia-mineracao', 'group' => 'energia-mineracao', 'sector_slug' => 'energia', 'publishable' => true],
|
||||
'energia' => ['external_id' => 'legacy-tempo-post-172679-20260430', 'source_url' => 'https://investemoz.tempo.co.mz/energia/', 'group' => 'energia-mineracao', 'sector_slug' => 'energia-especifica', 'publishable' => true],
|
||||
'mineracao' => ['external_id' => 'legacy-tempo-post-172674-20260430', 'source_url' => 'https://investemoz.tempo.co.mz/mineracao', 'group' => 'energia-mineracao', 'sector_slug' => 'mineracao', 'publishable' => true],
|
||||
'turismo' => ['external_id' => 'legacy-tempo-post-171990-20260616', 'source_url' => 'https://investemoz.tempo.co.mz/turismo', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'provincia-da-zambezia-onde-os-corredores-de-mocambique-convergem-com-a-natureza-a-cultura-e-o-turismo-sustentavel' => ['external_id' => 'legacy-tempo-post-172263-20260414', 'source_url' => 'https://investemoz.tempo.co.mz/provincia-da-zambezia-onde-os-corredores-de-mocambique-convergem-com-a-natureza-a-cultura-e-o-turismo-sustentavel', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'provincia-de-sofala-onde-a-natureza-a-historia-e-o-futuro-do-turismo-convergem' => ['external_id' => 'legacy-tempo-post-172256-20260414', 'source_url' => 'https://investemoz.tempo.co.mz/provincia-de-sofala-onde-a-natureza-a-historia-e-o-futuro-do-turismo-convergem/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'provincia-de-tete-onde-o-rio-zambeze-a-cultura-viva-e-a-conservacao-comunitaria-criam-o-futuro-do-turismo' => ['external_id' => 'legacy-tempo-post-172261-20260414', 'source_url' => 'https://investemoz.tempo.co.mz/provincia-de-tete-onde-o-rio-zambeze-a-cultura-viva-e-a-conservacao-comunitaria-criam-o-futuro-do-turismo/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'provincia-de-manica-onde-o-turismo-de-montanha-natureza-e-cultura-encontra-o-proximo-ciclo-de-investimento' => ['external_id' => 'legacy-tempo-post-172258-20260417', 'source_url' => 'https://investemoz.tempo.co.mz/provincia-de-manica-onde-o-turismo-de-montanha-natureza-e-cultura-encontra-o-proximo-ciclo-de-investimento/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'provincia-de-inhambane-um-destino-estrategico-para-investimento-turistico' => ['external_id' => 'legacy-tempo-post-172253-20260414', 'source_url' => 'https://investemoz.tempo.co.mz/provincia-de-inhambane-um-destino-estrategico-para-investimento-turistico/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'maputo-cidade-plataforma-estrategica-de-turismo-negocios-e-investimento-sustentavel' => ['external_id' => 'legacy-tempo-post-172240-20260414', 'source_url' => 'https://investemoz.tempo.co.mz/maputo-cidade-plataforma-estrategica-de-turismo-negocios-e-investimento-sustentavel/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'provincia-de-maputo-turismo' => ['external_id' => 'legacy-tempo-post-172237-20260417', 'source_url' => 'https://investemoz.tempo.co.mz/provincia-de-maputo-turismo/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'provincia-de-gaza-oportunidades-mais-solidas-e-estrategicas-de-investimento-no-turismo-no-sul-de-mocambique' => ['external_id' => 'legacy-tempo-post-172233-20260417', 'source_url' => 'https://investemoz.tempo.co.mz/provincia-de-gaza-oportunidades-mais-solidas-e-estrategicas-de-investimento-no-turismo-no-sul-de-mocambique/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'provincia-de-nampula-perfil-de-investimento-no-setor-do-turismo' => ['external_id' => 'legacy-tempo-post-172230-20260414', 'source_url' => 'https://investemoz.tempo.co.mz/provincia-de-nampula-perfil-de-investimento-no-setor-do-turismo/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'cabo-delgado-pemba-a-capital-do-turismo-costeiro-do-norte-de-mocambique' => ['external_id' => 'legacy-tempo-post-172223-20260414', 'source_url' => 'https://investemoz.tempo.co.mz/cabo-delgado-pemba-a-capital-do-turismo-costeiro-do-norte-de-mocambique/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'niassa-o-ultimo-grande-destino-virgem-do-turismo-africano' => ['external_id' => 'legacy-tempo-post-172219-20260414', 'source_url' => 'https://investemoz.tempo.co.mz/niassa-o-ultimo-grande-destino-virgem-do-turismo-africano/', 'group' => 'turismo', 'sector_slug' => 'turismo', 'publishable' => true],
|
||||
'infraestruturas' => ['external_id' => 'legacy-tempo-post-171992-20260430', 'source_url' => 'https://investemoz.tempo.co.mz/infraestruturas', 'group' => 'infraestruturas', 'sector_slug' => 'infraestruturas', 'publishable' => true],
|
||||
'mercado-financeiro' => ['external_id' => 'legacy-tempo-post-171994-20260417', 'source_url' => 'https://investemoz.tempo.co.mz/mercado-financeiro', 'group' => 'mercado-financeiro', 'sector_slug' => 'mercado-financeiro', 'publishable' => false],
|
||||
],
|
||||
];
|
||||
@@ -1,10 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'legacy_content_qa_enabled' => env('PORTAL_LEGACY_CONTENT_QA_ENABLED', false),
|
||||
'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),
|
||||
// Accepted first QA version, additionally guarded by the actual isolated database identity.
|
||||
'opportunity_modular_profiles_qa_enabled' => env('PORTAL_OPPORTUNITY_MODULAR_PROFILES_QA_ENABLED', false),
|
||||
'opportunity_other_qa_subtypes' => [],
|
||||
'operator_name' => env('PORTAL_OPERATOR_NAME'),
|
||||
'support' => [
|
||||
'email' => env('PORTAL_SUPPORT_EMAIL'),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,9 +1,175 @@
|
||||
<!-- qa-territory-resource-c86-c87-20261010:begin -->
|
||||
## Lote territorial C86/C87 — 2026-10-10T20:48:06.634373+02:00
|
||||
|
||||
**C37 encerrado somente para autenticação QA18, por confirmação manual expressa. Não foi repetido.** Aceitação ligada aos recibos de logout/recuperação em `c37-manual-close-20261010/acceptance.json`. A/B, auditoria integral e ativação continuam independentes.
|
||||
|
||||
| Requisito/caso | Correção e prova QA | Limite do fecho |
|
||||
| --- | --- | --- |
|
||||
| OP02; GEO01/02; C86 | API antes aceitava PT com província MZ por200; agora422 junto a country_code, preservando versão/payload/auditoria. UI limpa somente a seleção administrativa no preenchimento ao mudar país; estrangeiro incompleto pode guardar e recuperar. Conferência deixou de exigir província/distrito MZ a Portugal. | Aplicado a recursos de localização; não encerra origem, filtros ou estatísticas das solicitações. Registos anteriores não foram migrados/corrigidos silenciosamente. |
|
||||
| GEO01/02/03 | QA21 oportunidade51: MZ→CaboDelgado→ANCUABE→guardar→nova sessão→campos e mapa recuperados→F5. Distrito `5b03104f-9c47-5542-8de3-e81b8d5fdd55`; dois limites disponíveis e dois polígonos renderizados. Segundo recurso PT recuperado sem IDs MZ. Troca de pai limpa descendentes. | Demais consumidores/projetos/clusters/corredores e níveis inferiores não encerrados. Tiles externos bloqueados no Chromium; mapa base não demonstrado. Não houve nova importação cartográfica ou inferência de parcelas/direitos. |
|
||||
| OP02; autorização | QA23 organização alheia: consulta403 e escrita403. PostgreSQL QA READ ONLY confirmou os2recursos v1 e o parentesco geográfico; ligações territoriais aceites permaneceram 0. | Outros perfis, documentos/derivadas e lifecycle/workflow conservam provas/pendências próprias. |
|
||||
| GEO03; C87 | Lista repetia “limites oficiais por verificar” mesmo para administrativos do INE. Texto agora distingue edição administrativa de parcela; fonte e edição2019-06-07 preservadas. Popup já distinguia o propósito, lista foi alinhada. Chromium390px e imagem inspecionada. | A aceitação oficial não foi reaberta. A edição não afirma atualidade jurídica ou disponibilidade de investimento. Outras camadas e geometria de parcela têm critérios próprios. |
|
||||
|
||||
Provas sanitizadas: `territory-resource-chromium-20261010.json`, `territory-resource-persistence-qa.json`, `territory-resource-map-shot-20261010.json` e regressões `location-country-*qa.json`. Suite de recursos5/71 passou antes do ajuste de conferência; regressão específica final1/20 passou depois. TypeScript passou após o seletor; compilação QA finalexit0. Contagens de testes não substituem os percursos acima. Falhas do harness (versão Inertia omitida, seletor por atributo incorreto e caixa dos nomes) foram preservadas como tentativas; não são falhas do portal. Nome oficial ANCUABE não foi renomeado.
|
||||
|
||||
Mesma matriz:3393requisitos/3393linhas/6845ocorrências; quatro requisitos recebem subcritérios, sem alteração de estados globais. Países/origem/estatísticas restantes,173 correspondências anteriores com Mandie/Guro ambíguo, documentos, Response/§27, workflow/restauração e decisões modulares/SLA permanecem abertos. Não iniciar novas jornadas educativas.
|
||||
|
||||
**Código preparado e demonstração QA, sem disponibilidade ativa.** Zero migrações/instalações novas e zero escritas ativas. Manifesto ativo SHA256 `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` intacto. O backup/restauro QA18:19 liga-se ao fingerprint anterior; não foi apresentado como prova da revisão nova. Para futura candidata: renovar verificações/backup/restauro associados à revisão, testar compatibilidade/reversão no clone do ativo e preservar **030000→040000→09/190000**, sem repetir instalações QA. O backup ativo READ ONLY17:48 permanece histórico; nenhum corte autorizado.
|
||||
|
||||
<!-- qa-territory-resource-c86-c87-20261010:end -->
|
||||
|
||||
<!-- c37-manual-close-20261010:begin -->
|
||||
## C37 encerrado — autenticação QA18 — 2026-10-10T20:26:29.902032+02:00
|
||||
|
||||
**Confirmação manual expressa do responsável registada. C37 está encerrado exclusivamente no âmbito da autenticação QA18.** Login → MFA → redirecionamento → sessão autenticada → atualização → logout → nova sessão/recuperação estão ligados às provas anteriores, sem repetir ensaios nem alterar conta, chave ou MFA. O logout manual de 20:17:15 foi confirmado pelo responsável e pelo rastreio: POST302→/, autenticação e estado persistido vazios.
|
||||
|
||||
Aceitação e hashes dos recibos: `c37-manual-close-20261010/acceptance.json`. Referências: `c37-qa18-chromium-20261010.json`, `logout-finalize-20261010/manual-qa18-logout.json`, `portal-logout-chromium-20261010.json` e recuperação do dossier em `investor-ab-chromium-20261010.json`. A prova QA21 do botão é complementar; a conta18 tem a sua própria confirmação. Horário local UTC+02; datas técnicas dos recibos conservadas em UTC.
|
||||
|
||||
O fecho **não encerra A/B, auditoria integral, móvel/acessibilidade integral, outros perfis ou ativação ativa**. Os cinco requisitos relacionados continuam parciais pelos seus outros critérios; contagens preservadas: 3393 requisitos/3393 linhas/6845 ocorrências. Menções históricas abaixo a C37/autenticação18 pendente são superadas somente por este fecho. C85 permanece corrigido em QA. Próximo trabalho independente: gravação/recuperação territorial e consistência país–hierarquia por consumidor, países/origem/estatísticas, documentos e workflow, preservando decisões próprias de perfis modulares/SLA.
|
||||
|
||||
<!-- c37-manual-close-20261010:end -->
|
||||
|
||||
<!-- qa-logout-results-20261010:begin -->
|
||||
## Logout disponível e confirmação manual — 2026-10-10T18:20:44.687863+00:00
|
||||
|
||||
**C85 corrigido em QA; nenhuma alteração no ativo.** A ação existia no menu de utilizador do dashboard, mas não na navegação do investidor. O cabeçalho comum mostra agora a conta atual e **Terminar sessão**, sem obrigar a regressar ao dashboard. Reutiliza POST `/logout` do Fortify e limpa o cache Inertia; chaves, MFA, perfis e dados guardados foram preservados.
|
||||
|
||||
O responsável confirmou o botão e o logout. Rastreio QA18 às **2026-10-10T18:17:15+00:00** (20:17:15 em Moçambique): POST logout302→/, conta18→sem autenticação e estado persistido vazio. C37 recebe apenas esse critério; F5 explícito e novo login/MFA QA18 após a saída continuam pendentes. O GET/login anterior com conta autenticada não era um novo login. A declaração geral de testes efetuados não substitui evidências dos restantes critérios.
|
||||
|
||||
Chromium QA21, contextos isolados: ação visível em investimentos, dossier, artigo e simulador; logout direto do dossier em desktop e 320px; API privada401 e página privada→login; nova sessão com MFA normal recuperou case/results/steps/tasks com hash igual e F5 manteve acesso. Seis verificações de cabeçalho em 320/390/1440px sem violações automáticas ou overflow; foco por teclado demonstrado. Não consumiu códigos QA18. Não encerra A/B, leitor de ecrã ou acessibilidade integral.
|
||||
|
||||
Provas sanitizadas: `portal-logout-chromium-20261010.json` e `logout-finalize-20261010/manual-qa18-logout.json`. Cinco requisitos receberam subcritérios; permanecem **3393 requisitos/3393 linhas/6845 ocorrências**, com estados globais inalterados.
|
||||
|
||||
Regressão de instalador/publicação: 9 testes/93 asserções e TypeScript passaram. Backup/restauro QA renovado **2026-10-10T18:19:43+00:00**, 295 tabelas/103 ficheiros, em `profile_restore_20261010_181928_test:55441`, associado ao novo fingerprint que inclui os dois menus. O backup ativo READ ONLY de 17:48:52 UTC permanece um retrato anterior; candidata global, compatibilidade/reversão no clone do ativo e ativação continuam pendentes. Preservar **030000 → 040000 → 09/190000**, sem repetir instalações QA nem copiar contas/dossiers de teste.
|
||||
|
||||
As afirmações históricas abaixo sobre logout manual ainda por testar são superadas somente por esta confirmação; nenhuma aceitação adicional inferida.
|
||||
|
||||
<!-- qa-logout-results-20261010:end -->
|
||||
|
||||
<!-- qa-visual-results-20261010:begin -->
|
||||
## Ensaios visuais e pendências atuais — 2026-10-10T17:57:54.604301+00:00
|
||||
|
||||
Trabalho exclusivo na QA da porta 18097, base `desk_restore_20261009_174726_test:55441`. Nenhuma alteração na base ativa. A mesma matriz mantém **3393 requisitos / 3393 linhas / 6845 ocorrências de origem**: 3235 parciais, 145 não implementados, 5 dependentes de decisão, 1 implementado mas não ativado e 7 concluídos e demonstrados. Os estados globais não mudaram; foram atualizados subcritérios com provas próprias. A ausência de requisitos globalmente classificados “com falha” não significa ausência de casos abertos.
|
||||
|
||||
| Requisito | Demonstrado em QA | Critérios ainda pendentes |
|
||||
| --- | --- | --- |
|
||||
| C37; UI01; PROFILE24–25 | MFA manual QA18 aceite às 17:40:21 e 17:46:42 UTC; autenticação persistida e dashboard 200. Responsável confirmou acesso. O 403 inicial era do relatório administrativo reservado. Mensagem e retorno ao painel corrigidos; Chromium QA21 provou recusa 403, retorno e F5 sem perder sessão. | F5, logout e novo login no navegador habitual QA18. Não declarar o loop anterior resolvido por inferência: o rastreio não demonstra um ciclo persistente. Uma atualização Inertia 409 isolada não é perda de autenticação. |
|
||||
| A/B; GUIDE01; SIM02; DESK01–02; CRM01 | Chromium desktop QA18: criação → gravação → nova sessão → recuperação → aplicabilidade → ferramenta → resultado no dossier → preparação no Balcão → acompanhamento interno → nova recuperação. Cinco fases e 19 etapas preservadas. A dispensa S10; B aguarda informação externa em S10 enquanto S02 avança. Resultado: equilíbrio de 20 unidades e défice A=0 / B=70.000 MZN. QA23 recebe 403/404; gestor não vê resultados financeiros privados. | Balcão demonstrado em preparação DRAFT, sem submissão ou decisão externa. Restantes critérios especializados, fiscalidade, documentos e consumidores não encerrados. Repetição final A/B móvel ainda necessária. |
|
||||
| DASH01; PROFILE05/32 | QA21, a partir do dashboard: “Minhas oportunidades” e “Nova oportunidade” → rascunho incompleto #50 → guardar → nova sessão → recuperar → editar. Promotor solicita arquivo; Publicador QA25 arquiva com fundamento; estado recuperado. | Restauração por perfil ausente no workflow. Definir e implementar ator/regra coerentes sem conceder arquivo direto ao Promotor por inferência. Workflow e contratos especializados ainda exigem demonstrações próprias. |
|
||||
| COUNTRY01–04/06/07 | Residência PT guardada no perfil e recuperada após F5. Negócio e fundiário DRAFT: país PT / origem FR guardados e recuperados. Balcão A/B: PT / ZA recuperados noutra sessão. Quatro formulários públicos apresentam 249 países cada. Gestão: filtros ZA/unknown, quatro manifestações sem origem e zero pedidos submetidos ZA, com rascunhos excluídos. | Nos formulários públicos, a presença de opções não prova submissão ou recuperação. Concluir cada gravação e estatística positiva de origem conhecida no UI. Nos rascunhos de negócio/fundiário, F5 não substitui nova sessão. |
|
||||
| GEO01–03 | Oportunidade própria QA21 #51 e criação de projeto, cluster e corredor: Cabo Delgado registado → API com uma geometria acessível → um polígono SVG visível em cada consumidor. | Seleções desse ensaio não guardadas. Recuperar relações por consumidor e testar descendentes, restantes mapas e perfis. Tiles externos bloqueados no ensaio, sem confirmação da base externa. 408 associações anteriores e 173 pendentes preservadas; Mandie/Guro ambíguo. |
|
||||
| LEG02/03/06/07; C77–C83 | Corrigidos landmarks, ARIA, hierarquia de títulos, reflow, foco das tabelas e idioma HTML pt-MZ em QA. Quinze execuções dirigidas de axe, em 320/390/1440px, sem violações automáticas ou overflow. Três páginas com skiplink primeiro e tabelas financeiras operadas por teclado. | Cobertura integral, leitor de ecrã, dispositivos reais, mais perfis e editor C74. O dossier QA21 verifica o componente móvel corrigido, sem substituir a repetição final A/B. |
|
||||
| DOC01; C84 | Retirada mensagem global falsa de ClamD pendente; scan e motor apresentados por ficheiro/versão. Os 22 scans reais anteriores permanecem demonstrados. | PDFs, exportações, partilhas, derivadas e operações restantes exigem provas próprias. Um original limpo não comprova a derivada. |
|
||||
| OPS01 | Backup QA 2026-10-10T17:48:11+00:00: 295 tabelas e 103 ficheiros restaurados. Ativo READ ONLY, backup 2026-10-10T17:48:52+00:00: 288 tabelas restauradas numa base QA distinta e cinco ficheiros conferidos no arquivo. Instalador/publicação: 9 testes / 93 asserções, além de TypeScript. | Aplicação, compatibilidade e reversão da futura revisão candidata no clone fresco do ativo. Revisão candidata global e ativação continuam por fixar. Contagens de testes não substituem critérios funcionais. |
|
||||
|
||||
**C80 corrigido:** a resposta tardia do GET do rascunho podia substituir preenchimento já iniciado. Edição e avanço ficam bloqueados até a recuperação terminar; o retorno exige o snapshot completo guardado. Chromium com resposta deliberadamente atrasada comprovou bloqueio, recuperação e libertação. A/B recuperaram os inputs corretos noutra sessão, preservando versões anteriores.
|
||||
|
||||
**Recusa manual do mapa às 17:52:33 UTC:** o pedido à oportunidade 51 foi feito com QA18, não QA21; recebeu 403 com sessão preservada. É uma prova de autorização, não de apresentação do mapa. Usar QA21 (`profiles-v1-promoter@example.invalid`) para esse consumidor; o Chromium anterior QA21 recebeu 200. Nenhuma permissão alargada para contornar a recusa.
|
||||
|
||||
**Provas privadas sanitizadas:** `investor-ab-chromium-20261010.json`, `promoter-dashboard-chromium-20261010.json`, `consumers-mobile-accessibility-chromium-20261010.json`, `visual-followup-chromium-20261010.json`, `qa-report-access-chromium-20261010.json`, `c37-qa18-post-mfa-destination-20261010.json`, `visual-persistence-qa.json`, `profile-installation-backup-qa.json` e `virtual-desk/backup-proof.json`. A tentativa antiga de QA21 na oportunidade 30 recebeu 403 e não contou como mapa; foi substituída pelo consumidor próprio 51. Tentativas e falhas de harness preservadas; sem códigos, segredos ou cookies nas evidências entregáveis.
|
||||
|
||||
Mercado Financeiro permanece privado, sem corpo inventado; rotas públicas 404. Energia e Mineração mantêm artigos distintos e Turismo não criou associações geográficas automáticas. Fontes oficiais aceites não foram reabertas. Próximo lote: C37 manual, restauração por perfil, submissão/estatística por formulário, gravação/recuperação territorial, documentos, Response/§27 e workflow. Perfis modulares/SLA continuam decisões separadas; não iniciar novas jornadas educativas.
|
||||
|
||||
As versões históricas abaixo ficam preservadas. Afirmações antigas sobre falta de navegador, A/B não testados ou ClamD global pendente são superadas apenas pelos subcritérios acima. Código preparado, QA demonstrada e disponibilidade ativa são estados distintos.
|
||||
<!-- qa-visual-results-20261010:end -->
|
||||
|
||||
# InvesteMoz — backlog do lote 3
|
||||
|
||||
<!-- qa-c71-sectors-result-20261010:begin -->
|
||||
## Prioridades atuais após C71 — 2026-10-10T16:23:25.695297+00:00
|
||||
|
||||
**QA recuperada e lote setorial demonstrado; base ativa fora do âmbito.** C71 técnico corrigido, com backup/restauro final de 295 tabelas/103 ficheiros. AUD-OPS-01 volta a parcial, porque aceitação global e ativação continuam pendentes. Biblioteca: 22 novos originais importados/scaneados;21 divulgados por ação separada e 1 artigo vazio privado. Chromium desktop com QA21:4 dossiers criados, retorno, logout/novaMFA/F5/replay; QA23 negado 403. Ver relatório executivo e provas individuais.
|
||||
|
||||
| Prioridade / requisito | Falta e próxima ação | Critério concreto |
|
||||
| --- | --- | --- |
|
||||
| P0 / AUD-UI-01,C37/QA18 | Incorporar tentativa manual sanitizada; primeira rejeição atual foi de palavra-passe, antes do MFA. 2FAS continua compatível; sem nova aplicação ou reset de segredo. | MFA→`/investimentos`→F5→logout→novo login na sessão problemática; não usar QA21 como substituto. |
|
||||
| P1 / AUD-LEG-01/06/07 |32 das 50 versões ainda não importadas, sitemap/paginação/acervo e C68 (imagem 404). Reconciliar próximo lote por identidade/hash/citação. | Prévia, armazenamento privado, scan real, replay/recuperação e divulgação separada; não inventar corpo de Mercado Financeiro. |
|
||||
| P1 / AUD-LEG-02/03/07 | Móvel/acessibilidade/mais perfis e editor administrativo C74 sem ensaio visual próprio; restantes ligações/consumidores. | Prova de cada ação visível e permissão; preservar confirmação manual limitada do piloto e diferenças entre versões. |
|
||||
| P1 / percurso,I0–I3,A/B | Cenários completos com ferramenta→resultado→Balcão→acompanhamento, e ações do dashboard/workflow continuam independentes. | Cinco fases/19 etapas, nova sessão, preparação paralela, isolamento/permissões, retorno ao dossier. |
|
||||
| P1 / países e cartografia | Completar países/origem/filtros/estatísticas por formulário e 173 correspondências territoriais; Mandie/Guro permanece ambíguo. | UI→API→gravação→recuperação→resultado por consumidor; território≠geometria≠mapa. |
|
||||
| P1 / documentos,Response/§27 | Completar condições semânticas, PDFs/exportações/derivadas por consumidor. | Scan próprio, originais/hash preservados, permissões e resultados recuperáveis. |
|
||||
| P1/P2 / decisões e ativação | Perfis modulares/SLA, calendário/nomeações reais e candidata permanecem separados. Preparar ação autenticada de importação em lote preservando identidade:CLI `--actor` sóQA. | Decisão concreta por regra; backup/restauro da base ativa da candidata e sequência 030000 → 040000 → 09/190000 sem repetições. |
|
||||
|
||||
**Mesma matriz:**3393 requisitos/linhas, 6845 ocorrências; 3235 parciais, 145 não implementados, 5 dependentes de decisão, 1 implementado mas não ativado, 7 concluídos e demonstrados, 0 com falha global. O backlogCSV agora projeta todos os 3393 IDs;39 estavam omitidos. Casos/aceitações dentro dos parciais continuam abertos. As secções abaixo são histórico anterior à recuperação/importação, não o estado atual.
|
||||
<!-- qa-c71-sectors-result-20261010:end -->
|
||||
|
||||
<!-- qa18-credential-diagnosis-20261010:begin -->
|
||||
**Atualização15:41UTC:** três tentativas manuais registadas com emailQA18 correto e palavra-passe recebida sem correspondência; retorno/login antes do MFA. Não houve desencriptação nem sessão autenticada nesses pedidos. Credencial privada reconfirmada por leitura e auxiliar local sem JSON aberto para cópia exata. Próxima ação: retomar a tentativa com esse valor; C37/MFA completo continuam pendentes. Prova privada `investor-credential-manual-rejection-20261010.json`, detalhes no C73.
|
||||
|
||||
## Acesso QA18 — confirmação das credenciais, C73/C37 pendente
|
||||
|
||||
Palavra-passe privada e MFA18 reconfirmados por leitura; navegador isolado no18097 chegou ao desafio da conta18 (login302 → MFA200), sem submeter código. Rejeição manual não reproduzida; manter causa/aceitação pendentes. Rastreio sanitizado recuperado após saturação5MiB, preservando histórico; próximos POST/login distinguem correspondência por booleanos sem guardar valores. Próxima ação: incorporar a tentativa manual e só fechar C37 após MFA → `/investimentos` → atualização → logout → novo login. Contas/chave/base ativa preservadas; C71 continua aberto. Ver C73 em `casos-reproducao.md` e recibos privados `investor-credential-*`.
|
||||
<!-- qa18-credential-diagnosis-20261010:end -->
|
||||
|
||||
<!-- legacy-manual-sectors-20261010:begin -->
|
||||
## Confirmação manual e reconciliação setorial — 10/10/2026
|
||||
|
||||
Responsável confirmou leitura pública → autenticação → criar/escolher dossier → guardar referência → nova sessão → recuperar dossier e abrir artigo. AUD-LEG-03/04/06/07 receberam apenas os subcritérios correspondentes; estados e contagens da matriz inalterados. Conta/perfil/dispositivo não indicados. Móvel, acessibilidade, outros perfis, cobertura integral, C37/QA18 e A/B continuam pendentes.
|
||||
|
||||
Quatro setores reconciliados: Energia & Mineração (índice + dois artigos), Turismo (artigo +11 referências territoriais), Infraestruturas (título corrigido em código) e Mercado Financeiro (corpo vazio preservado). **17 artigos +5 imagens** preparados; prévia CLI em transação READ ONLY:22 candidatos,0 duplicados,0 conflitos; nenhuma aplicação/scan/publicação nova. C72 corrigido na transformação dos originais, sem prova visual dos novos setores. C71 continua P0: recuperar integridade/backup/restauro antes de aplicação QA. Biblioteca/entidades/componentes existentes reutilizados; rotas dos restantes setores ainda por parametrizar. Ver `legado-piloto-qa.md` e inventários existentes.
|
||||
|
||||
|
||||
| Prioridade | Próxima ação | Aceitação exigida |
|
||||
| --- | --- | --- |
|
||||
| P0 / C71 | Recuperar cluster QA preservando dados e renovar backup/restauro. | Conteúdos/schema/sequências/ficheiros conferidos; guarda, chave e percursos recuperados. |
|
||||
| P1 / LEG01/05/06/07 | Renovar prévia dos22 exemplares, importar como rascunhos privados após C71, scan e parametrizar leitura/contexto por identidade. | Sem duplicações; fontes/URLs preservadas; publicação separada; origem/referência recuperadas por setor. |
|
||||
| P1 / LEG02/07 / C72 | Verificar o título preservado de Infraestruturas na página real após importação. | Conteúdo integral legível; original/hash imutáveis; markup ativo removido. Verificação de transformação já passada. |
|
||||
| P1 / Mercado Financeiro | Manter corpo vazio identificado e procurar no acervo um exemplar/conteúdo real. | Sem conteúdo inventado ou Fintech usado como substituto silencioso. |
|
||||
| P1 / aceitação pendente | Ensaios móveis, acessibilidade e outros perfis, C37/QA18, A/B e restante acervo. | Evidência própria por percurso/perfil; relato manual do piloto não os encerra. |
|
||||
<!-- legacy-manual-sectors-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-browser-20261010:begin -->
|
||||
## Próximo lote após a demonstração do piloto
|
||||
|
||||
| Requisito | Demonstrado | Falta / próxima ação |
|
||||
| --- | --- | --- |
|
||||
| AUD-OPS-01 / C71 — P0 | Diagnóstico e preservação READ ONLY; backup anterior intacto, readiness atual inválida. | Recuperar cluster QA e renovar backup/restauro antes de novas escritas; não fixar candidata. |
|
||||
| AUD-LEG-04 | Consulta pública e login apenas para dossier; MFA da conta QA21/nova sessão/F5; anónimo/QA23 sem contexto; QA23 negado 403. Concluído exclusivamente QA. | Ativação separada; não assumir C37/QA18 resolvido. |
|
||||
| AUD-LEG-02 | Desktop/móvel/fotografia/fontes/paleta, pesquisa, teclado; C70 título QA corrigido. | Verificação sistemática de controlos/tecnologias assistivas e adaptação dos outros setores. |
|
||||
| AUD-LEG-03/05 | Artigo→consumidores→dossier/fonte/retorno; referência/evento únicos; sem mistura. | Fundamentar correspondências dos seis corredores; mapas gerais não equivalem a geometrias relacionadas com o artigo. |
|
||||
| AUD-LEG-01/06/07 | Piloto visual e fontes preservadas. | Reconciliar os demais 50 exemplares observados, sitemap misto/paginação, outros URLs/anexos; C68 imagem 404 continua aberto. |
|
||||
| C37; A/B; dashboard QA21 | Ensaio QA21 do piloto não os substitui. | C37 no contexto problemático QA18; A/B completos com ferramentas/Balcão/acompanhamento; CRUD de oportunidades pelo dashboard. |
|
||||
|
||||
Continuar países/origem/estatísticas, 173 lacunas cartográficas (Mandie/Guro ambíguo), documentação/derivadas, Response/materialidade e workflow na mesma matriz. Nenhum responsável real ou compromisso SLA foi designado. Base ativa fora do âmbito; sem repetir instalações QA030/040/09-190. C71 bloqueia novas escritas QA até recuperar a integridade e o restauro, sem revogar as provas já obtidas.
|
||||
<!-- qa-legacy-browser-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-20261010:begin -->
|
||||
## Histórico do lote planeado antes do ensaio Chromium
|
||||
|
||||
| Prioridade / requisito | Próxima ação concreta | Critério de fecho |
|
||||
| --- | --- | --- |
|
||||
| P1 / AUD-LEG-02/03/04/06/07 | Roteiro visual do piloto com Promotor QA21: leitura anónima, pesquisa, fonte, criação/continuação, nova sessão, retorno e móvel/teclado. | Ações observadas por perfil/consumidor; mesmos IDs/dados, recuperação/negações e resultado visível. C37 continua independente. |
|
||||
| P1 / AUD-LEG-01/05; LIB01/02; DOC01 | Reconciliar os 50 exemplares observados, paginação/URLs externos e sitemap misto; importar apenas próximo lote com hash/versão/scan e divulgação separada. | Inventário/deduplicação/citações e destinos explícitos; sem omitir não importados nem publicar automaticamente. |
|
||||
| P1 / AUD-LEG-03/05; GEO | Conferir seis nomes/âmbitos de corredores com cadastros/publicações existentes. | Relação territorial inequívoca com citação/IDs; geometria disponível distinguida de uso visual. Nada de inferir parcelas ou coberturas pelo nome. |
|
||||
| P1 / AUD-LEG-07 / C68 | Rever imagem legada GOVERNADOR-in.gif (404) e outros links não recolhidos antes de expandir esses artigos. | Original recuperado com fundamento ou ausência identificada; não declarar ficheiro inexistente como válido. |
|
||||
| P1 / OP04 / PROFILE28 | Continuar Response integral/materialidade e cerimónia funcional; quatro módulos QA42–48 e ausência de histórico QA49 têm prova HTTP, não fecho visual/formal. | Critérios especializados por categoria; revisões afetadas, decisões externas/aprovação/publicação distintas. Confirmação legado não aprova perfis/SLA. |
|
||||
|
||||
Países/origem, estatísticas,173 lacunas geo (Mandie/Guro ambíguo), documentos por consumidor, recuperação e acessos/workflow restantes permanecem no backlog anterior. Próximo trabalho executável não depende de designar pessoas reais ou aprovar compromissos SLA. Plano global 030000 → 040000 → 190000 conservado; recursos do legado/cartografia/divulgação separados. Ver `legado-piloto-qa.md`.
|
||||
<!-- qa-legacy-20261010:end -->
|
||||
|
||||
<!-- qa-response-20261010:begin -->
|
||||
## Incremento atual — Response/§27 e propostas para decisão
|
||||
|
||||
Somente QA **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Mesmo inventário/matriz; nenhum fecho integral ou escrita ativa.
|
||||
|
||||
- **AUD-OP-04:** `/minhas-oportunidades/{id}/response` integra a leitura do editor existente. Preserva IDs, códigos, referências UUID e estados atuais. Dez casos #30–39 recuperados noutra sessão MFA; Validador permitido, organização alheia 403 e anónimo 401. A versão do adaptador declara expressamente que a resposta normativa completa continua parcial.
|
||||
- **C65 / QA40:** §27 exige unicidade de número cadastral por oportunidade. Rascunho incompleto/duplicado permanece recuperável; submissão devolve 422 junto a `canonical.land_rights.parcels.1.cadastral_number`. Após correção, submissão aceite. Números ausentes não são inventados.
|
||||
- **C66 / QA41:** `succession` constava no contrato original mas era recusada pela lista semântica de venda. Lista reconciliada; criação/edição, submissão e nova sessão demonstradas. Não alterar silenciosamente outras modalidades.
|
||||
- **Ainda parcial:** algoritmo de completude, metadados/identidade de itens inline, equivalência explícita dos estados e recuperação dos autores de alteração/exclusão dos recursos. §27 não cria tabelas paralelas. Condições sem regra inequívoca continuam como decisões D-S1/S2.
|
||||
|
||||
Provas: `opportunity-response-http-qa.json`, `OpportunityCanonicalWorkflowTest`, `profile-installation-checks-qa.json` e schema original preservado. O recibo identifica separadamente a tentativa anterior em que passaram os dez critérios de leitura e o ensaio semântico posterior; falhas do harness foram conservadas, não apresentadas como falhas do portal.
|
||||
|
||||
**Propostas modulares e SLA continuam não aprovados.** O ficheiro `propostas-perfis-modulares-sla.md` explicita compromissos internos de atendimento, janela operacional do Promotor, pausas por tarefa, espera externa separada e ausência de decisão automática. Nenhum `due_at`, titular ou perfil modular ativado.
|
||||
|
||||
**Ativação:** o restauro reportado é QA. Para uma futura ativação, conferir a identidade, schema, dados e configuração efetivos da base ativa para a revisão candidata, capturar hashes/manifesto dessa revisão e provar o seu backup/restauro. As provas QA ou `ready=true` não substituem essa ligação. Sequência 030000 → 040000 → 190000; C37, QA21 e A/B visual pendentes, candidata não fixada.
|
||||
|
||||
**Próximo trabalho independente:** completar metadados/Response sem novos cadastros; PDFs gerados/exportações/derivadas por consumidor; países/origem com filtros e estatísticas por formulário; correspondências inequívocas, mantendo Mandie/Guro ambíguo. Cartografia permanece 408 resolvidas e 173 lacunas neste incremento.
|
||||
<!-- qa-response-20261010:end -->
|
||||
|
||||
<!-- 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.**
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T12:26:56+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 |
|
||||
| --- | --- | --- |
|
||||
@@ -14,7 +180,7 @@ QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_tes
|
||||
| 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.
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_123125_test:55441` → **295 tabelas com linhas/hashes iguais e 71 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.
|
||||
|
||||
|
||||
@@ -1,5 +1,126 @@
|
||||
<!-- qa-territory-resource-cases-20261010:begin -->
|
||||
## Casos C86/C87 — recursos e cartografia
|
||||
|
||||
**C86 (P1, OP02/GEO02):** editar localização MZ com província e mudar payload para PT mantendo IDs MZ. Esperado422 no país e preservação do original; observadoantes200 e gravação contraditória. Causa: catálogo de países e seleção territorial validados separadamente; editor e conferência não aplicavam país. Correção: regra cruzada no servidor; UI retira a hierarquia somente no preenchimento após mudança explícita; rascunho estrangeiro incompleto aceite; conferência não pede campos MZ para estrangeiro. Provas: regressão antes/depois, browserQA21 e SQLREADONLY; tentativa rejeitada não muda payload/versão/auditoria. Dois recursos guardados/recuperados noutra sessão; QA23 consulta/escrita403. Nenhuma conversão de registos históricos.
|
||||
|
||||
**C87 (P1, GEO03):** recuperar recurso CaboDelgado/Ancuabe e ler a lista sob o mapa. Esperado limite administrativo/edição/fonte; observado “limites oficiais por verificar” genérico. Causa: lista tinha texto fixo, apesar de popup usar purpose=administrative_boundary. Correção: lista usa o propósito existente e distingue edição administrativa de polígono a comparar; fontes oficiais aceites preservadas. Chromium390px mostrou dois limites e legenda correta, origemINE/2019-06-07. Tiles externos não testados; geometrias/dados/relações não modificados. Provas: territory-resource-map-shot-20261010.json e imagens anteriores preservadas.
|
||||
|
||||
C37 já encerrado por confirmação manual somente para autenticação QA18; não reabrir pelo estado histórico dos recibos. A/B e demais critérios independentes continuam abertos.
|
||||
|
||||
<!-- qa-territory-resource-cases-20261010:end -->
|
||||
|
||||
<!-- c37-manual-close-20261010:begin -->
|
||||
## C37 encerrado — autenticação QA18 — 2026-10-10T20:26:29.902032+02:00
|
||||
|
||||
**Confirmação manual expressa do responsável registada. C37 está encerrado exclusivamente no âmbito da autenticação QA18.** Login → MFA → redirecionamento → sessão autenticada → atualização → logout → nova sessão/recuperação estão ligados às provas anteriores, sem repetir ensaios nem alterar conta, chave ou MFA. O logout manual de 20:17:15 foi confirmado pelo responsável e pelo rastreio: POST302→/, autenticação e estado persistido vazios.
|
||||
|
||||
Aceitação e hashes dos recibos: `c37-manual-close-20261010/acceptance.json`. Referências: `c37-qa18-chromium-20261010.json`, `logout-finalize-20261010/manual-qa18-logout.json`, `portal-logout-chromium-20261010.json` e recuperação do dossier em `investor-ab-chromium-20261010.json`. A prova QA21 do botão é complementar; a conta18 tem a sua própria confirmação. Horário local UTC+02; datas técnicas dos recibos conservadas em UTC.
|
||||
|
||||
O fecho **não encerra A/B, auditoria integral, móvel/acessibilidade integral, outros perfis ou ativação ativa**. Os cinco requisitos relacionados continuam parciais pelos seus outros critérios; contagens preservadas: 3393 requisitos/3393 linhas/6845 ocorrências. Menções históricas abaixo a C37/autenticação18 pendente são superadas somente por este fecho. C85 permanece corrigido em QA. Próximo trabalho independente: gravação/recuperação territorial e consistência país–hierarquia por consumidor, países/origem/estatísticas, documentos e workflow, preservando decisões próprias de perfis modulares/SLA.
|
||||
|
||||
<!-- c37-manual-close-20261010:end -->
|
||||
|
||||
<!-- qa-logout-c85-20261010:begin -->
|
||||
## C85 — logout ausente no percurso do investidor
|
||||
|
||||
- **Reprodução:** autenticar QA18, abrir `/investimentos` ou um dossier; procurar logout no cabeçalho. Não havia botão; era preciso conhecer o menu do dashboard.
|
||||
- **Esperado:** terminar sessão diretamente no percurso, em desktop/móvel e por teclado; dados guardados preservados e acesso privado negado após saída.
|
||||
- **Causa:** PortalNavigation usa PortalGroupedMenu sem ação de sessão; o logout de UserMenuContent só era apresentado pelo AppLayout do dashboard.
|
||||
- **Correção QA:** conta e botão Terminar sessão no cabeçalho comum; POST Fortify/CSRF existentes, limpeza do cache Inertia, sem alteração de guard, MFA ou privilégios.
|
||||
- **Evidência:** responsável confirmou botão/logout QA18; trace18 POST/logout302→/ com auth e persistência vazias. Chromium21 desktop/móvel, API401, página privada→login, nova sessão MFA e snapshot do dossier iguais. Cabeçalho acessível por teclado, sem overflow ou violações automáticas nos seis ensaios.
|
||||
- **Estado:** causa corrigida e logout manual demonstrado. C37 continua limitado pelos critérios F5 explícito e novo login/MFA18 após saída; provas21 não substituem conta18. Provas sanitizadas e 0 escritas ativas no relatório atual.
|
||||
|
||||
<!-- qa-logout-c85-20261010:end -->
|
||||
|
||||
<!-- qa-visual-cases-20261010:begin -->
|
||||
## Casos adicionais C77–C84 e estado C37 — 10/10/2026
|
||||
|
||||
| Caso | Reprodução e observado | Esperado, causa e correção QA |
|
||||
| --- | --- | --- |
|
||||
| C37 | MFA manual aceite; destino pretendido era relatório administrativo, negado 403 com sessão autenticada. Nova tentativa 17:46:42 UTC chegou ao dashboard 200; responsável confirmou acesso. | Recusa administrativa correta. Página QA explica o acesso reservado e oferece retorno; pedido Inertia passa uma vez por 409 para renderizar a página completa, evitando modal na página MFA. QA21 provou 403 → painel → investimentos → F5. C37 habitual ainda pede F5/logout/novo login; ciclo anterior não demonstrado como falha persistente. |
|
||||
| C77 / UI01, LEG02, DASH01 | axe: mains aninhados, conteúdo fora de landmarks e cabeçalho Sheet fora do Dialog. | Um main, navegação e apoio nomeados, SheetHeader dentro do SheetContent. Quinze verificações dirigidas posteriores sem violações automáticas. |
|
||||
| C78 / GEO03 | Mapas com aria-label num div sem role. | Região nomeada. Cabo Delgado apresentou um polígono em quatro consumidores; seleção ainda não guardada e tiles externos não testados. |
|
||||
| C79 / LEG02 | Turismo começava com h3 sob o título h1, omitindo h2. | Normalização apenas na apresentação para h2..h6 e saltos máximos de um nível, mantendo original, bytes, hash e texto. LegacyContentTest 5/84 e Chromium passaram. |
|
||||
| C80 / SIM02, GUIDE01 | Preenchimento iniciado antes de terminar o GET podia ser sobrescrito pelo rascunho remoto; faltavam campos de A na recuperação. | Impedir edição e avanço até recuperar; cancelar efeito obsoleto; devolver resultado só após guardar snapshot completo. Chromium com GET deliberadamente atrasado e nova sessão A/B demonstraram recuperação. Histórico preservado. |
|
||||
| C81 / SIM02 | Tabelas financeiras com scroll sem foco por teclado. | tabindex=0, região nomeada e foco visível. QA21 focou ambas e usou ArrowDown; axe passou. Leitor de ecrã real pendente. |
|
||||
| C82 / GUIDE01 | Dossier a 390px alargava a página para 413px devido a labels/select/datetime da atribuição. | min-width:0, max-width:100% e quebra de linha, sem esconder ações. Dossier próprio QA21 corrigido sem overflow; repetir A/B móvel final. |
|
||||
| C83 / UI01, LEG02 | HTML lang=en para interface em português. | Configuração pt-MZ somente no router QA e fallback anterior no template. Quinze verificações com pt-MZ; locale de validação preservado. |
|
||||
| C84 / DOC01 | Mensagem global “ClamD real pendente” contrariava os scans reais. | Mostrar scan/motor por ficheiro/versão; retirar texto global. QA21 recursos51 confirmou apresentação; nenhuma derivada/PDF recebeu scan fictício. |
|
||||
|
||||
**Mapa manual 51, 17:52:33 UTC:** conta autenticada QA18 recebeu 403; QA21 havia recebido 200. Sessão mantida. É recusa correta por perfil/autoria e não um mapa funcional demonstrado pelo responsável. Credenciais e entradas MFA são distintas; não ampliar autorização para permitir o ensaio na conta errada.
|
||||
|
||||
Provas: `visual-followup-chromium-20261010.json`, `qa-report-access-chromium-20261010.json`, `investor-ab-chromium-20261010.json`, `visual-persistence-qa.json` e `layout-map-diagnosis-chromium-20261010.json`. Falhas do harness preservadas separadamente: ações antes de guardar, moeda não selecionada, esperas e oportunidade30 alheia a QA21. Não contar o 403 dessa oportunidade como cobertura do mapa. Nenhuma alteração ativa.
|
||||
<!-- qa-visual-cases-20261010:end -->
|
||||
|
||||
# Casos de reprodução e lacunas observáveis
|
||||
|
||||
<!-- qa-c71-sector-cases-20261010:begin -->
|
||||
## Estado dos casos C71–C76 após o lote — 2026-10-10T16:23:25.695297+00:00
|
||||
|
||||
**C71 / AUD-OPS-01:** causa/operação/reprodução históricas preservadas abaixo. CorrigidoQA: recuperação emcluster novo, comparação295 tabelas/34.902 linhas/31 sequências e schema/permissões compatíveis; cópia física75575 ficheiros com hashes iguais. Prévia22 itens renovada antes de aplicação; backup/restauro final295/103verificado. Processo que removeu26catálogos continua não identificado; nenhum remendo de catálogo ou rotação de chave. Requisito inteiro parcial pelas demais dependências/ativação. A manutenção foi retirada; GET/login200no18097. Ver provas no relatório atual.
|
||||
|
||||
**C72 / AUD-LEG-02/07:** título deInfraestruturas desaparecia pelo sanitizador excluirh1/filhos; h1→h2 preserva conteúdo e limpeza. Agora exemplar importado/scaneado/consultável e título observado no Chromium, captura `infra-original-heading`; original/hash intacto. CorrigidoQA, não ativo.
|
||||
|
||||
**C73 / AUD-UI-01/C37:** a manutenção técnica é independente das3rejeições de palavra-passe15:38–15:39UTC; estas ocorreram antes do MFA. QA18secret/chave/credencial reconfirmados15:57:57porREADONLY. 2FAS Auth já suportaTOTP; não requer nova aplicação/remoção da entrada antiga. C37continua pendente no navegador do responsável, sem novo ensaio MFA18 deste lote.
|
||||
|
||||
**C74 / AUD-LEG-07, consumidor documental:** reproduzir comimagem do manifesto semtema: login gestorQA14→PUT `/management/portal-services/resources/24` comAssuntovazio→HTTP422campo`topic`. Esperado: guardar metadados originais autorizados seminventarassociação. Causa: importador admite vazio, validaçãoAPI/frontend exigiamvalor. CorreçãoQA: tema nullable sobguarda realQA, vazio normalizado para stringcompatível; interfaceAssuntoopcional. RepetiçãoPUTs das5 imagens+recuperaçãoSQL/download passaram; originais/hashes/scans/ausência de relações preservados. Teste dedicado1/11asserções; visualdoeditor ainda pendente. DivulgaçãoPOSTseparada, sem contorno da autorização.
|
||||
|
||||
**C75 / AUD-LEG-02/03, regressão detectada antes da divulgação:** GET `/explorar/agronegocio` no testeLegacyContentTest retornava500 na nova parametrização, `Undefined property stdClass::$title`. Causa: projeçãoSQL selecionava documento mas omitia título do recurso usado nas leituras relacionadas. Corrigido incluindo`r.title`; teste4/81eHTTP/Chromium passaram. Não atribuir ao middleware. Nenhuma ocorrência ativa alegada.
|
||||
|
||||
**C76 / AUD-LEG-03/06, regressão detectada antes da divulgação:** POST `/investimentos` comartigo válido retornava422. Esperado: validar ID da biblioteca e guardar fonte numdossier próprio. Causa: `value(d.external_id)`sobrebuilder comprojeção pré-existente lia primeiro campoUUID; não encontrava a identidade configurada. Correção: leitura explícita`first()?->external_id`, preservando controles público/hash/scan/contexto. Testes4/81; HTTP4 dossiers/Chromium4 dossiers eREADONLY8 referências únicas. CorrigidoQA, nenhuma escrita ativa.
|
||||
|
||||
Falhas do harness conservadas separadamente: primeira leitura usou indevidamente rota pública de download para exemplar privado404 (a rota protegida correta é `/management/documents/{UUID}/download`); recompilação durante sessãoInertia produziu409/reload esperado; primeiras esperasnetworkidle/imagem ainda não decodificada falharam sob concorrência. Corrigida ordenação/espera no teste, sem alterar autenticação/aplicação para fabricar sucesso. Histórico de tentativasprivadas preservado; resultado final sófoi declarado após percursos completos.
|
||||
<!-- qa-c71-sector-cases-20261010:end -->
|
||||
|
||||
<!-- qa18-credential-diagnosis-20261010:begin -->
|
||||
**Atualização manual15:41UTC:** os pedidos15:38:38,15:39:04 e15:39:43UTC têm `email_exact_matches_qa18=true` e `password_matches_qa18=false`; POST/login302 → GET/login200, sem `login.id` nem utilizador autenticado. Primeiro ponto de falha agora identificado: validação da palavra-passe recebida, antes do MFA; não é rejeição do código, desencriptação ou perda após autenticação. A razão pela qual o valor enviado diverge não está demonstrada. A palavra-passe privada foi novamente conferida por READ ONLY às15:41:06UTC; hash e MFA válidos, conta inalterada. Rastreio sanitizado arquivado em `investor-credential-manual-rejection-20261010.json`. Preparado um auxiliar privado local com apenas a palavra-passe (sem JSON/aspas), ignorado pelo Git, para cópia direta; nenhum segredo publicado. Conector do navegador manual indisponível; completar a tentativa nesse navegador e manter C37 aberto.
|
||||
|
||||
## C73 — rejeição de credenciais QA18 reportada; diagnóstico renovado
|
||||
|
||||
**Requisito:** AUD-UI-01/C37; P0 de acesso, confirmação manual pendente. **Relato:** `/login` em `http://127.0.0.1:18097` apresentou “These credentials do not match our records.” **Resultado esperado:** credenciais QA18 corretas conduzem ao desafio MFA, sem autenticar antes de um segundo fator válido. **Diagnóstico READ ONLY 10/10/2026,15:34:50UTC:** destino efetivo `desk_restore_20261009_174726_test:55441`, uma conta18, email exato, palavra-passe privada corresponde ao hash, sem necessidade de rehash; MFA confirmado, desencriptação e correspondência com segredo privado válidas; cache de configuração não carregada. Nenhuma alteração à conta, MFA, chave ou base ativa.
|
||||
|
||||
**Interface observada:** Chromium em contexto isolado, pelo mesmo servidor18097; GET/login200 → POST/login302 → GET/two-factor-challenge200, com conta18 indicada e `login.id=18`; nenhuma autenticação completa nem código MFA submetido. Recibos privados `investor-credential-diagnosis-20261010_153450.json` e `investor-credential-browser-*.json`. A rejeição relatada não foi reproduzida com os valores do ficheiro; a causa da tentativa manual permanece indeterminada. Não atribuir a desencriptação, sessão, erro de cópia ou conta errada sem evidência da tentativa.
|
||||
|
||||
**Falha adicional identificada e corrigida no rastreio:** o ficheiro anterior atingiu5243564bytes e deixou de recolher pedidos às10:07:06UTC, por limite de5MiB. Histórico preservado; novos pedidos gravados em segmentos privados. O diagnóstico de POST/login regista apenas booleanos de correspondência à conta18, nunca email submetido, palavra-passe, hash, cookies ou códigos. Próxima rejeição poderá distinguir credenciais enviadas, resposta e estado MFA/sessão. C37, a recuperação autenticada e A/B continuam pendentes; esta prova termina no ecrã MFA. C71 permanece independente e aberto.
|
||||
<!-- qa18-credential-diagnosis-20261010:end -->
|
||||
|
||||
<!-- legacy-sector-heading-20261010:begin -->
|
||||
## C72 — título original removido na projeção de Infraestruturas
|
||||
|
||||
**Requisitos:** AUD-LEG-02/07; P1 na expansão. **Reprodução:** aplicar `LegacyContent::sanitize()` ao HTML original do post171992, através de `tools/legacy-sector-content-check.php before`. **Esperado:** título “2. Sectores Prioritários de Investimento” preservado sem introduzir segundo h1 de página. **Observado:** título ausente; os restantes16 exemplares do lote não apresentavam essa perda. **Causa:** lista de apresentação não admitia h1 e removia o elemento com todos os filhos. **Correção preparada:** normalizar h1 interno para h2 e manter limpeza de atributos/filhos. **Prova:** verificação after sobre17 originais, hashes preservados, título presente e markup ativo removido; nenhum acesso à base. **Pendente:** importar o exemplar privado depois de C71, divulgar separadamente e demonstrar a página real. Não atualizar outros critérios da matriz com esta prova de transformação, nem declarar o setor publicado. C37/QA18 continua independente.
|
||||
<!-- legacy-sector-heading-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-browser-20261010:begin -->
|
||||
## C70 — identificação errada no título do navegador
|
||||
|
||||
**Requisito:** AUD-LEG-02; P2. **Reprodução:** abrir anónimamente `/explorar/agronegocio` na QA 18097 e consultar o título do separador. **Esperado:** identidade do portal e distinção QA. **Observado:** “Explorar agronegócio - Laravel”. **Causa:** `resources/js/app.tsx` usa o nome padrão da compilação; a QA herdava `VITE_APP_NAME` do ambiente local. **Correção:** `vite.config.ts` define “InvesteMoz QA” exclusivamente sob `INVESTMOZ_QA_BUILD=1`; não altera .env nem build ativo. **Evidência:** título verificado no Chromium, percurso renderizado após recompilação; manifesto ativo inalterado. Código preparado eQA demonstrada; não ativado.
|
||||
|
||||
As primeiras tentativas do ensaio de browser terminaram por falta de espera da navegação Inertia no próprio teste, antes de inspecionar o URL atualizado. O runner foi corrigido para aguardar o destino; recibos das tentativas preservados privadamente. Não classificar essas falhas de instrumentação como falhas do portal. C37 não foi repetido; C68 permanece aberto.
|
||||
|
||||
## C71 — falha do novo backup, cluster PostgreSQL QA
|
||||
|
||||
**Requisito:** AUD-OPS-01, dependência de AUD-PROFILE-31; P0, aberto. **Reprodução:** guarda identifica `desk_restore_20261009_174726_test:55441`; executar `tools/portal-profile-installation-backup-qa.php`. **Esperado:** dump atual, restauro noutra base QA e comparação integral. **Observado:** `pg_dump` exit 1 ao consultar `pg_roles`, pois não consegue abrir `global/2964`; nenhum novo restauro criado. **Causa identificada:** ficheiro de `pg_db_role_setting` ausente; inspeção READ ONLY identifica 26 ficheiros partilhados ausentes no cluster QA em Temp. O processo que os removeu não foi identificado. **Mitigação:** originais/backups anteriores preservados; 295 tabelas/34.902 linhas/31 sequências extraídas privadamente por leitura; readiness atual invalidada e erro do CLI controlado. **Correção pendente:** recuperar cluster QA preservando schema, IDs, relações, sequências e chave estável, conferir todos os dados/ficheiros e repetir backup/restauro antes de ensaios com escrita. Não declarar preservação NDJSON ou backup13:35 como restauro renovado. Nenhum `key:generate`, catálogo inventado, reinício do PostgreSQL ativo ou escrita ativa.
|
||||
|
||||
Referência técnica: [PostgreSQL17 — pg_db_role_setting](https://www.postgresql.org/docs/17/catalog-pg-db-role-setting.html), catálogo partilhado por todas as bases de um cluster. Diagnósticos completos e extrações permanecem privados.
|
||||
<!-- qa-legacy-browser-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-20261010:begin -->
|
||||
## Casos adicionais preservados — fontes e consumidor novo
|
||||
|
||||
- **C67 / OP04:** publicar a chamada de concessãoQA42 após aprovação. Esperado: projeção restrita persistida e legível. Observado inicial:HTTP503; rótulo de modalidade adicionado à síntese excedia coluna `summary` de 500 carateres. Causa no novo adaptador, não no middleware. Correção limita projeção a 499 + reticência e preserva texto canónico completo. Tentativa falhada arquivada; depois QA42–48 divulgadas/recuperadas noutra sessão MFA. Visual pendente.
|
||||
- **C68 / LEG07:** GET público legado `/images/ANO-2017/JUNHO/21/GOVERNADOR-in.gif`. Esperado: imagem original; observado 404. Identificado no inventário; não afeta PNG do agronegócio. Falha permanece emartigos consumidores dessaimagem; não inventar substituição ou autenticidade.
|
||||
- **C69 / LIB02 / LEG01:** prévia do exemplar do artigo JSON no importador existente. Esperado: cadastro privado, scan e adaptação segura; observado MIME application/json não suportado. Lista doimportador reconciliada e upload JSON guardado em QA; original preservado, apresentação sanitizada, hash verificado, publicação autenticada separada. Dois recursos; repetição sem duplicação. Não alargar divulgação ativa.
|
||||
|
||||
Superutilizador27 recebeu403 em gestão documental, conforme autorização existente; gestor QA14 executou divulgação. Esse ensaio negativo comprova segregação e não é uma falha a contornar. C37 não foi retestado nem encerrado.
|
||||
<!-- qa-legacy-20261010:end -->
|
||||
|
||||
<!-- qa-response-20261010:begin -->
|
||||
- **C65 / QA40:** §27 exige unicidade de número cadastral por oportunidade. Rascunho incompleto/duplicado permanece recuperável; submissão devolve 422 junto a `canonical.land_rights.parcels.1.cadastral_number`. Após correção, submissão aceite. Números ausentes não são inventados.
|
||||
- **C66 / QA41:** `succession` constava no contrato original mas era recusada pela lista semântica de venda. Lista reconciliada; criação/edição, submissão e nova sessão demonstradas. Não alterar silenciosamente outras modalidades.
|
||||
|
||||
C65 foi identificado por confronto código/§27; C66 por divergência da lista do servidor com o contrato. A prova atual é pós-correção. Não atribuir a estes casos falhas antigas não reproduzidas.
|
||||
<!-- qa-response-20261010:end -->
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Casos adicionais deste lote — sem reiniciar o inventário
|
||||
|
||||
|
||||
@@ -535,7 +535,7 @@ C:\laragon\www\portal-investidor\docs\investmoz\v2\consolidacao-nove-diferencas.
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\consolidacao-variantes.md,7958,3defe7f46aeaa37da2f06f5c358da83f48ff4656566d4044bfe7cfb0ad50d5b2,original,,Documentação do repositório,Nenhum cálculo ativado pela auditoria,Situação documental não determinada por inventário,
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\ficheiros-primeira-entrega.csv,15338,b8c6eefb8fe879df7637bcda4aa4d458254edd82aceaa86991b5d885c046ee0a,original,,Documentação do repositório,Nenhum cálculo ativado pela auditoria,Situação documental não determinada por inventário,
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\ficheiros-segunda-entrega.csv,3345,a7445854213ad91cf5a929237dc7c2ab2f2f682f5eb014cd10c4f9623ac30d20,original,,Documentação do repositório,Nenhum cálculo ativado pela auditoria,Situação documental não determinada por inventário,
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\gestao-oportunidades-dashboard.md,13951,a12b57985e779180860983968922941bac9db3ab91aa4977d4ccf2b5b6d74997,original,,Documentação do repositório,Nenhum cálculo ativado pela auditoria,Situação documental não determinada por inventário,
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\gestao-oportunidades-dashboard.md,58976,a544573a042064c74cc1aaa3003cdecf82f9d17b5ff198956b634504b74ba183,original," | [Visual QA 10/10/2026] Versão atual com provas visuais dirigidas. SHA anterior a12b57985e779180860983968922941bac9db3ab91aa4977d4ccf2b5b6d74997, bytes anteriores 13951; histórico preservado. | [Visual QA 10/10/2026] Versão atual com provas visuais dirigidas. SHA anterior b08f5b48263f4cca4aefeaaf6d59d6f1dfee185f2e0c57a8d4e00743588b0c6e, bytes anteriores 57819; histórico preservado. | [Visual QA 10/10/2026] Versão atual com provas visuais dirigidas. SHA anterior c54663ddf13b48235da8614d2b2f7c8b62bf801142d00d45642d65bcbbaba5fe, bytes anteriores 58005; histórico preservado.",Documentação do repositório,Nenhum cálculo ativado pela auditoria,Situação documental não determinada por inventário,
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\indice-documento-mestre-atual.csv,151889,35ffb5d727f8ab1811ac946c2f89c2e95a74108b9a9cfbdd688a29fffee6080f,original,,Documentação do repositório,Nenhum cálculo ativado pela auditoria,Situação documental não determinada por inventário,
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\indice-documento-mestre.csv,153757,4c13f1efd25a130c0d63e4a7f2efd9f9c2fbe9eb7ddee452534f06e8d7759bdd,original,,Documentação do repositório,Nenhum cálculo ativado pela auditoria,Situação documental não determinada por inventário,
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\oportunidades-correspondencias.md,15378,3812fe47bf667a5323363cbfc7c0b329c1f717f4ea814a6e3c454e6624224a72,original,,Documentação do repositório,Nenhum cálculo ativado pela auditoria,Situação documental não determinada por inventário,
|
||||
@@ -590,4 +590,9 @@ C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\prompt\novos\~$ompt a se
|
||||
C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\prompt\novos\Gestao de perfis investeMOZ.docx,46648,283a32f4c4b02bdfd9318a9174268224eeefac4349c49e3171545707bd222855,original,Inventário complementar 10/10/2026; versões anteriores preservadas,Acessível localmente; importação/publicação não inferida,Nenhum cálculo ativado,Situação jurídica/documental não inferida do inventário,Exemplar identificado por SHA256
|
||||
C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\prompt\novos\~WRL4044.tmp,46970,87fee09bb7e547d7aeebe7de984681fb52e588c74988da7ebe0ac72048e73a22,original,Inventário complementar 10/10/2026; versões anteriores preservadas,Acessível localmente; importação/publicação não inferida,Nenhum cálculo ativado,Situação jurídica/documental não inferida do inventário,Exemplar identificado por SHA256
|
||||
C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\prompt\novos\Gestao de perfis investeMOZ v1.docx,50215,f9e0f6280566fed8b52d0b36bb814a82e11976fec9ea9f62d64611ff8c824b38,original,Inventário complementar 10/10/2026; versões anteriores preservadas,Acessível localmente; importação/publicação não inferida,Nenhum cálculo ativado,Situação jurídica/documental não inferida do inventário,Exemplar identificado por SHA256
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\auditoria-portal\propostas-perfis-modulares-sla.md,17568,1fa569e1c02d0e204f98e58252babefc2e7f0391265215e5e49e7566026620c2,proposta interna QA,"Documento integral; regras/fundamento/efeito/decisões efonte§25.4. Não fonteoficial,nenhuma aprovaçãoinferida.",Consulta no repositório; publicação não inferida,Nenhuma regra/SLA/cálculo ativado,"Proposta para decisão, não ato jurídico",proposta20261010v1
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\auditoria-portal\propostas-perfis-modulares-sla.md,24585,20028b0abcb1235109668b6c36ef7e931777c5444334a776f967a7e1ee750c85,reconciliação auditoriaQA,Legado inventariado/pilotoQA; cobertura histórica e decisões separadas explicitamente preservadas.,Relatório/inventário para consulta; não habilita importação/publicação ativa,Nenhum cálculo ativado,"Documento de programação/reconciliação, não fonte oficial",proposta20261010v1 | exemplar anterior SHA256=a4549b18cad22eddeafdad7cb64f299c4200b01e439fb6a334714c19c7f0a588 | atualizaçãoQA10/10/2026 | atualizaçãoQA10/10/2026
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\auditoria-portal\legado-piloto-qa.md,38405,8ffe19cc416804baa67487d3c7f2218b3ed218ca72c855c97f1c8bef65a8189a,reconciliação auditoriaQA,"C71 recuperado e 22 originais QA importados/scaneados; 21 divulgados separadamente; 16 novos artigos e 4 dossiers demonstrados. Históricos/restrições preservados. | Nota anterior: Confirmação manual limitada e reconciliação dos quatro setores;22 candidatos privados, sem importação/publicação; C71/C37/A-B/móvel/acessibilidade/outros perfis preservados. | [Visual QA 10/10/2026] Versão atual com provas visuais dirigidas. SHA anterior c610372999be13f3df9d7683693adff1dbd8db4c9439cd2e951db2d294db4781, bytes anteriores 31191; histórico preservado. | [Visual QA 10/10/2026] Versão atual com provas visuais dirigidas. SHA anterior b2b25ef793d7b90b6b9a5295792a7d2415db3138851a7d50da9d090c84367938, bytes anteriores 37248; histórico preservado. | [Visual QA 10/10/2026] Versão atual com provas visuais dirigidas. SHA anterior 11ae88513acc709f80504bbf8f74200bafd47b3b910bdbc0eed7a921fcca1dc0, bytes anteriores 37434; histórico preservado.",Relatório/inventário para consulta; não habilita importação/publicação ativa,Nenhum cálculo ativado,"Documento de programação/reconciliação, não fonte oficial", | atualizaçãoQA10/10/2026 | atualizaçãoQA10/10/2026 | exemplar anterior SHA256=2b23ac5f0e0117f53362dfaa756b083aabb5ddc0625a4a2c4c7acd8a06902467 | exemplar anterior SHA256=4561077d6ce5e112e2ccb5d04742a304f03fd4bc188dc0133b4833497427b85f | interfaceQA10/10/2026 | exemplar anterior SHA256=1ca20bb4036f8c73ea5a98a53d43964fe555427595d67df8f7f64c7e1ee591d3 | interface QA10/10/2026 | exemplar anterior SHA256=30c9e98b5615fbc9992d1522155c8556b0371b06618ec041f21812a81ea12477 | exemplar anterior SHA256=25c343e53febc02cf04455fef61f6dd6e9a959ca6a25b288ada0b154182accbe | exemplar anterior SHA256=87a11691519df7bb6c29e74ae525f0a7074c54bca3e3eed38799b37c5fa0bf1c | loteQAsetores2026-10-10T16:23:25.695297+00:00; exemplar anteriorSHA256=bb3d68ddac3e97a34246e20ba4cfd30f34afc523d2439be0ec38e83b64a6ad31 | revisão de legibilidade; exemplar anterior SHA256=c5ec30a05739fabfadf958398903ae9efe6cb50af8bbc1534dedde484aa5b989
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\auditoria-portal\legado-inventario.csv,35375,56cc3058d6824b8535cbe8ea905c8764233849656c62af0eaa1c76275cf191e9,reconciliação auditoriaQA,"C71 recuperado e 22 originais QA importados/scaneados; 21 divulgados separadamente; 16 novos artigos e 4 dossiers demonstrados. Históricos/restrições preservados. | Nota anterior: Confirmação manual limitada e reconciliação dos quatro setores;22 candidatos privados, sem importação/publicação; C71/C37/A-B/móvel/acessibilidade/outros perfis preservados.",Relatório/inventário para consulta; não habilita importação/publicação ativa,Nenhum cálculo ativado,"Documento de programação/reconciliação, não fonte oficial", | atualizaçãoQA10/10/2026 | atualizaçãoQA10/10/2026 | exemplar anterior SHA256=ff219009d137cd691fd08ad18135a7c039548141187334d9b9fdda806ea119b3 | loteQAsetores2026-10-10T16:23:25.695297+00:00; exemplar anteriorSHA256=74b33e590199880f9d04e77ce989aca63f30b92ceecc1cfbe172fa9f40784487
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\auditoria-portal\legado-conteudos-reconciliados.csv,19973,02a352202d7e0bbe9e72ec7132b46489094facd4ef040f7074c8031f93ff7660,reconciliação auditoriaQA,"C71 recuperado e 22 originais QA importados/scaneados; 21 divulgados separadamente; 16 novos artigos e 4 dossiers demonstrados. Históricos/restrições preservados. | Nota anterior: Confirmação manual limitada e reconciliação dos quatro setores;22 candidatos privados, sem importação/publicação; C71/C37/A-B/móvel/acessibilidade/outros perfis preservados.",Relatório/inventário para consulta; não habilita importação/publicação ativa,Nenhum cálculo ativado,"Documento de programação/reconciliação, não fonte oficial", | atualizaçãoQA10/10/2026 | atualizaçãoQA10/10/2026 | exemplar anterior SHA256=b4ecf886ac1412f1699fbe092f070fd24298a91e6cc4adaf1eaad121fd563843 | loteQAsetores2026-10-10T16:23:25.695297+00:00; exemplar anteriorSHA256=a5ad522f12419c30b630988c57f84d8e29c1ff154fa4a0a5e8c041297dd7a396
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\auditoria-portal\legado-referencias.csv,809020,084d2b0ea0c118c9c68d38407108e9c08c34ae073424b8b04bdf9f89f31b47d7,reconciliação auditoriaQA,Legado inventariado/pilotoQA; cobertura histórica e decisões separadas explicitamente preservadas.,Relatório/inventário para consulta; não habilita importação/publicação ativa,Nenhum cálculo ativado,"Documento de programação/reconciliação, não fonte oficial", | atualizaçãoQA10/10/2026 | atualizaçãoQA10/10/2026
|
||||
C:\laragon\www\portal-investidor\docs\investmoz\v2\auditoria-portal\legado-sitemap-anunciado.csv,5736407,8b35432d4320da391a6bb7dbcaca15485cd2b9c03d4286ad4489a0e028271273,reconciliação auditoriaQA,Legado inventariado/pilotoQA; cobertura histórica e decisões separadas explicitamente preservadas.,Relatório/inventário para consulta; não habilita importação/publicação ativa,Nenhum cálculo ativado,"Documento de programação/reconciliação, não fonte oficial", | atualizaçãoQA10/10/2026 | atualizaçãoQA10/10/2026
|
||||
|
||||
|
@@ -0,0 +1,51 @@
|
||||
ID legado,Título,Autor,Categoria,Publicado,Modificado,SHA256 conteúdo,URLs observadas,Recurso QA,Situação
|
||||
171985,Agronegocio,tempo,Oportunidades,2026-04-06T09:19:07+00:00,2026-04-20T12:41:43+00:00,28383a11d95f13894381357d8f1227b33031d1d3ff74507da4ea8e5ce2470913,https://investemoz.tempo.co.mz/agronegocio,6,recurso único QA; não é oportunidade
|
||||
62413,15/30 costa do sol com dua na zona de mupulene muito lindo,tempo,Categoria,2017-06-26T11:44:21+00:00,2017-06-26T11:44:21+00:00,c04ae821317ef556c01c3141ce75eeeeb5da7787e0c6849b1b3709b757edb9eb,https://investemoz.tempo.co.mz/1530-costa-do-sol-com-dua-na-zona-de-mupulene-muito-lindo,,inventariado; cadastro/versão por reconciliar
|
||||
173220,Alumínio “Verde”,tempo,Oportunidades,2026-06-16T12:41:02+00:00,2026-06-16T12:41:06+00:00,c61f05a56bb5a5f13d9d5d54dbefd270cf9ac40320917d77aaad76cac56c7b97,https://investemoz.tempo.co.mz/aluminio-verde,,inventariado; cadastro/versão por reconciliar
|
||||
73945,Banco Mundial classifica de proibitivos juros praticados em Moçambique,tempo,Nas Redes,2017-07-31T19:55:24+00:00,2017-07-31T19:55:24+00:00,36ba82b8885b00a4ea2fd9e4b1d8cf304a8e78e591972682f0aa53717f648d19,https://investemoz.tempo.co.mz/banco-mundial-classifica-de-proibitivos-juros-praticados-em-mocambique,,inventariado; cadastro/versão por reconciliar
|
||||
173213,Bens de Consumo e Franquias,tempo,Oportunidades,2026-06-16T11:31:06+00:00,2026-06-16T11:32:10+00:00,abd5f36a0c2b89d2989334137aae5d59b3158da38d556013ccd55deaa5069e92,https://investemoz.tempo.co.mz/bens-de-consumo-franquias,,inventariado; cadastro/versão por reconciliar
|
||||
172223,"Cabo Delgado - Pemba, A capital do turismo costeiro do norte de Moçambique",tempo,Turismo,2026-04-14T10:21:20+00:00,2026-04-14T10:41:05+00:00,c96b2430c44b8edc9cc1d0a7974c23290dd0207cff366fea8326bb28bcab8285,https://investemoz.tempo.co.mz/cabo-delgado-pemba-a-capital-do-turismo-costeiro-do-norte-de-mocambique/ | https://investemoz.tempo.co.mz/cabo-delgado-pemba-a-capital-do-turismo-costeiro-do-norte-de-mocambique,20,RecursoQA20; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
173482,Checklist para Contratação e Direitos do Trabalho Doméstico,tempo,Trabalho Doméstico,2026-09-15T12:36:11+00:00,2026-09-15T12:36:14+00:00,309b51ebc53bf840f9014da733bc09f8d162138be4e6e9d5a72670e26cc8e482,https://investemoz.tempo.co.mz/checklist-para-contratacao-e-direitos-do-trabalho-domestico,,inventariado; cadastro/versão por reconciliar
|
||||
76474,"Coloque o seu número para o grupo de whatsapp, venda de acessórios de computador",tempo,Categoria,2017-08-08T17:29:51+00:00,2017-08-08T17:29:51+00:00,c8beeb94f936b3bfc69c720770bd84376e9f19b668d96185aaefbada05727010,https://investemoz.tempo.co.mz/coloque-o-seu-numero-para-o-grupo-de-whatsapp-venda-de-acessorios-de-computador,,inventariado; cadastro/versão por reconciliar
|
||||
173484,Direitos Especiais da Trabalhadora Doméstica no Regulamento,tempo,Trabalho Doméstico,2026-09-15T12:38:08+00:00,2026-09-15T12:38:11+00:00,34c31bb02a3ad0886f12ac2853283de2ffb89a925be3cda73b7ce9385c6caec4,https://investemoz.tempo.co.mz/direitos-especiais-da-trabalhadora-domestica-no-regulamento,,inventariado; cadastro/versão por reconciliar
|
||||
171981,Energia & Mineração,tempo,Oportunidades,2026-04-06T09:17:46+00:00,2026-04-30T13:35:32+00:00,e9a95f0c8176eea48a75bd2c5bce69f15308806988fa5203a688af7c40305e9c,https://investemoz.tempo.co.mz/energia-mineracao,7,RecursoQA7; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
173222,Energia e Recursos Naturais,tempo,Oportunidades,2026-06-16T12:47:16+00:00,2026-06-16T12:51:30+00:00,6f8236b0441d61fce5171994ba6b82ffc8b5f2de3e8242de7be29248ed046ad2,https://investemoz.tempo.co.mz/energia-recursos-naturais,,inventariado; cadastro/versão por reconciliar
|
||||
62414,Frelimo e Zanu-PF: Evocados laços de irmandade,tempo,Categoria,2017-06-26T12:03:20+00:00,2017-06-26T12:03:20+00:00,013307965b8fcb349a0d936d844ed12437bda4c90389f8efe958d4ffdb9ce62b,https://investemoz.tempo.co.mz/frelimo-e-zanu-pf-evocados-lacos-de-irmandade,,inventariado; cadastro/versão por reconciliar
|
||||
62418,Governo dará todo O apoio à PGR no seguimento da auditoria,tempo,Categoria,2017-06-26T12:30:37+00:00,2017-06-26T12:30:45+00:00,65299b6711499e417be778b51c7ab7880616b0ccbf362854370eb775fc54caf3,https://investemoz.tempo.co.mz/governo-dara-todo-o-apoio-a-pgr-no-seguimento-da-auditoria,,inventariado; cadastro/versão por reconciliar
|
||||
75002,Governo pretende construir 100 mil habitações em dois anos,tempo,Nas Redes,2017-08-04T20:01:55+00:00,2017-08-04T20:01:55+00:00,b32ea95039c2af387687738ed601f0947384eb7e19ca4bd282d603c60ca26afa,https://investemoz.tempo.co.mz/governo-pretende-construir-100-mil-habitacoes-em-dois-anos,,inventariado; cadastro/versão por reconciliar
|
||||
173478,Guia Prático para Empregadores sobre Contratação e Direitos no Trabalho Doméstico,tempo,Trabalho Doméstico,2026-09-15T12:30:34+00:00,2026-09-15T12:30:37+00:00,8038d8c221ba3cbbc4712304a6b17a6bba8ad946c8bac1a7836d472944aaf930,https://investemoz.tempo.co.mz/guia-pratico-para-empregadores-sobre-contratacao-e-direitos-no-trabalho-domestico,,inventariado; cadastro/versão por reconciliar
|
||||
173218,Imobiliário,tempo,Oportunidades,2026-06-16T11:34:45+00:00,2026-06-16T11:34:49+00:00,be8b606f087a939bd88a86f4436a0ff18c5fb774a179e6e915d4abb10a95f930,https://investemoz.tempo.co.mz/imobiliario,,inventariado; cadastro/versão por reconciliar
|
||||
171992,Infraestruturas,tempo,Oportunidades,2026-04-06T09:25:07+00:00,2026-04-30T12:44:37+00:00,9716da16aba58eec173343f0bbd582d62d23a5eee7d20522c080c2da5da8ba66,https://investemoz.tempo.co.mz/infraestruturas,22,RecursoQA22; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
172240,"Maputo Cidade – Plataforma Estratégica de Turismo, Negócios e Investimento Sustentável",tempo,Turismo,2026-04-14T10:58:53+00:00,2026-04-14T11:03:28+00:00,0866639d09c5c0b3b00e8527554065587c793f60df978b66b6a578b3c741bc0d,https://investemoz.tempo.co.mz/maputo-cidade-plataforma-estrategica-de-turismo-negocios-e-investimento-sustentavel/ | https://investemoz.tempo.co.mz/maputo-cidade-plataforma-estrategica-de-turismo-negocios-e-investimento-sustentavel,16,RecursoQA16; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
171994,Mercado Financeiro,tempo,Oportunidades,2026-04-06T09:27:00+00:00,2026-04-17T09:53:42+00:00,e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,https://investemoz.tempo.co.mz/mercado-financeiro,23,"RecursoQA23; original/hash/ClamD/replay demonstrados. Corpo vazio na origemHTML/Inertia200;privado,sem página publicada."
|
||||
173480,Modelo de Contrato de Trabalho Doméstico para Pequenas Famílias,tempo,Trabalho Doméstico,2026-09-15T12:34:17+00:00,2026-09-15T12:34:19+00:00,5b95ad9010fcbdc1000052f47766ca54409d3e0649211729ced9ef142f5c6db0,https://investemoz.tempo.co.mz/modelo-de-contrato-de-trabalho-domestico-para-pequenas-familias,,inventariado; cadastro/versão por reconciliar
|
||||
172219,Niassa - O último grande destino virgem do turismo africano,tempo,Turismo,2026-04-14T10:17:14+00:00,2026-04-14T10:43:56+00:00,47d82a59a86c3cbdef04ea065e711e7139d50137e440f03efbd710e3c4220ef7,https://investemoz.tempo.co.mz/niassa-o-ultimo-grande-destino-virgem-do-turismo-africano/ | https://investemoz.tempo.co.mz/niassa-o-ultimo-grande-destino-virgem-do-turismo-africano,21,RecursoQA21; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
173476,Procedimentos para Rescisão do Contrato de Trabalho Doméstico segundo o Regulamento de Moçambique,tempo,Trabalho Doméstico,2026-09-15T11:39:21+00:00,2026-09-15T11:39:24+00:00,bb634a9021da9d7b46ed105317aa54e4af42a16dab042e0ceb2bdc0d702d1a67,https://investemoz.tempo.co.mz/procedimentos-para-rescisao-do-contrato-de-trabalho-domestico-segundo-o-regulamento-de-mocambique,,inventariado; cadastro/versão por reconciliar
|
||||
172263,"Província da Zambézia: Onde os Corredores de Moçambique Convergem com a Natureza, a Cultura e o Turismo Sustentável",tempo,Turismo,2026-04-14T14:21:52+00:00,2026-04-14T14:26:27+00:00,9e7ca4e9542135d5969f5c008ce8e29b037cc06a53875cfa7dc3e7452ec47744,https://investemoz.tempo.co.mz/provincia-da-zambezia-onde-os-corredores-de-mocambique-convergem-com-a-natureza-a-cultura-e-o-turismo-sustentavel,11,RecursoQA11; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
172233,Província de Gaza: Oportunidades mais sólidas e estratégicas de investimento no turismo no sul de Moçambique.,tempo,Turismo,2026-04-14T10:52:30+00:00,2026-04-17T08:28:56+00:00,2bf2df9bd3b7ab8656209a48f4928b5d8bbf2959e77c292fc4c9987e67ba2fea,https://investemoz.tempo.co.mz/provincia-de-gaza-oportunidades-mais-solidas-e-estrategicas-de-investimento-no-turismo-no-sul-de-mocambique/ | https://investemoz.tempo.co.mz/provincia-de-gaza-oportunidades-mais-solidas-e-estrategicas-de-investimento-no-turismo-no-sul-de-mocambique,18,RecursoQA18; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
172253,Província de Inhambane: Um Destino Estratégico para Investimento Turístico,tempo,Turismo,2026-04-14T14:15:12+00:00,2026-04-14T14:25:44+00:00,2d1cc729481ce65a5e50898c0eea4a1eb2418273b03fc749009e2ffaaa72fe71,https://investemoz.tempo.co.mz/provincia-de-inhambane-um-destino-estrategico-para-investimento-turistico/ | https://investemoz.tempo.co.mz/provincia-de-inhambane-um-destino-estrategico-para-investimento-turistico,15,RecursoQA15; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
172258,"Província de Manica: Onde o Turismo de Montanha, Natureza e Cultura Encontra o Próximo Ciclo de Investimento",tempo,Turismo,2026-04-14T14:18:28+00:00,2026-04-17T10:42:16+00:00,4c8bdd79212ba98fa347733e2f58aef8796b29cc8cb8b649d200bd37435df1c3,https://investemoz.tempo.co.mz/provincia-de-manica-onde-o-turismo-de-montanha-natureza-e-cultura-encontra-o-proximo-ciclo-de-investimento/ | https://investemoz.tempo.co.mz/provincia-de-manica-onde-o-turismo-de-montanha-natureza-e-cultura-encontra-o-proximo-ciclo-de-investimento,14,RecursoQA14; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
172237,"Província de Maputo: Natureza, Cultura e Conectividade como Motores de Crescimento Sustentável",tempo,Turismo,2026-04-14T10:55:21+00:00,2026-04-17T08:27:24+00:00,d112d0e535912f4487c2b8d41978e5e663f1db43f1a2cdf170dbbec535f13f27,https://investemoz.tempo.co.mz/provincia-de-maputo-turismo/ | https://investemoz.tempo.co.mz/provincia-de-maputo-turismo,17,RecursoQA17; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
172230,Província de Nampula - Perfil de investimento no setor do turismo,tempo,Turismo,2026-04-14T10:50:32+00:00,2026-04-14T11:10:23+00:00,d18ccc1e036b3480605462ce7176be75153f6aa988bc9b37d8d395b01e555a7a,https://investemoz.tempo.co.mz/provincia-de-nampula-perfil-de-investimento-no-setor-do-turismo/ | https://investemoz.tempo.co.mz/provincia-de-nampula-perfil-de-investimento-no-setor-do-turismo,19,RecursoQA19; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
172256,"Província de Sofala: Onde a Natureza, a História e o Futuro do Turismo Convergem",tempo,Turismo,2026-04-14T14:16:47+00:00,2026-04-14T14:26:17+00:00,12b46a34ba61c6ef0058a6c410ebc17329d897fe4b06a13b4ea3fe3c85a28342,https://investemoz.tempo.co.mz/provincia-de-sofala-onde-a-natureza-a-historia-e-o-futuro-do-turismo-convergem/ | https://investemoz.tempo.co.mz/provincia-de-sofala-onde-a-natureza-a-historia-e-o-futuro-do-turismo-convergem,12,RecursoQA12; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
172261,"Província de Tete: Onde o Rio Zambeze, a Cultura Viva e a Conservação Comunitária Criam o Futuro do Turismo",tempo,Turismo,2026-04-14T14:19:48+00:00,2026-04-14T14:26:25+00:00,e78fce2931260a7d6f82270897e906feb640f65d084c2d4ffb5ba19303858275,https://investemoz.tempo.co.mz/provincia-de-tete-onde-o-rio-zambeze-a-cultura-viva-e-a-conservacao-comunitaria-criam-o-futuro-do-turismo/ | https://investemoz.tempo.co.mz/provincia-de-tete-onde-o-rio-zambeze-a-cultura-viva-e-a-conservacao-comunitaria-criam-o-futuro-do-turismo,13,RecursoQA13; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
73246,Rofino Licuco preso cinco meses depois de condenado por agressão,tempo,Nas Redes,2017-07-28T20:23:11+00:00,2017-07-28T20:23:11+00:00,806c54948641f4f5933a2ea4a6935dfb7a67202ab227960451d62435880289f6,https://investemoz.tempo.co.mz/rofino-licuco-preso-cinco-meses-depois-de-condenado-por-agressao,,inventariado; cadastro/versão por reconciliar
|
||||
77232,Sete indivíduos mortos a tiro na Matola,tempo,Nas Redes,2017-08-11T20:30:32+00:00,2017-08-11T20:30:32+00:00,e657a97d5d6db7fa0ea6b7d14029ff873ea5d36dfdadda5f8b9bc8f1ef5dd011,https://investemoz.tempo.co.mz/sete-individuos-mortos-a-tiro-na-matola,,inventariado; cadastro/versão por reconciliar
|
||||
62416,Sete pessoas morrem e 150 são resgatados em acidente com eléctrico na Índia,tempo,Categoria,2017-06-26T12:14:48+00:00,2017-06-26T12:14:48+00:00,0fe20a9ca9dd99c58072f43b3dece1de347ef4063ee5f75bcffe8b2f4bf253b5,https://investemoz.tempo.co.mz/sete-pessoas-morrem-e-150-sao-resgatados-em-acidente-com-electrico-na-india,,inventariado; cadastro/versão por reconciliar
|
||||
173209,Fintech,tempo,Oportunidades,2026-06-16T11:24:22+00:00,2026-06-23T10:03:06+00:00,8db9206bf5192b68e7c9bdfb87e9dc9c7063a4b7eca56630685b0e6c294f55ab,https://investemoz.tempo.co.mz/tech-fintech,,inventariado; cadastro/versão por reconciliar
|
||||
171990,Turismo,tempo,Oportunidades,2026-04-06T09:22:51+00:00,2026-06-16T13:07:59+00:00,604d30a740cc6ed20d24e3447806e733aa4aefb9f339f81d3dc57b644dd19fb7,https://investemoz.tempo.co.mz/turismo,10,RecursoQA10; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
74364,,tempo,Categoria,2017-07-26T21:20:33+00:00,2017-07-26T21:20:33+00:00,2a7f0a5b09e0136fec9eae357ac8a88a159ead23b25ed7bb735c75997b10ec5e,https://investemoz.tempo.co.mz/74364-2,,inventariado; cadastro/versão por reconciliar
|
||||
62929,Daúde Razaque já não é treinador da Liga Desportiva de Maputo,tempo,Nas Redes,2017-06-27T16:41:43+00:00,2017-06-27T16:41:43+00:00,c3b57ea685290bfb01c50403bc83db98cf1a42ad51c04d5b8d288c99b8c0a808,https://investemoz.tempo.co.mz/daude-razaque-ja-nao-e-treinador-da-liga-desportiva-de-maputo,,inventariado; cadastro/versão por reconciliar
|
||||
62649,Daviz Simango não se incomoda com possível sucessão na Beira,tempo,Nas Redes,2017-06-26T19:38:09+00:00,2017-06-26T19:38:09+00:00,090e5f41ed11c8e680d2e30ea5c8310b7c6b165ddec318a8b0cf94927f78da74,https://investemoz.tempo.co.mz/daviz-simango-nao-se-incomoda-com-possivel-sucessao-na-beira,,inventariado; cadastro/versão por reconciliar
|
||||
173470,Direitos e Deveres do Trabalhador Doméstico,tempo,Trabalho Doméstico,2026-09-15T11:28:25+00:00,2026-09-15T11:28:28+00:00,f69fd6fd8509913f2d79df0267c469dc7a9d0d30420ce8428749d53ba21446ab,https://investemoz.tempo.co.mz/direitos-e-deveres-do-trabalhador-domestico,,inventariado; cadastro/versão por reconciliar
|
||||
172679,Energia,tempo,Featured,2026-04-30T13:03:57+00:00,2026-04-30T13:28:48+00:00,e2a7eb4be0a2cf4139737f6428c147ac35de99899478ce79195dc5e1b0a960f3,https://investemoz.tempo.co.mz/energia | https://investemoz.tempo.co.mz/energia/,8,RecursoQA8; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
62739,"“Exigimos responsabilização dos agentes do crime”, Daviz Simango",tempo,Nas Redes,2017-06-26T23:13:36+00:00,2017-06-26T23:13:36+00:00,e37ea18176246dab529d2ecec7734f0fb2b0a54cadc507fbd789033d5b8e5b93,https://investemoz.tempo.co.mz/exigimos-responsabilizacao-dos-agentes-do-crime-daviz-simango-2,,inventariado; cadastro/versão por reconciliar
|
||||
62854,Governo e parceiros reforçam mecanismos de controlo de HIV/Sida,tempo,Nas Redes,2017-06-27T11:07:53+00:00,2017-06-27T11:07:53+00:00,0073816b1fa4bd50ec6417b07272da06565230d06a19079ca76cb006c882d2f1,https://investemoz.tempo.co.mz/governo-e-parceiros-reforcam-mecanismos-de-controlo-de-hivsida,,inventariado; cadastro/versão por reconciliar
|
||||
63128,Graça Machel diz que desnutrição é um problema de desenvolvimento,tempo,Nas Redes,2017-06-27T22:01:59+00:00,2017-06-27T22:01:59+00:00,711f3707e3a0d72ec919216fc50823de9060349104ee98fe0a6b20d6684b6031,https://investemoz.tempo.co.mz/graca-machel-diz-que-desnutricao-e-um-problema-de-desenvolvimento,,inventariado; cadastro/versão por reconciliar
|
||||
63181,In dependência,tempo,Nas Redes,2017-06-27T21:38:06+00:00,2017-06-27T21:38:06+00:00,b0d87c0d58d6d1343d102cd3141bda1215c5afd54f3780a1c6c262d786dbfe27,https://investemoz.tempo.co.mz/in-dependencia,,inventariado; cadastro/versão por reconciliar
|
||||
63146,"Kroll não descobriu como foram gastos 2 biliões de dólares; Governo de Nyusi não apoiou a Auditoria; criação da Proindicus, EMATUM e MAM não foi para proteção da Zona Económica Exclusiva",tempo,Nas Redes,2017-06-27T19:30:01+00:00,2017-06-27T19:30:01+00:00,4ccbad5c92ddc3884f58181a4f1ed3c1d3d6a59f005bfd99b197a6fc1fc3c280,https://investemoz.tempo.co.mz/kroll-nao-descobriu-como-foram-gastos-2-bilioes-de-dolares-governo-de-nyusi-nao-apoiou-a-auditoria-criacao-da-proindicus-ematum-e-mam-nao-foi-para-protecao-da-zona-economica-exclusiva-2,,inventariado; cadastro/versão por reconciliar
|
||||
172674,Mineração,tempo,Featured,2026-04-30T13:12:09+00:00,2026-04-30T13:30:13+00:00,8d3acc292ed806bc4cc7bcb6a3a6a5759f30b610766a5ef4b3d6a0b7c56d5861,https://investemoz.tempo.co.mz/mineracao,9,RecursoQA9; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
62688,O Instituto Nacional de Meteorologia prevê o seguinte estado do tempo para esta ...,tempo,Nas Redes,2017-06-26T18:21:18+00:00,2017-06-26T18:21:18+00:00,396fe2575641622015505eb11d4c7152b6e3e1b741966f14c0cd1673fd455fd3,https://investemoz.tempo.co.mz/o-instituto-nacional-de-meteorologia-preve-o-seguinte-estado-do-tempo-para-esta-29,,inventariado; cadastro/versão por reconciliar
|
||||
173474,Sanções Disciplinares Aplicáveis a Trabalhadores Domésticos segundo o Regulamento de Moçambique,tempo,Trabalho Doméstico,2026-09-15T11:37:04+00:00,2026-09-15T11:37:06+00:00,e09a0fe71397e2b060ff8435111117aa487b476a85ec522fffcdf9e23a6cdac2,https://investemoz.tempo.co.mz/sancoes-disciplinares-aplicaveis-a-trabalhadores-domesticos-segundo-o-regulamento-de-mocambique,,inventariado; cadastro/versão por reconciliar
|
||||
63248,Dois alunos da 9ª classe caíram nas mãos da Polícia da República de Moçambique (...,tempo,Nas Redes,2017-06-28T11:30:01+00:00,2017-06-28T11:30:01+00:00,2220ca2c07d10f10b2bd6fd0f103ee733b83570d436c751c449c85b42df46a13,https://investemoz.tempo.co.mz/dois-alunos-da-9a-classe-cairam-nas-maos-da-policia-da-republica-de-mocambique,,inventariado; cadastro/versão por reconciliar
|
||||
173463,Regulamento do Trabalho Doméstico,tempo,Trabalho Doméstico,2026-09-15T10:56:07+00:00,2026-09-15T12:41:44+00:00,eb43b6c1f097631250f0b36d3d01b4d106db2fcae07a56bb154819143fff11e9,https://investemoz.tempo.co.mz/regulamento-do-trabalho-domestico,,inventariado; cadastro/versão por reconciliar
|
||||
|
@@ -0,0 +1,93 @@
|
||||
URL,HTTP,Tipo,Título,ID legado,Autor,Categoria legado,Publicado,Modificado,Bytes,SHA256,Destino proposto,Situação
|
||||
https://investemoz.tempo.co.mz/,200,text/html,Informacao Publica,,,,,,8368,42a2f14ca41b028045e9f2b2bb70fa324feb811698d9223f913b0b002a309f89,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/agronegocio,200,text/html,Agronegocio,171985,tempo,Oportunidades,2026-04-06T09:19:07+00:00,2026-04-20T12:41:43+00:00,88030,0bcd03e0e73cdf9b0f1a3a686689ae1a5ed80e990dcc34c77c9627c32f9d156f,/agronegocio,piloto preparado
|
||||
https://investemoz.tempo.co.mz/portal-do-investidor,200,text/html,Informacao Publica,,,,,,8368,f62485a612c9c8cc5765758614fdbcaafdd77e1d7eb35139c67045f40e638689,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/robots.txt,200,text/plain,,,,,,,153,70743fc5a6acd78ea3083b92220bbd775e9c37642843fa16b7e353726e839228,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/sitemap.xml,200,text/xml,,,,,,,7606912,8470ee4038c9d4b1d722edda9044d71ad3afdfd1817c9b41680d0b910d77d011,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://contentmanager.tempo.co.mz/wp-content/uploads/2026/04/Agronegocio.png,200,image/png,,,,,,,550888,5e3840705cf98226d1f189cf665aaa98cb05da96c645159a1332a1d429d642eb,biblioteca / correspondência por rever,piloto preparado
|
||||
https://contentmanager.tempo.co.mz/wp-content/uploads/2026/04/Energia-Mineracao.png,200,image/png,,,,,,,534405,0a2c50d91dad3a6007544634540e0f61b11d3506682a5a19066bae03cdcba490,biblioteca existente recursoQA24;URLs/versão originais preservados,RecursoQA24; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://contentmanager.tempo.co.mz/wp-content/uploads/2026/04/Infraestruturas.png,200,image/png,,,,,,,363037,8e58544e49e719f9c865c91e07ef1030155a9e3d59260e6dc93e9c9e25674e39,biblioteca existente recursoQA27;URLs/versão originais preservados,RecursoQA27; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://contentmanager.tempo.co.mz/wp-content/uploads/2026/04/Mercado-Financeiro.png,200,image/png,,,,,,,708105,417b4a33468f360acef272ce3e4180c1d0a171980befa73ddb9a33de5a9d4c4e,biblioteca existente recursoQA28;URLs/versão originais preservados,RecursoQA28; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://contentmanager.tempo.co.mz/wp-content/uploads/2026/04/Turismo.png,200,image/png,,,,,,,575317,fe9a2e382e385b35f6e61ae320e199a979678677fd327fb7d4b07d8ce0111c11,biblioteca existente recursoQA26;URLs/versão originais preservados,RecursoQA26; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz,200,text/html,Informacao Publica,,,,,,8368,db5c258c7a10d2d9ec91d378c153e1da53de4eef1dfa168b2dc666ba9aea36fc,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/1530-costa-do-sol-com-dua-na-zona-de-mupulene-muito-lindo,200,text/html,15/30 costa do sol com dua na zona de mupulene muito lindo,62413,tempo,Categoria,2017-06-26T11:44:21+00:00,2017-06-26T11:44:21+00:00,46443,b9b6634f98240994fdd6a1d9f41012f9336e0a32e43a939710113802e0eca948,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/aluminio-verde,200,text/html,Alumínio “Verde”,173220,tempo,Oportunidades,2026-06-16T12:41:02+00:00,2026-06-16T12:41:06+00:00,88028,b6667ab0ed4cb3eca5ba7d9e09b829697a84ac1338d94025eada726b0b84addd,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/apple-touch-icon.png,200,image/png,,,,,,,1662,4001aa032ff113e1a268a9bbf1ab0fd9949439f9f54a85895956eb323aba977d,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/banco-mundial-classifica-de-proibitivos-juros-praticados-em-mocambique,200,text/html,Banco Mundial classifica de proibitivos juros praticados em Moçambique,73945,tempo,Nas Redes,2017-07-31T19:55:24+00:00,2017-07-31T19:55:24+00:00,63817,3b4535a181412bc95ef25725d32e997479328d6096f80046f95ca567233b1ce7,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/bens-de-consumo-franquias,200,text/html,Bens de Consumo e Franquias,173213,tempo,Oportunidades,2026-06-16T11:31:06+00:00,2026-06-16T11:32:10+00:00,88150,c7287adeaf11d2d8a40b0dc6a7b35e83ca0e2f312e2ac314caa01f166c08e3f8,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/build/assets/app-5tyHq2tj.js,200,application/javascript,,,,,,,76449,befcbe0252f450f664935a51f077030a30ae5955487f863990f204822bb7da1b,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/build/assets/app-CChIcWcY.js,200,application/javascript,,,,,,,115,81538671d6a533184b030e29c12d953c5dc584fd27e57ded6a576634e439f077,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/build/assets/app-CshAeK4J.css,200,text/css,,,,,,,219921,4c5addbbbb41752b8d8b17e1f3545ec461ec2631af24891940d3afa7347ccb91,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/build/assets/vendor-core-CDlfZzJQ.js,200,application/javascript,,,,,,,460473,fd4fdef257763f052e34a444b691d7322f49425e61e36d5fb83da8f0c340538b,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/build/assets/vendor-i18n-BY-fwNFQ.js,200,application/javascript,,,,,,,53387,7e87c342349f3e03d817f42e5f4da03a53f924839bc3f491d3cdf09d9c7f5886,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/cabo-delgado-pemba-a-capital-do-turismo-costeiro-do-norte-de-mocambique/,200,text/html,"Cabo Delgado - Pemba, A capital do turismo costeiro do norte de Moçambique",172223,tempo,Turismo,2026-04-14T10:21:20+00:00,2026-04-14T10:41:05+00:00,123385,2ea7aa8588d4055b7033c66197d819e48716fcbf9520dd39ffe1713ef75a22c2,biblioteca existente recursoQA20;URLs/versão originais preservados,RecursoQA20; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/cdn-cgi/challenge-platform/scripts/jsd/main.js,200,application/javascript,,,,,,,22552,37f21e386bbbbaf80c7d98a667bbe049a55acfaebc7902fb88e56d03d5064e1d,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/checklist-para-contratacao-e-direitos-do-trabalho-domestico,200,text/html,Checklist para Contratação e Direitos do Trabalho Doméstico,173482,tempo,Trabalho Doméstico,2026-09-15T12:36:11+00:00,2026-09-15T12:36:14+00:00,86191,5e68de9c525c7779772fd5eb6f6e9e0a36ab93ca06ca4f2a686e2f0afa6482e2,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/coloque-o-seu-numero-para-o-grupo-de-whatsapp-venda-de-acessorios-de-computador,200,text/html,"Coloque o seu número para o grupo de whatsapp, venda de acessórios de computador",76474,tempo,Categoria,2017-08-08T17:29:51+00:00,2017-08-08T17:29:51+00:00,44204,d1c7794dd9ef8b223bad657753162b6a5a6f64ce52870b05440e5d3ec961fb52,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/direitos-especiais-da-trabalhadora-domestica-no-regulamento,200,text/html,Direitos Especiais da Trabalhadora Doméstica no Regulamento,173484,tempo,Trabalho Doméstico,2026-09-15T12:38:08+00:00,2026-09-15T12:38:11+00:00,86424,b7aeb6d6143a2925a220bb899af2cac8363c542d0f1f337b72191f4994793aa6,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/energia-mineracao,200,text/html,Energia & Mineração,171981,tempo,Oportunidades,2026-04-06T09:17:46+00:00,2026-04-30T13:35:32+00:00,60048,f0d3fb494743c7dbec389fd3650754402135ceb6f2293ae6cef00fd0af93cbad,biblioteca existente recursoQA7;URLs/versão originais preservados,RecursoQA7; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/energia-recursos-naturais,200,text/html,Energia e Recursos Naturais,173222,tempo,Oportunidades,2026-06-16T12:47:16+00:00,2026-06-16T12:51:30+00:00,88150,718e69adfa50a11de8aeda60209afc0cbff1fd0c2be4bcb18ff59c43abed356a,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/favicon.ico,200,image/x-icon,,,,,,,15406,87f9c3a47246509b030080e505c103b48e93f3451ff84f0af38daa9164ce5356,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/favicon.svg,200,image/svg+xml,,,,,,,3545,242f4f8f93f5fbc6c8aeec500c9bac02dcfe68daba166d484c5cdc986c88d8ee,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/frelimo-e-zanu-pf-evocados-lacos-de-irmandade,200,text/html,Frelimo e Zanu-PF: Evocados laços de irmandade,62414,tempo,Categoria,2017-06-26T12:03:20+00:00,2017-06-26T12:03:20+00:00,47408,6b87be9796d5f1f2707ccce29f6dfc76a4c9ce0511f2e91b51333f2f799676e6,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/governo-dara-todo-o-apoio-a-pgr-no-seguimento-da-auditoria,200,text/html,Governo dará todo O apoio à PGR no seguimento da auditoria,62418,tempo,Categoria,2017-06-26T12:30:37+00:00,2017-06-26T12:30:45+00:00,49598,b771655708e07cecb4da3fba87c9474c5e5eb5c47ed2e8d8b5fba2cbc99440d6,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/governo-pretende-construir-100-mil-habitacoes-em-dois-anos,200,text/html,Governo pretende construir 100 mil habitações em dois anos,75002,tempo,Nas Redes,2017-08-04T20:01:55+00:00,2017-08-04T20:01:55+00:00,63563,3dbfba8e5a5df2b6337f623b45927031861d0843c12fd13109191fe74a1be790,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/guia-pratico-para-empregadores-sobre-contratacao-e-direitos-no-trabalho-domestico,200,text/html,Guia Prático para Empregadores sobre Contratação e Direitos no Trabalho Doméstico,173478,tempo,Trabalho Doméstico,2026-09-15T12:30:34+00:00,2026-09-15T12:30:37+00:00,84088,de793ec332602fbf9bd6310a4d13be43b6a2064d5adc03913a5c27069f2b50fd,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/android-chrome-512x512.png,200,image/png,,,,,,,15124,5cf3fb02d85cd40d2e88fb75749070fc981a9346c6419b2f9910e2f7f6961121,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/tempo-background.png,200,image/png,,,,,,,10636,28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa,biblioteca existente recursoQA25;URLs/versão originais preservados,RecursoQA25; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/imobiliario,200,text/html,Imobiliário,173218,tempo,Oportunidades,2026-06-16T11:34:45+00:00,2026-06-16T11:34:49+00:00,87978,c044ba5246951dfd1fff8d47db000dc6eba2e29b98b900922ac1cce9ed3467e6,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/infraestruturas,200,text/html,Infraestruturas,171992,tempo,Oportunidades,2026-04-06T09:25:07+00:00,2026-04-30T12:44:37+00:00,88068,fd5df9e47e8e427ca0ce2e558d3fed29ff1ab0a2e5886779fb375329e84d77a2,biblioteca existente recursoQA22;URLs/versão originais preservados,RecursoQA22; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/maputo-cidade-plataforma-estrategica-de-turismo-negocios-e-investimento-sustentavel/,200,text/html,"Maputo Cidade – Plataforma Estratégica de Turismo, Negócios e Investimento Sustentável",172240,tempo,Turismo,2026-04-14T10:58:53+00:00,2026-04-14T11:03:28+00:00,123599,2d5d06531402bab1dd276669b78cce500cb903f6e540f886b832f6856f0d35cc,biblioteca existente recursoQA16;URLs/versão originais preservados,RecursoQA16; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/mercado-financeiro,200,text/html,Mercado Financeiro,171994,tempo,Oportunidades,2026-04-06T09:27:00+00:00,2026-04-17T09:53:42+00:00,87723,3cf0ac4e7acc897d842272465ffc2dd12967cb5484884eccd9107dd8375c3060,biblioteca existente recursoQA23;URLs/versão originais preservados,"RecursoQA23; original/hash/ClamD/replay demonstrados. Corpo vazio na origemHTML/Inertia200;privado,sem página publicada."
|
||||
https://investemoz.tempo.co.mz/modelo-de-contrato-de-trabalho-domestico-para-pequenas-familias,200,text/html,Modelo de Contrato de Trabalho Doméstico para Pequenas Famílias,173480,tempo,Trabalho Doméstico,2026-09-15T12:34:17+00:00,2026-09-15T12:34:19+00:00,86922,93f3910de0a5ff8f3752a7d2fe73e0201d05b717cd4b37380cd584ab9ec087eb,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/niassa-o-ultimo-grande-destino-virgem-do-turismo-africano/,200,text/html,Niassa - O último grande destino virgem do turismo africano,172219,tempo,Turismo,2026-04-14T10:17:14+00:00,2026-04-14T10:43:56+00:00,126058,cad44af1c05e0d76579dbab7112b35d18938b80156fb58f4580e583fc905976f,biblioteca existente recursoQA21;URLs/versão originais preservados,RecursoQA21; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/procedimentos-para-rescisao-do-contrato-de-trabalho-domestico-segundo-o-regulamento-de-mocambique,200,text/html,Procedimentos para Rescisão do Contrato de Trabalho Doméstico segundo o Regulamento de Moçambique,173476,tempo,Trabalho Doméstico,2026-09-15T11:39:21+00:00,2026-09-15T11:39:24+00:00,85371,a76a8c9f6ef4b039a9349f52e8d2f0ce06020ce8101d2736e3c6e0b2e76c7d58,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/provincia-da-zambezia-onde-os-corredores-de-mocambique-convergem-com-a-natureza-a-cultura-e-o-turismo-sustentavel,200,text/html,"Província da Zambézia: Onde os Corredores de Moçambique Convergem com a Natureza, a Cultura e o Turismo Sustentável",172263,tempo,Turismo,2026-04-14T14:21:52+00:00,2026-04-14T14:26:27+00:00,124167,e871558095c9d08329651084994266f61afd64eb72ecf85befdd2dbe099c1915,biblioteca existente recursoQA11;URLs/versão originais preservados,RecursoQA11; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-gaza-oportunidades-mais-solidas-e-estrategicas-de-investimento-no-turismo-no-sul-de-mocambique/,200,text/html,Província de Gaza: Oportunidades mais sólidas e estratégicas de investimento no turismo no sul de Moçambique.,172233,tempo,Turismo,2026-04-14T10:52:30+00:00,2026-04-17T08:28:56+00:00,123875,baf3c60fa77742c9b88a67937f9269c95284f3d5d371319009c9af2bdb6be140,biblioteca existente recursoQA18;URLs/versão originais preservados,RecursoQA18; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-inhambane-um-destino-estrategico-para-investimento-turistico/,200,text/html,Província de Inhambane: Um Destino Estratégico para Investimento Turístico,172253,tempo,Turismo,2026-04-14T14:15:12+00:00,2026-04-14T14:25:44+00:00,123445,35829f3b9725650ad51ad7b84f149ddde2157202c17083b1faec4212f5305df6,biblioteca existente recursoQA15;URLs/versão originais preservados,RecursoQA15; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-manica-onde-o-turismo-de-montanha-natureza-e-cultura-encontra-o-proximo-ciclo-de-investimento/,200,text/html,"Província de Manica: Onde o Turismo de Montanha, Natureza e Cultura Encontra o Próximo Ciclo de Investimento",172258,tempo,Turismo,2026-04-14T14:18:28+00:00,2026-04-17T10:42:16+00:00,123841,f1ffc7cb73f68643e8ca5883c187bc7b8bb125d9f907ae190d1503d0877c9968,biblioteca existente recursoQA14;URLs/versão originais preservados,RecursoQA14; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-maputo-turismo/,200,text/html,"Província de Maputo: Natureza, Cultura e Conectividade como Motores de Crescimento Sustentável",172237,tempo,Turismo,2026-04-14T10:55:21+00:00,2026-04-17T08:27:24+00:00,123013,71887a836c75fc67b80a6a83be2a2af23b93acbd5aa353f42c75f598544ac46a,biblioteca existente recursoQA17;URLs/versão originais preservados,RecursoQA17; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-nampula-perfil-de-investimento-no-setor-do-turismo/,200,text/html,Província de Nampula - Perfil de investimento no setor do turismo,172230,tempo,Turismo,2026-04-14T10:50:32+00:00,2026-04-14T11:10:23+00:00,123337,148faca42ca44da6017fbd46e23de27fd497d36f9f8b1b695e0ea3f35474fcbb,biblioteca existente recursoQA19;URLs/versão originais preservados,RecursoQA19; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-sofala-onde-a-natureza-a-historia-e-o-futuro-do-turismo-convergem/,200,text/html,"Província de Sofala: Onde a Natureza, a História e o Futuro do Turismo Convergem",172256,tempo,Turismo,2026-04-14T14:16:47+00:00,2026-04-14T14:26:17+00:00,123519,5100b90cb6c67fb36ce171ebf04c6801fe3ead214bcff26f942f6946fcd3add0,biblioteca existente recursoQA12;URLs/versão originais preservados,RecursoQA12; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-tete-onde-o-rio-zambeze-a-cultura-viva-e-a-conservacao-comunitaria-criam-o-futuro-do-turismo/,200,text/html,"Província de Tete: Onde o Rio Zambeze, a Cultura Viva e a Conservação Comunitária Criam o Futuro do Turismo",172261,tempo,Turismo,2026-04-14T14:19:48+00:00,2026-04-14T14:26:25+00:00,123849,8e28ccbeed95208d8766bfbc57b5b4b398d5787ea5aac922daa59e596ca58511,biblioteca existente recursoQA13;URLs/versão originais preservados,RecursoQA13; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/rofino-licuco-preso-cinco-meses-depois-de-condenado-por-agressao,200,text/html,Rofino Licuco preso cinco meses depois de condenado por agressão,73246,tempo,Nas Redes,2017-07-28T20:23:11+00:00,2017-07-28T20:23:11+00:00,63685,0b8053aa5c5a8ee29648394dd7e0a84dd0eb095776c5f3d88b3b59c47f1ae233,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/sete-individuos-mortos-a-tiro-na-matola,200,text/html,Sete indivíduos mortos a tiro na Matola,77232,tempo,Nas Redes,2017-08-11T20:30:32+00:00,2017-08-11T20:30:32+00:00,63135,f903b084f891f13467121990c3deccd2b995c036e9eaa9124ebe53dc2d04c081,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/sete-pessoas-morrem-e-150-sao-resgatados-em-acidente-com-electrico-na-india,200,text/html,Sete pessoas morrem e 150 são resgatados em acidente com eléctrico na Índia,62416,tempo,Categoria,2017-06-26T12:14:48+00:00,2017-06-26T12:14:48+00:00,45244,a193cf19595a6e3de307d0307c3c96bc819d5075678e91ac9ebc5681185f4a89,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/tech-fintech,200,text/html,Fintech,173209,tempo,Oportunidades,2026-06-16T11:24:22+00:00,2026-06-23T10:03:06+00:00,88010,3b3b525c5d7ef34b5b9a1023aea5e6bc70c619672b5d173e6d9fe767782ab2d3,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/turismo,200,text/html,Turismo,171990,tempo,Oportunidades,2026-04-06T09:22:51+00:00,2026-06-16T13:07:59+00:00,87984,b7b4b2f9d9c4fc3a993e97ecdccb48f70ec9f560e13b15a9aad7205d88d22126,biblioteca existente recursoQA10;URLs/versão originais preservados,RecursoQA10; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/74364-2,200,text/html,,74364,tempo,Categoria,2017-07-26T21:20:33+00:00,2017-07-26T21:20:33+00:00,43595,2da21e3e51ccfe3a095710c85c0f549f0d995f2a2cc29bdccb1bebdee10ba805,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/cabo-delgado-pemba-a-capital-do-turismo-costeiro-do-norte-de-mocambique,200,text/html,"Cabo Delgado - Pemba, A capital do turismo costeiro do norte de Moçambique",172223,tempo,Turismo,2026-04-14T10:21:20+00:00,2026-04-14T10:41:05+00:00,123383,99a2d46fed61caf9c8313ec3a695e15608bd478177d3048c3bda7fa2281039e8,biblioteca existente recursoQA20;URLs/versão originais preservados,RecursoQA20; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/daude-razaque-ja-nao-e-treinador-da-liga-desportiva-de-maputo,200,text/html,Daúde Razaque já não é treinador da Liga Desportiva de Maputo,62929,tempo,Nas Redes,2017-06-27T16:41:43+00:00,2017-06-27T16:41:43+00:00,64169,80f4f83caa75dcef3df39d697bc9d7afd77468caea640f2bbdeda70960ef8f5d,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/daviz-simango-nao-se-incomoda-com-possivel-sucessao-na-beira,200,text/html,Daviz Simango não se incomoda com possível sucessão na Beira,62649,tempo,Nas Redes,2017-06-26T19:38:09+00:00,2017-06-26T19:38:09+00:00,64147,16981acbf74f0b85ae8abb4d4853aac8c05a68795b59913370672a4a57c9f1a1,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/direitos-e-deveres-do-trabalhador-domestico,200,text/html,Direitos e Deveres do Trabalhador Doméstico,173470,tempo,Trabalho Doméstico,2026-09-15T11:28:25+00:00,2026-09-15T11:28:28+00:00,86045,1821b7404c7761bd3ca1600fe1b0be0e63c98dc64800c30a3b228bca8dfe7ee3,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/energia,200,text/html,Energia,172679,tempo,Featured,2026-04-30T13:03:57+00:00,2026-04-30T13:28:48+00:00,59488,fe0f7fe20a980bee1f96277e25b2536eaa9941733d63fd39c8f018adcae0a6e8,biblioteca existente recursoQA8;URLs/versão originais preservados,RecursoQA8; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/energia/,200,text/html,Energia,172679,tempo,Featured,2026-04-30T13:03:57+00:00,2026-04-30T13:28:48+00:00,59490,69b5a62220e4f47037cc0c13c74a7c0c9d6b8845b260b12a317099ce4e560339,biblioteca existente recursoQA8;URLs/versão originais preservados,RecursoQA8; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/exigimos-responsabilizacao-dos-agentes-do-crime-daviz-simango-2,200,text/html,"“Exigimos responsabilização dos agentes do crime”, Daviz Simango",62739,tempo,Nas Redes,2017-06-26T23:13:36+00:00,2017-06-26T23:13:36+00:00,64225,8eb9a45e56e65febc657c4aba0ddcce77239a5d9c59902a25717a09f4572b5f5,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/governo-e-parceiros-reforcam-mecanismos-de-controlo-de-hivsida,200,text/html,Governo e parceiros reforçam mecanismos de controlo de HIV/Sida,62854,tempo,Nas Redes,2017-06-27T11:07:53+00:00,2017-06-27T11:07:53+00:00,64139,0bb91025f1790b9571ad761351ed2fbf52b4d7ee7f33c7e4bc7aee2e9c1c90b1,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/graca-machel-diz-que-desnutricao-e-um-problema-de-desenvolvimento,200,text/html,Graça Machel diz que desnutrição é um problema de desenvolvimento,63128,tempo,Nas Redes,2017-06-27T22:01:59+00:00,2017-06-27T22:01:59+00:00,64267,4c7c9a780d23a580b9f229b2a965d0f6145e5950c8d56a963047d720905a09e4,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/ANO-2017/JUNHO/21/GOVERNADOR-in.gif,404,,,,,,,,,,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/in-dependencia,200,text/html,In dependência,63181,tempo,Nas Redes,2017-06-27T21:38:06+00:00,2017-06-27T21:38:06+00:00,63079,ec3bc03878819dfb5af3c6268b4a812010d177924489ac49190351ac0b2f5bd4,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/kroll-nao-descobriu-como-foram-gastos-2-bilioes-de-dolares-governo-de-nyusi-nao-apoiou-a-auditoria-criacao-da-proindicus-ematum-e-mam-nao-foi-para-protecao-da-zona-economica-exclusiva-2,200,text/html,"Kroll não descobriu como foram gastos 2 biliões de dólares; Governo de Nyusi não apoiou a Auditoria; criação da Proindicus, EMATUM e MAM não foi para proteção da Zona Económica Exclusiva",63146,tempo,Nas Redes,2017-06-27T19:30:01+00:00,2017-06-27T19:30:01+00:00,66959,9cf92d04143df86c4cd86bf0a372696da3463b71239e4b4d737d355ff09e7e76,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/maputo-cidade-plataforma-estrategica-de-turismo-negocios-e-investimento-sustentavel,200,text/html,"Maputo Cidade – Plataforma Estratégica de Turismo, Negócios e Investimento Sustentável",172240,tempo,Turismo,2026-04-14T10:58:53+00:00,2026-04-14T11:03:28+00:00,123597,4a4a88ac0c6fdeb26bdfe7183c9b618075ceb1460875ace0b351e96a2f1dd9e9,biblioteca existente recursoQA16;URLs/versão originais preservados,RecursoQA16; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/mineracao,200,text/html,Mineração,172674,tempo,Featured,2026-04-30T13:12:09+00:00,2026-04-30T13:30:13+00:00,104140,803652cd4358d0b65a364a91ef916f6c5cbfe6bb539867e3d94b6ca36fbf10c7,biblioteca existente recursoQA9;URLs/versão originais preservados,RecursoQA9; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/niassa-o-ultimo-grande-destino-virgem-do-turismo-africano,200,text/html,Niassa - O último grande destino virgem do turismo africano,172219,tempo,Turismo,2026-04-14T10:17:14+00:00,2026-04-14T10:43:56+00:00,126056,c19557c9937180420d7ae52d28fbc3aea51e1229eefe2e05a33f576f98bcd2d0,biblioteca existente recursoQA21;URLs/versão originais preservados,RecursoQA21; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/o-instituto-nacional-de-meteorologia-preve-o-seguinte-estado-do-tempo-para-esta-29,200,text/html,O Instituto Nacional de Meteorologia prevê o seguinte estado do tempo para esta ...,62688,tempo,Nas Redes,2017-06-26T18:21:18+00:00,2017-06-26T18:21:18+00:00,64583,b1de67ebe5fefc505086bad0469295e41bf50f6f7aba7fe9e27f30220a14071f,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/provincia-de-gaza-oportunidades-mais-solidas-e-estrategicas-de-investimento-no-turismo-no-sul-de-mocambique,200,text/html,Província de Gaza: Oportunidades mais sólidas e estratégicas de investimento no turismo no sul de Moçambique.,172233,tempo,Turismo,2026-04-14T10:52:30+00:00,2026-04-17T08:28:56+00:00,123873,e2835df9992914631c34a5e3006e86277ddf135a0fc264f6ecf3811f778ac60d,biblioteca existente recursoQA18;URLs/versão originais preservados,RecursoQA18; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-inhambane-um-destino-estrategico-para-investimento-turistico,200,text/html,Província de Inhambane: Um Destino Estratégico para Investimento Turístico,172253,tempo,Turismo,2026-04-14T14:15:12+00:00,2026-04-14T14:25:44+00:00,123443,919876fd899936071aa7a56a76c357ae30370b29014db2b1e7837450a099f0bd,biblioteca existente recursoQA15;URLs/versão originais preservados,RecursoQA15; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-manica-onde-o-turismo-de-montanha-natureza-e-cultura-encontra-o-proximo-ciclo-de-investimento,200,text/html,"Província de Manica: Onde o Turismo de Montanha, Natureza e Cultura Encontra o Próximo Ciclo de Investimento",172258,tempo,Turismo,2026-04-14T14:18:28+00:00,2026-04-17T10:42:16+00:00,123839,37b9eebf01ccf49ff3832755aa91b2595bbb80729b7b7cc861558c556d3f4cb7,biblioteca existente recursoQA14;URLs/versão originais preservados,RecursoQA14; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-maputo-turismo,200,text/html,"Província de Maputo: Natureza, Cultura e Conectividade como Motores de Crescimento Sustentável",172237,tempo,Turismo,2026-04-14T10:55:21+00:00,2026-04-17T08:27:24+00:00,123011,e6bbe002132682c1bbcf5cf7666dcc78dc18915c92bf298a5540db6c07935a23,biblioteca existente recursoQA17;URLs/versão originais preservados,RecursoQA17; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-nampula-perfil-de-investimento-no-setor-do-turismo,200,text/html,Província de Nampula - Perfil de investimento no setor do turismo,172230,tempo,Turismo,2026-04-14T10:50:32+00:00,2026-04-14T11:10:23+00:00,123335,704d23e4c838df4249894b550f087eb36b90797d61d26de82b829c8d2b7d444f,biblioteca existente recursoQA19;URLs/versão originais preservados,RecursoQA19; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-sofala-onde-a-natureza-a-historia-e-o-futuro-do-turismo-convergem,200,text/html,"Província de Sofala: Onde a Natureza, a História e o Futuro do Turismo Convergem",172256,tempo,Turismo,2026-04-14T14:16:47+00:00,2026-04-14T14:26:17+00:00,123517,cc0d8bead12f2e42af60cdf10fb2412756e67c2c03ddc8d91c621e31e921933c,biblioteca existente recursoQA12;URLs/versão originais preservados,RecursoQA12; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/provincia-de-tete-onde-o-rio-zambeze-a-cultura-viva-e-a-conservacao-comunitaria-criam-o-futuro-do-turismo,200,text/html,"Província de Tete: Onde o Rio Zambeze, a Cultura Viva e a Conservação Comunitária Criam o Futuro do Turismo",172261,tempo,Turismo,2026-04-14T14:19:48+00:00,2026-04-14T14:26:25+00:00,123847,70ca06168749124e2eaae340b1396ccbe183a1e5dfd348dfd93575d9b3519205,biblioteca existente recursoQA13;URLs/versão originais preservados,RecursoQA13; original/hash/ClamD/replay demonstrados. DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.
|
||||
https://investemoz.tempo.co.mz/sancoes-disciplinares-aplicaveis-a-trabalhadores-domesticos-segundo-o-regulamento-de-mocambique,200,text/html,Sanções Disciplinares Aplicáveis a Trabalhadores Domésticos segundo o Regulamento de Moçambique,173474,tempo,Trabalho Doméstico,2026-09-15T11:37:04+00:00,2026-09-15T11:37:06+00:00,86561,6af9a928c826dd6a756310c0f948cb0db8c1b8bd98ca15577faba528ba6f3cf3,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/dois-alunos-da-9a-classe-cairam-nas-maos-da-policia-da-republica-de-mocambique,200,text/html,Dois alunos da 9ª classe caíram nas mãos da Polícia da República de Moçambique (...,63248,tempo,Nas Redes,2017-06-28T11:30:01+00:00,2017-06-28T11:30:01+00:00,64573,be57946f86277610a1597e73eaedeaf55941de76aaef3f8eda77e3a92672a6f0,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/regulamento-do-trabalho-domestico,200,text/html,Regulamento do Trabalho Doméstico,173463,tempo,Trabalho Doméstico,2026-09-15T10:56:07+00:00,2026-09-15T12:41:44+00:00,93646,6cafa4cc6a5a327de0ca4c5d98fce5e10bf7152c1dc987aa42350034fd747e1c,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/build/assets/welcome-DyF43GB5.js,200,application/javascript,,,,,,,19506,c94067f98b9d24cc457a20ed759f6beec1dbf9675354299f6141ed6056e95ab0,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/assets/logos/1_COR.png,200,image/png,,,,,,,25390,73c30a98a87960fc388d33aaf9ed3a15c3123338f8e82e84c3538adb6d816c9c,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/hero/hero-1.png,200,image/png,,,,,,,2315768,3c9246169025b9b1b4d1d10fb51c1f644123984bde56c6c2d9bf869d851024fc,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/entidades/APIEX.jpg,200,image/jpeg,,,,,,,240665,e04e4da3a1478b34a14e1d92dbebdea5bdf9bfd645c942d785b184d63dc58c48,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/entidades/BM.png,200,image/png,,,,,,,369795,3a8deddcf90f2376e8fe28447724739ce57da64b26e6887ddade566871deeea6,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/entidades/BM.jpg,200,image/jpeg,,,,,,,15681,bb93f6bb51104d4fe9d0776a79bbf3b2978f31c6802884e6f0fc3d50056268ff,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/entidades/bvm.jpg,200,image/jpeg,,,,,,,296992,8d3e532137896c9319bab4422e836bd1232fa81babf4626efa8c411927052731,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/entidades/BMM.png,200,image/png,,,,,,,199364,a92c6ed17cf8769d97a0e70c5db31cc6de1b583a71e161b8b35a7eae21468122,biblioteca / correspondência por rever,inventariado; não importado
|
||||
https://investemoz.tempo.co.mz/images/hero/hero-3.jpg,200,image/jpeg,,,,,,,9875137,91377213b6286aca7ee4af90860a7dc7a75fa08a165ef7b3192081db43f92dff,biblioteca / correspondência por rever,inventariado; não importado
|
||||
|
@@ -0,0 +1,212 @@
|
||||
<!-- qa-visual-results-20261010:begin -->
|
||||
## Ensaios visuais e pendências atuais — 2026-10-10T17:57:54.604301+00:00
|
||||
|
||||
Trabalho exclusivo na QA da porta 18097, base `desk_restore_20261009_174726_test:55441`. Nenhuma alteração na base ativa. A mesma matriz mantém **3393 requisitos / 3393 linhas / 6845 ocorrências de origem**: 3235 parciais, 145 não implementados, 5 dependentes de decisão, 1 implementado mas não ativado e 7 concluídos e demonstrados. Os estados globais não mudaram; foram atualizados subcritérios com provas próprias. A ausência de requisitos globalmente classificados “com falha” não significa ausência de casos abertos.
|
||||
|
||||
| Requisito | Demonstrado em QA | Critérios ainda pendentes |
|
||||
| --- | --- | --- |
|
||||
| C37; UI01; PROFILE24–25 | MFA manual QA18 aceite às 17:40:21 e 17:46:42 UTC; autenticação persistida e dashboard 200. Responsável confirmou acesso. O 403 inicial era do relatório administrativo reservado. Mensagem e retorno ao painel corrigidos; Chromium QA21 provou recusa 403, retorno e F5 sem perder sessão. | F5, logout e novo login no navegador habitual QA18. Não declarar o loop anterior resolvido por inferência: o rastreio não demonstra um ciclo persistente. Uma atualização Inertia 409 isolada não é perda de autenticação. |
|
||||
| A/B; GUIDE01; SIM02; DESK01–02; CRM01 | Chromium desktop QA18: criação → gravação → nova sessão → recuperação → aplicabilidade → ferramenta → resultado no dossier → preparação no Balcão → acompanhamento interno → nova recuperação. Cinco fases e 19 etapas preservadas. A dispensa S10; B aguarda informação externa em S10 enquanto S02 avança. Resultado: equilíbrio de 20 unidades e défice A=0 / B=70.000 MZN. QA23 recebe 403/404; gestor não vê resultados financeiros privados. | Balcão demonstrado em preparação DRAFT, sem submissão ou decisão externa. Restantes critérios especializados, fiscalidade, documentos e consumidores não encerrados. Repetição final A/B móvel ainda necessária. |
|
||||
| DASH01; PROFILE05/32 | QA21, a partir do dashboard: “Minhas oportunidades” e “Nova oportunidade” → rascunho incompleto #50 → guardar → nova sessão → recuperar → editar. Promotor solicita arquivo; Publicador QA25 arquiva com fundamento; estado recuperado. | Restauração por perfil ausente no workflow. Definir e implementar ator/regra coerentes sem conceder arquivo direto ao Promotor por inferência. Workflow e contratos especializados ainda exigem demonstrações próprias. |
|
||||
| COUNTRY01–04/06/07 | Residência PT guardada no perfil e recuperada após F5. Negócio e fundiário DRAFT: país PT / origem FR guardados e recuperados. Balcão A/B: PT / ZA recuperados noutra sessão. Quatro formulários públicos apresentam 249 países cada. Gestão: filtros ZA/unknown, quatro manifestações sem origem e zero pedidos submetidos ZA, com rascunhos excluídos. | Nos formulários públicos, a presença de opções não prova submissão ou recuperação. Concluir cada gravação e estatística positiva de origem conhecida no UI. Nos rascunhos de negócio/fundiário, F5 não substitui nova sessão. |
|
||||
| GEO01–03 | Oportunidade própria QA21 #51 e criação de projeto, cluster e corredor: Cabo Delgado registado → API com uma geometria acessível → um polígono SVG visível em cada consumidor. | Seleções desse ensaio não guardadas. Recuperar relações por consumidor e testar descendentes, restantes mapas e perfis. Tiles externos bloqueados no ensaio, sem confirmação da base externa. 408 associações anteriores e 173 pendentes preservadas; Mandie/Guro ambíguo. |
|
||||
| LEG02/03/06/07; C77–C83 | Corrigidos landmarks, ARIA, hierarquia de títulos, reflow, foco das tabelas e idioma HTML pt-MZ em QA. Quinze execuções dirigidas de axe, em 320/390/1440px, sem violações automáticas ou overflow. Três páginas com skiplink primeiro e tabelas financeiras operadas por teclado. | Cobertura integral, leitor de ecrã, dispositivos reais, mais perfis e editor C74. O dossier QA21 verifica o componente móvel corrigido, sem substituir a repetição final A/B. |
|
||||
| DOC01; C84 | Retirada mensagem global falsa de ClamD pendente; scan e motor apresentados por ficheiro/versão. Os 22 scans reais anteriores permanecem demonstrados. | PDFs, exportações, partilhas, derivadas e operações restantes exigem provas próprias. Um original limpo não comprova a derivada. |
|
||||
| OPS01 | Backup QA 2026-10-10T17:48:11+00:00: 295 tabelas e 103 ficheiros restaurados. Ativo READ ONLY, backup 2026-10-10T17:48:52+00:00: 288 tabelas restauradas numa base QA distinta e cinco ficheiros conferidos no arquivo. Instalador/publicação: 9 testes / 93 asserções, além de TypeScript. | Aplicação, compatibilidade e reversão da futura revisão candidata no clone fresco do ativo. Revisão candidata global e ativação continuam por fixar. Contagens de testes não substituem critérios funcionais. |
|
||||
|
||||
**C80 corrigido:** a resposta tardia do GET do rascunho podia substituir preenchimento já iniciado. Edição e avanço ficam bloqueados até a recuperação terminar; o retorno exige o snapshot completo guardado. Chromium com resposta deliberadamente atrasada comprovou bloqueio, recuperação e libertação. A/B recuperaram os inputs corretos noutra sessão, preservando versões anteriores.
|
||||
|
||||
**Recusa manual do mapa às 17:52:33 UTC:** o pedido à oportunidade 51 foi feito com QA18, não QA21; recebeu 403 com sessão preservada. É uma prova de autorização, não de apresentação do mapa. Usar QA21 (`profiles-v1-promoter@example.invalid`) para esse consumidor; o Chromium anterior QA21 recebeu 200. Nenhuma permissão alargada para contornar a recusa.
|
||||
|
||||
**Provas privadas sanitizadas:** `investor-ab-chromium-20261010.json`, `promoter-dashboard-chromium-20261010.json`, `consumers-mobile-accessibility-chromium-20261010.json`, `visual-followup-chromium-20261010.json`, `qa-report-access-chromium-20261010.json`, `c37-qa18-post-mfa-destination-20261010.json`, `visual-persistence-qa.json`, `profile-installation-backup-qa.json` e `virtual-desk/backup-proof.json`. A tentativa antiga de QA21 na oportunidade 30 recebeu 403 e não contou como mapa; foi substituída pelo consumidor próprio 51. Tentativas e falhas de harness preservadas; sem códigos, segredos ou cookies nas evidências entregáveis.
|
||||
|
||||
Mercado Financeiro permanece privado, sem corpo inventado; rotas públicas 404. Energia e Mineração mantêm artigos distintos e Turismo não criou associações geográficas automáticas. Fontes oficiais aceites não foram reabertas. Próximo lote: C37 manual, restauração por perfil, submissão/estatística por formulário, gravação/recuperação territorial, documentos, Response/§27 e workflow. Perfis modulares/SLA continuam decisões separadas; não iniciar novas jornadas educativas.
|
||||
|
||||
As versões históricas abaixo ficam preservadas. Afirmações antigas sobre falta de navegador, A/B não testados ou ClamD global pendente são superadas apenas pelos subcritérios acima. Código preparado, QA demonstrada e disponibilidade ativa são estados distintos.
|
||||
<!-- qa-visual-results-20261010:end -->
|
||||
|
||||
# Legado Tempo — piloto de agronegócio em QA
|
||||
|
||||
<!-- qa-c71-sectors-result-20261010:begin -->
|
||||
## Resultado atual — 2026-10-10T16:23:25.695297+00:00
|
||||
|
||||
**QA18097 recuperada; nenhuma alteração na base ativa.** C71 técnico corrigido: faltavam26 ficheiros de catálogos partilhados, incluindo `global/2964`, necessários ao `pg_dump`. O agente/processo que os removeu continua desconhecido. O cluster danificado foi preservado e substituído por cluster novo em diretório estável, apenas no alvoQA55441. As 295 tabelas/34.902 linhas e 31 sequências iniciais conferiram; schema/owners/ACL/triggers equivalentes após normalização explícita de 2 constraints varchar/text e slots de colunas removidas. A cópia física retida confere em 75575 ficheiros por nomes, tamanhos ehashes; isso não afirma restauro lógico de todos os bancos históricosQA.
|
||||
|
||||
**Backup/restauro posterior ao lote:** 2026-10-10T16: 22:44+00:00,295 tabelas e 103 ficheiros privados conferidos em `profile_restore_20261010_162229_test:55441`. APP_KEY, MFA e contas preservados. Não é prova de backup da base ativa nem fixa candidata global. QA/login voltou aHTTP200; manutenção arquivada. 2FAS Auth continua compatível com TOTP; a rejeição manualQA18 registada foi de palavra-passe antes do MFA, sem justificar troca de aplicação.
|
||||
|
||||
| Requisito / caso | Evidência deste lote | Lacuna concreta |
|
||||
| --- | --- | --- |
|
||||
| AUD-OPS-01 / C71 | Cluster saudável, dados/sequências/permissões preservados, backup/restauro renovado,0escritasativas. Estado global passa de com falha para parcial. | C37/QA18,A/B e outros consumidores; candidata e backup ativo/autorização própria. |
|
||||
| AUD-LEG-01/05/07 |22 ficheiros (17 artigos+5 imagens) importados privadamente, hashes recuperados,22 scans ClamD reais; replay sem duplicações/eventos/scans novos. |32 das 50 versões observadas continuam não importadas; sitemap misto/paginação/links externos/C68 e cobertura integral. |
|
||||
| AUD-LEG-02/03/06 / C72 |16 artigos novos com texto públicos, título deInfraestruturas preservado. Chromium desktop: imagens/pesquisa/artigos→MFA21→4 dossiers→retorno→logout→nova sessão→F5/replay. HTTP e persistência separados,8 dossiers no total. | Móvel/acessibilidade/mais perfis e percursosA/B completos não encerrados. |
|
||||
| AUD-LEG-04 |Leitura pública; guardar requer login. Anónimo/QA23 sem contexto privado; QA23recebe403 ao dossier alheio. Estado de consulta/proteçãoQA mantido concluído no âmbito demonstrado. | Disponibilização ativa e demais aceitações são independentes. |
|
||||
| Mercado Financeiro |OrigemHTML e respostaInertiaHTTP200 contêm corpo vazio; exemplar22 no lote fica privado na biblioteca (recurso23). `/mercado-financeiro` e respetiva exploração404,sem página vazia apresentada como entrega. | Obter conteúdo real/versão com texto; não usar Fintech nem inventar corpo. |
|
||||
| C74 |Imagens semAssunto eram recusadas porPUT422. QA aceita tema vazio sem inferir setor; interface tornaAssunto opcional. Cinco metadados vazios guardados/recuperados; teste dedicado1/11. | Editor administrativo sem ensaio visual próprio; alteração protegida pela guardaQA. |
|
||||
|
||||
**Contagens canónicas:**3393 requisitos/3393 linhas, 6845 ocorrências de origem; 3235 parciais, 145 não implementados, 5 dependentes de decisão, 1 implementado mas não ativado, 7 concluídos e demonstrados, 0 com falha. Casos adicionais e aceitações abertas continuam visíveis dentro dos requisitos parciais;0 com falha global não significa portal sem erros. BacklogCSV é projeção reconciliada dos mesmos3393 IDs, incluindo39 anteriormente omitidos, não outra matriz.
|
||||
|
||||
**RotasQA:** `/explorar/energia-mineracao` → `/energia-mineracao`, `/energia`, `/mineracao`; `/explorar/turismo` → `/turismo` e 11 artigos territoriais; `/explorar/infraestruturas` → `/infraestruturas`. Agronegócio preservado. Energia/Mineração são fontes distintas; Turismo mantém texto editorial e 0associações territoriais automáticas. Todos os dados de fonte/autoria/datas/hash/URLs/versões originais são conservados. Os artigos não criaram oportunidades (49antes/depois), geometrias ou perfis. Divulgação foi uma ação autenticada do gestorQA14 pelo mecanismo existente, separada da importaçãoCLIQA (atribuição `--actor`, não autenticação ativa); nenhum desafioMFA foi observado nessa conta. QA21/QA23 usaramMFA normalmente.
|
||||
|
||||
**Próximo trabalho executável:** restantes32 versões/acervo eC68 por ficheiro; Response/§27/validações, PDFs/derivadas epaíses/origem/estatísticas por consumidor;173 correspondências territoriais ainda abertas comMandie/Guroambíguo. Não acrescentar jornadas educativas para contornar essas pendências. Perfis modulares eSLA conservam decisões próprias. C37/QA18,A/B,móvel/acessibilidade permanecem abertos; este lote não demonstraCRUD/workflow de oportunidades nem os encerra.
|
||||
|
||||
**Ativação futura:** revisão/hashes/flags e aceitação global→leitura/backup/restauro do ativo correspondente→código/build compatíveis com flags desligadas→030000→040000→09/190000 somente se ausentes→importação privada de conteúdo/cartografia com prévia e scan→divulgação separada→disponibilização incremental. QA já tem as 3 instalações;não as repetir. Previsão desta extensão: 22 originais (reutilizar hashes existentes),21 potencialmente divulgáveis e 1 exemplar vazio privado;0 contas/dossiers/opportunities/geometrias transferidos. O CLIrecusa aplicação ativa: falta preparar/demonstrar uma ação autenticada emlote que preserve exatamente as identidades/versionamento existentes; upload unitário não foi provado equivalente. Cartografia:408 candidatas/173 pendentes no retratoQA anterior, não importadas por esta frente. `ready=true` continua preparação, não instalação/disponibilização.
|
||||
|
||||
**Reversão:** retirar divulgação em ações auditadas e ocultar consumidores/flag, preservando originais, versões, consentimentos/referências eeventos. Restauro técnico usa o novo dump íntegro e chave estável numa QA distinta,comparação antes de corte;não voltar ao cluster danificado. Nunca drop de tabelas utilizadas nem copiar fixturesQA para o ativo. Provas: `legacy-sectors-{apply,replay,http,browser,persistence}-qa.json`, `legacy-financial-origin-20261010/proof.json`, `portal-qa-recovery/20261010_154445/*-proof.json` e `profile-installation-backup-qa.json` (privadas, sem credenciais/cookies/códigos nas capturas).
|
||||
|
||||
**Versões históricas preservadas abaixo:** frases sobreC71aberto,22 ficheiros apenas preparados ou rotas apenas deagronegócio descrevem os incrementos anteriores e são substituídas, para o estado atual, pelas provas desta secção.
|
||||
<!-- qa-c71-sectors-result-20261010:end -->
|
||||
|
||||
<!-- legacy-manual-sectors-20261010:begin -->
|
||||
## Confirmação manual recebida do responsável — 10/10/2026
|
||||
|
||||
Confirmado nesta conversa: **leitura pública → autenticação → criar/escolher dossier → guardar referência → nova sessão → recuperar dossier e abrir artigo**. Conta, perfil, dispositivo e hora de execução não foram indicados; não os presumir. “Criar/escolher” conserva o relato do responsável, sem declarar que ambas as alternativas foram ensaiadas separadamente.
|
||||
|
||||
| Critério confirmado manualmente | Correspondência na matriz existente |
|
||||
| --- | --- |
|
||||
| Leitura pública e autenticação ao entrar no percurso privado | AUD-LEG-03 e AUD-LEG-04 |
|
||||
| Criar/escolher dossier e guardar referência | AUD-LEG-03, AUD-LEG-06; gravação/consumidor em AUD-LEG-07 |
|
||||
| Nova sessão, recuperação do dossier e abertura do artigo | AUD-LEG-03, AUD-LEG-06 e AUD-LEG-07 |
|
||||
|
||||
Só estes subcritérios receberam a confirmação manual. Nenhum estado global foi alterado, nenhuma API/persistência recebeu prova técnica adicional por inferência e nenhuma linha foi acrescentada à matriz. O relato manual fica separado das provas Chromium/API/READ ONLY anteriores.
|
||||
|
||||
**Continuam pendentes:** ensaios manuais móveis, acessibilidade, outros perfis e cobertura integral do legado. As observações automáticas anteriores de viewport/teclado/QA23 ficam preservadas como provas limitadas; não constituem essas aceitações manuais. C37 da conta18 e os cenários A/B completos não foram encerrados. A demonstração não renova backup/restauro nem resolve C71.
|
||||
|
||||
## Reconciliação dos quatro restantes setores — preparação, sem importação
|
||||
|
||||
As 92 entradas do inventário existente foram conferidas antes da seleção. Os quatro artigos de entrada foram novamente recolhidos por leitura anónima: HTTP200 e corpo/data de modificação iguais ao exemplar inicial. Os artigos descendentes e imagens usam os snapshots já inventariados, com hash conferido. Sitemap misto, paginação e restante acervo continuam abertos.
|
||||
|
||||
| Fonte legada | Identidade e cadastro existente | Conteúdo e lacuna concreta |
|
||||
| --- | --- | --- |
|
||||
| [Energia & Mineração](https://investemoz.tempo.co.mz/energia-mineracao) | 171981 / setor QA2 (`energia`) | Índice com ligações explícitas a Energia172679 e Mineração172674; preservar as três identidades e os setores existentes2/8/7. |
|
||||
| [Turismo](https://investemoz.tempo.co.mz/turismo) | 171990 / setor QA3 (`turismo`) | Artigo setorial e 11 artigos territoriais explicitamente ligados; categoria Turismo preservada, sem inferir geometrias. |
|
||||
| [Infraestruturas](https://investemoz.tempo.co.mz/infraestruturas) | 171992 / setor QA4 (`infraestruturas`) | Artigo integral; C72 removia um título h1. Normalização h1→h2 preparada e conferida sobre o original. |
|
||||
| [Mercado Financeiro](https://investemoz.tempo.co.mz/mercado-financeiro) | 171994 / setor QA6 (`mercado-financeiro`) | Corpo vazio no exemplar atual. Arquivar metadados/original; não inventar texto nem substituir por Fintech. |
|
||||
|
||||
**Lote privado preparado: 17 versões de artigos + 5 imagens = 22 documentos.** Inclui quatro entradas setoriais, dois artigos de Energia/Mineração e 11 artigos territoriais de Turismo. A imagem de fundo genérica é um único exemplar por bytes, referenciado por vários artigos; não representa a fotografia de cada território. URLs com/sem barra são conservados como aliases do mesmo ID/versão/hash. Autoria, publicação, alteração, classificação original e HTML ficam preservados.
|
||||
|
||||
Os setores foram confrontados com a preservação READ ONLY da QA de10/10/2026 às14:54:19UTC, não com a base ativa. Correspondência2/3/4/6 para as entradas setoriais;8/7 para Energia/Mineração. Nenhum setor, identidade, artigo ou oportunidade foi criado na base. Alterações do corpo com a mesma identidade/versão deverão gerar conflito, não substituição silenciosa.
|
||||
|
||||
**Prévia real do importador existente:** 22 candidatos, zero duplicados por bytes e zero conflitos de identidade encontrados; transação READ ONLY no alvo `desk_restore_20261009_174726_test:55441`. CLI guardado, sem `--apply`, sem contas ou privilégios escolhidos. Não é aplicação/publicação autenticada. Nenhum scan novo foi executado ou declarado limpo; os scans do piloto não se estendem a este lote. C71 impede a aplicação enquanto faltar integridade/backup/restauro QA.
|
||||
|
||||
Reutilização concreta: biblioteca `documents`/`portal_resources`, importador `documents:import-batch`, verificador de binários, `portal_resource_links`, `LegacyContent`, controlador e componente `portal/legacy`, referências cifradas/eventos do dossier. Rotas de leitura/entrada, identidade do artigo e rótulos do componente ainda estão fixos em agronegócio: parametrizar esses pontos antes de disponibilizar outras páginas. As URLs legadas não são rotas QA disponíveis por estarem nesta tabela. Não criar um cadastro de conteúdo paralelo.
|
||||
|
||||
**C72 — código preparado:** o sanitizador removia o texto do h1 interno de Infraestruturas. A correção transforma-o em h2 e aplica a limpeza normal dos seus filhos/atributos. Os 17 originais conservaram hash; transformação e remoção de markup ativo verificadas sem bootstrap de aplicação ou acesso à base. Resultado não equivale a apresentação ou percurso dos novos setores demonstrado em QA.
|
||||
|
||||
Próximo incremento, após recuperar C71: prévia renovada no alvo e aplicação como rascunhos privados → ClamD por ficheiro → metadados/relações por citação e divulgação separada → parametrizar adaptador/componente e URLs preservadas → ensaios públicos e de dossier por setor. Mercado Financeiro mantém corpo vazio visível/identificado até existir conteúdo real; a consulta/cadastro do exemplar não exige uma relação a módulo/regra. Nenhuma importação ativa ou cálculo fiscal é habilitado.
|
||||
|
||||
Provas privadas: `legacy-sector-reconciliation-20261010/reconciliation.json`, `import-preview.json`, `content-check-before.json`, `content-check-after.json`; manifesto de staging `legacy-setores-20261010/manifest.json`. Inventários únicos atualizados: `legado-inventario.csv` e `legado-conteudos-reconciliados.csv`. Permanecem92 URLs/50 versões de artigos;22 entradas reconciliadas e17 versões preparadas neste incremento, sem omitir as restantes.
|
||||
<!-- legacy-manual-sectors-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-browser-20261010:begin -->
|
||||
## Verificação do piloto na interface — 10/10/2026, exclusivamente QA
|
||||
|
||||
**O piloto existente foi demonstrado em Chromium instalado**, com contextos separados, sem usar o perfil pessoal do utilizador. O controlo nativo continua indisponível (`native pipe`, os error 2, duas sondagens); esse problema não impediu renderização, cliques e capturas por Playwright. C37/QA18 permanece pendente; este ensaio usou Promotor QA21 e outra conta sintética QA23.
|
||||
|
||||
Página pública→pesquisa com/sem resultado→artigo→carteira/mapas/biblioteca/oportunidade publicada→CTA→login/MFA da conta QA21→dossier guardado→fonte/retorno→logout→novo contexto/MFA→recuperação e F5→continuação/repetição passaram. Leitura anónima e QA23 não recebem o título/ID do contexto privado; dossier negado 403 à QA23. A criação na interface consta da tentativa inicial preservada; as repetições recuperam o mesmo dossier sem criar outro. Cinco fases / 19 etapas observadas. Persistência em transação READ ONLY: uma referência, um evento, um documento por hash; originais e scan preservados; projeto independente com zero referências.
|
||||
|
||||
Desktop 1440 px / móvel 390 px: fotografia 900×506, Noto Sans / Noto Serif carregadas, cores reais adaptadas, texto integral de 4.702 carateres, foco de 3 px por teclado e nenhum deslocamento horizontal. Sete capturas privadas. **C70 corrigido:** título de separador “Laravel” substituído por “InvesteMoz QA” apenas no build QA; bundle ativo preservado. Isto não certifica acessibilidade integral nem demonstra todas as geometrias/consumidores do portal.
|
||||
|
||||
**AUD-LEG-04 concluído e demonstrado em QA**, por consulta pública/proteção privada. AUD-LEG-01/02/03/05/06/07 continuam parciais pelas lacunas explicitadas na matriz: cobertura histórica, controlo assistivo sistemático, relações territoriais concretas, restante mapa de URLs e anexos. Nenhum I0–I3/A-B/workflow/C37 é encerrado por esta prova.
|
||||
|
||||
Recibos: `legacy-content-browser-qa.json`, `legacy-content-browser-persistence-qa.json`; testes `tools/legacy-content-browser-qa.cjs` e `tools/legacy-content-browser-persistence-qa.php`. Capturas e credenciais permanecem privadas; não foram registados Cookie, X-XSRF-TOKEN ou corpo com o código MFA. O ZIP anteriormente entregue é um snapshot anterior à verificação, não prova de execução remota.
|
||||
|
||||
**C71 / P0 — novo backup QA bloqueado:** `pg_dump` falhou às 14:48–14:49UTC: `global/2964` ausente, catálogo `pg_db_role_setting`; diagnóstico identifica 26 ficheiros de catálogos partilhados ausentes no cluster 55441, situado em Temp. Não atribuir a remoção à limpeza automática sem evidência. O backup/restauro válido de 13:35:39UTC (295 tabelas/81 ficheiros) permanece histórico. Preservação posterior READ ONLY: 295 tabelas/34.902 linhas/31 sequências; não equivale a backup/restauro verificado. Readiness QA foi invalidada e o CLI passa a devolver exit 1 controlado. Próxima ação: recuperação em ambiente isolado, preservação/conferência integral e nova prova de restauro antes de retomar escritas. Nenhum catálogo recriado às cegas nem acesso/escrita na base ativa. Ver `qa-readable-preservation.json`, `profile-installation-backup-failure-qa.json` e C71 em `casos-reproducao.md`.
|
||||
<!-- qa-legacy-browser-20261010:end -->
|
||||
|
||||
10/10/2026. Fonte pública: https://investemoz.tempo.co.mz/. Esta frente integra a auditoria existente. A autorização refere-se exclusivamente à integração do legado em QA; decisões de perfis modulares/SLA, C37 e ensaios visuais anteriores permanecem separados. Nenhuma instalação, publicação ou escrita na base ativa.
|
||||
|
||||
## Endereços e demonstração disponível
|
||||
|
||||
- Entrada pública: **http://127.0.0.1:18097/explorar/agronegocio**.
|
||||
- Artigo, mantendo o caminho do legado: **http://127.0.0.1:18097/agronegocio**.
|
||||
- Exemplar único na biblioteca: **http://127.0.0.1:18097/documentos/6**; imagem original: `/documentos/5/view`.
|
||||
- Preparar/continuar: **http://127.0.0.1:18097/investimentos/a-partir-de/agronegocio**. Só este passo exige autenticação e os dados pessoais do dossier continuam privados.
|
||||
- O menu existente recebe “Explorar agronegócio” exclusivamente em QA. Os restantes acessos e URLs permanecem disponíveis.
|
||||
|
||||
Servidor guardado: `desk_restore_20261009_174726_test`, PostgreSQL loopback **55441**; HTTP **18097**, cookie próprio do ambiente. A identidade real é verificada antes de qualquer escrita. O adaptador só funciona com flag QA e essa base isolada; uma flag não o disponibiliza na base ativa.
|
||||
|
||||
## Inventário e reconciliação
|
||||
|
||||
| Unidade | Quantidade / situação | Artefacto |
|
||||
| --- | --- | --- |
|
||||
| URLs efetivamente recolhidos | 92; 91 HTTP200 e um HTTP404 | `legado-inventario.csv`; originais e hashes no inventário privado |
|
||||
| Versões de artigos observadas | 50 agrupadas por ID legado, alteração anunciada e hash do corpo; URLs equivalentes conservados | `legado-conteudos-reconciliados.csv` |
|
||||
| Referências entre páginas/recursos | 2.826 ocorrências identificadas, incluindo destinos externos e paginação não recolhidos | `legado-referencias.csv` |
|
||||
| Sitemap anunciado | 30.170 entradas; URLs de `infopublica.tempo.co.mz`, apesar da recolha no domínio InvestMoz | `legado-sitemap-anunciado.csv`; não afirmar que essas páginas foram recolhidas |
|
||||
| Importação do piloto | Dois documentos e dois recursos existentes: artigo6 e imagem5; repetição sem novos documentos/recursos | `legacy-import-preview-qa.txt`, `legacy-import-qa.json` privados |
|
||||
| Associações do artigo | Tema agronegócio; imagem referenciada no `thumbnail` original; apoio editorial à S01 | `portal_resource_links` existentes, com citação/âmbito; nenhuma associação territorial automática |
|
||||
|
||||
O levantamento é progressivo. Não fecha a cobertura histórica do acervo: entradas do sitemap, paginação, destinos externos e conteúdos fora do piloto permanecem inventariados e por reconciliar/importar. Fontes oficiais já aceites mantêm essa decisão. O artigo Tempo é editorial: não recebe classificação oficial pelo facto de citar corredores ou políticas.
|
||||
|
||||
O domínio entrega uma SPA Laravel/Inertia: metadados “Informacao Publica”, página `welcome`, artigos `noticias/show` e uma área de investimento escolhida pela aplicação. Os resultados indexados e os metadados atuais divergem; ambos ficam identificados. Não presumir que a aplicação de leitura é WordPress porque as imagens provêm de `/wp-content/uploads/`.
|
||||
|
||||
**Lacuna do legado C68:** `https://investemoz.tempo.co.mz/images/ANO-2017/JUNHO/21/GOVERNADOR-in.gif` devolve404. Não afeta a imagem do piloto; não foi substituída nem considerada válida. Conteúdos com essa referência precisam de revisão individual na expansão.
|
||||
|
||||
**Conflitos de identidade/versão:** o importador existente compara hashes e identidade. Repetir o mesmo lote não acrescenta bytes nem recursos. Alteração do corpo com a mesma identidade exige revisão; não substitui silenciosamente o exemplar. A classificação original “Oportunidades” fica preservada no exemplar, mas o artigo é um guia na biblioteca e não cria uma linha de oportunidade.
|
||||
|
||||
## Fonte única e preservação
|
||||
|
||||
Post legado **171985**, autor **tempo**, publicado **2026-04-06T09:19:07+00:00**, modificado **2026-04-20T12:41:43+00:00**. Original de conteúdo preservado como documento JSON na biblioteca privada, sem props de autenticação, cookies ou CSRF. O corpo HTML original mantém-se nesse exemplar; o adaptador só apresenta uma projeção sanitizada. Snapshots HTTP brutos ficam exclusivamente privados e nunca são servidos.
|
||||
|
||||
- SHA256 do corpo original: `28383a11d95f13894381357d8f1227b33031d1d3ff74507da4ea8e5ce2470913`.
|
||||
- SHA256 do exemplar da biblioteca: `0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2`.
|
||||
- Imagem original `Agronegocio.png`: `5e3840705cf98226d1f189cf665aaa98cb05da96c645159a1332a1d429d642eb`, 550.888 bytes, 900×506. Autoria individual e data de criação não indicadas; não inventadas.
|
||||
|
||||
O importador prepara rascunhos privados e executa ClamD. A divulgação QA foi registada em metadados pelo gestor documental sintético14 e a publicação foi uma ação autenticada posterior. Superutilizador27 não recebeu permissão documental por acumulação de papéis. Disponibilidade pública, situação documental `not_applicable` e utilização em cálculo `false` permanecem independentes. A autorização deste piloto não habilita divulgação ativa nem cálculo.
|
||||
|
||||
O dossier conserva apenas referência ao recurso/documento, hash, versão de metadados, origem, ator e data, no payload cifrado existente. O artigo inteiro não é copiado para cada projeto. Há evento `legacy_content_referenced`; repetição da mesma fonte/versão não duplica referência, evento ou versão. A criação e associação são atómicas. Roteiros antigos não são convertidos nem renumerados.
|
||||
|
||||
## Referências visuais usadas
|
||||
|
||||
| Evidência real | Adaptação QA |
|
||||
| --- | --- |
|
||||
| CSS legado `app-CshAeK4J.css`: Noto Sans e Noto Serif; head inclui Instrument Sans | Instrument Sans do portal conservada; Noto Sans/Serif empacotadas localmente apenas no build QA do piloto, sem chamadas a fontes externas durante consulta |
|
||||
| `welcome-DyF43GB5.js`: vermelho `#ED1C24`, hover `#b22222`, verde `#10352b`, fundo `#f6f3ea` | Fundo claro e verde profundo; vermelho escuro nos controlos para contraste legível. Não copiar cores claras com texto de contraste insuficiente |
|
||||
| Hero fotográfico; cartões `rounded-2xl`, grelha móvel de uma coluna e expansão em ecrãs maiores | Imagem original, cartões sem animação obrigatória, grelha responsiva, largura de leitura limitada |
|
||||
| Imagem ligada ao post e hospedagem Tempo | Original na biblioteca, alt descritivo, dimensões declaradas e origem explícita; não afirmar que a fotografia identifica um terreno disponível |
|
||||
|
||||
Foco visível, labels de pesquisa, mensagens de pesquisa sem resultado, hierarquia de títulos, links identificados e layout móvel preparados em React. Estas condições não equivalem a inspeção visual, medição no navegador ou aceitação de acessibilidade completa.
|
||||
|
||||
## Percurso e relações
|
||||
|
||||
Página setorial → artigo educativo → oportunidades publicadas **no mesmo setor** e acesso à carteira/mapas existentes → autenticação para iniciar ou escolher dossier → referência guardada → nova sessão recupera → regressar ao artigo com contexto autorizado → continuar o mesmo dossier.
|
||||
|
||||
A QA tem uma oportunidade publicada nesse setor, independente do artigo. Os links públicos da carteira, mapas, biblioteca e ficha dessa oportunidade respondem200. O artigo menciona **Pemba–Lichinga, Nacala, Vale do Zambeze, Beira, Limpopo e Maputo**. Os corredores cadastrados incluem designações como “Nacala e Norte”, “Beira e Centro”, “Maputo–Limpopo” e “Zambeze–Macuse”; nome semelhante não demonstra igualdade de âmbito. Nenhuma delimitação administrativa, geometria ou parcela foi associada por inferência. Correspondências concretas continuam pendentes e não alteram as408 geometrias QA já reportadas.
|
||||
|
||||
O contexto privado só regressa a um utilizador com acesso de escrita ao dossier; outras contas e anónimos continuam a consultar o artigo sem receber ID/título privado. Cinco fases e19 etapas preservadas. Não foram criadas novas jornadas educativas, identidades, tabelas de artigos ou oportunidades a partir do conteúdo.
|
||||
|
||||
## Provas e critérios ainda abertos
|
||||
|
||||
`legacy-content-http-qa.json`: leitura anónima; pesquisa com resultado/sem resultado; imagem e exemplar com hash; consumidor da biblioteca/carteira/mapas/oportunidade; autenticação normal do Promotor21; criação e associação; logout/nova sessão MFA; recuperação; continuação de dossier já existente; referência única; projeto independente sem mistura; outra conta403; contexto privado ausente em consulta anónima/alheia. Respostas Inertia observadas como dados, sem afirmar render/cliques.
|
||||
|
||||
`LegacyContentTest`, `InvestorPathTest` e `ImportDocumentBatchTest`: oito testes/264 asserções no ensaio inicial, complementados pelos controlos HTTP específicos. Testes sintéticos usam fixture de scan, não comprovam ClamD; o lote real comprova scan/hash dos dois documentos. TypeScript, buildQA e análise estática separados. Não usar a contagem global para fechar os requisitos.
|
||||
|
||||
**Histórico do ensaio inicial, anterior ao Chromium:** `cua.getState()` devolveu zero browsers/apps e criar o navegador `iab` devolveu “Browser is not available: iab”. Não foi repetido C37. Navegador do utilizador pode executar o roteiro abaixo; registar os resultados na mesma matriz antes de qualquer fecho funcional.
|
||||
|
||||
1. Sem login, abrir **http://127.0.0.1:18097/explorar/agronegocio**. Esperado: menu QA, fotografia, pesquisa e artigo; nenhum pedido de login para leitura. Pesquisar “corredores” e depois uma expressão inexistente.
|
||||
2. Abrir “Ler o artigo”. Esperado: corpo completo, autor/datas/origem, contexto territorial explicitamente textual, uma ficha publicada do setor e links funcionais para carteira/mapas/biblioteca. Conferir móvel e teclado.
|
||||
3. Clicar “Iniciar ou continuar uma ideia”. Entrar como **Promotor QA21** com MFA normal; credenciais apenas locais. Esperado: seleção dos seus dossiers e formulário com consentimento explícito, fonte preservada. C37 permanece um ensaio separado.
|
||||
4. Criar um dossier sintético. Esperado: referência ao artigo, cinco fases/19 etapas, próxima ação existente; sem transformar artigo em oportunidade ou escolher terra/financiamento automaticamente.
|
||||
5. Fazer logout; nova sessão/MFA; recuperar o mesmo dossier. Abrir a fonte e regressar ao dossier pelo contexto. Esperado: mesmo ID/fonte/dados, sem mistura. Numa conta alheia, o dossier é negado, mas o artigo permanece público.
|
||||
6. Voltar ao artigo e escolher um dossier existente. Esperado: referência única após guardar/repetir, evento auditado e nova sessão recupera. Registar separadamente resultado apresentado, API e persistência.
|
||||
|
||||
## Expansão, ativação e reversão
|
||||
|
||||
1. Demonstrar o piloto na interface e corrigir falhas observadas; depois reconciliar as outras quatro páginas setoriais, artigos de âmbito territorial e anexos por identidade/versão/hash. Conteúdos editoriais permanecem distintos de oportunidades concretas.
|
||||
2. Resolver relações territoriais com citações e IDs inequívocos; preservar divergências de âmbito. Reutilizar `portal_resource_links`, cadastros territoriais e mapas; não desenhar fronteiras pelo nome do corredor.
|
||||
3. Completar cobertura histórica: sitemap misto, paginação, links externos, imagem404, datas/autorias não indicadas e conflitos de edição. Não publicar os50 artigos ou30.170 entradas automaticamente.
|
||||
4. Preparar revisão candidata, mapa de URLs e importação autenticada no gestor existente. O CLI `documents:import-batch --apply` continua QA; não é uma ação de ativação. Upload JSON preparado apenas no ambiente QA, com hash/duplicados/quarentena já existentes. A disponibilização ativa exige aprovação própria e revisão da guarda QA, não apenas mudar uma flag.
|
||||
5. Preservar sequência global **030000 →040000 →190000**, só instalar o que ledger/schema da base alvo demonstrar ausente. Leitura pública depende da biblioteca/documentos, não dos perfis modulares; associação ao percurso exige Balcão+190000. Conteúdo, cartografia e disponibilização são ações diferentes.
|
||||
6. Contagem prevista do primeiro lote: **1 artigo +1 imagem**, reutilização por hash se já existirem, **3 associações fundamentadas**, **0 oportunidades criadas pelo artigo**, **0 geometrias importadas**, **0 dossiers/contas QA transferidos**. Contagens finais dependem da prévia autenticada no alvo.
|
||||
7. Antes de ativar, renovar backup/restauro da **base ativa e revisão candidata** com ficheiros privados e chave estável. O ensaio QA não o substitui. Publicar cada recurso/camada/oportunidade separadamente; sem ativar cálculos.
|
||||
8. Reversão: ocultar o piloto/desativar flag e reverter bundle compatível; retirar divulgação dos dois recursos numa ação auditada, sem eliminar originais/versões/associações históricas ou fontes já guardadas nos dossiers. Restauro de dados apenas do backup verificado, reconciliando escritas posteriores.
|
||||
|
||||
Perfis modulares e SLA continuam sujeitos a decisões próprias. C37, Promotor21 visual, A/B, workflow normativo integral, materialidade e restantes lacunas não são encerrados por este piloto.
|
||||
|
||||
## Verificação final deste lote
|
||||
|
||||
Leitura ativa renovada em10/10/2026, com transação READ ONLY: zero documentos do legado, piloto indisponível e bundle ativo inalterado. Ausentes exatamente as extensões `2026_10_09_030000_extend_opportunity_resources`, `2026_10_10_040000_create_portal_profile_authorization` e `2026_10_09_190000_prepare_investor_path`. Os190000 fiscal(03/10) eBalcão-base(07/10) instalados não substituem09/190000. Recibo `legacy-active-readonly.json`.
|
||||
|
||||
Persistência: um documento por hash; publicação e revisões documentais auditadas; ClamAV1.5.4 registado nos dois originais. Dossier criado e dossier continuado têm uma referência e um evento cada; projeto independente conserva zero referências/eventos. `legacy-persistence-qa.json`.
|
||||
|
||||
Restauro QA renovado em10/10/2026: **295 tabelas e81 ficheiros, zero ausentes**, base de restauro distinta `profile_restore_20261010_133520_test:55441`. Ensaio QA, sem substituir backup da futura base ativa/candidata. Recibo `profile-installation-backup-qa.json`. Build QA, TypeScript e análise estática passaram; oito testes específicos da biblioteca/percurso passaram, além dos ensaios HTTP por critério. Candidata não fixada nem ativação autorizada.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
<!-- 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.**
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T12:26:56+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 |
|
||||
| --- | --- | --- |
|
||||
@@ -14,7 +14,7 @@ QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_tes
|
||||
| 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.
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_123125_test:55441` → **295 tabelas com linhas/hashes iguais e 71 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.
|
||||
|
||||
|
||||
@@ -1,5 +1,108 @@
|
||||
<!-- qa-territory-resource-activation-20261010:begin -->
|
||||
## C86/C87 — efeito e reversão, sem instalação nova
|
||||
|
||||
Recursos030000, perfis040000 e percurso09/190000 continuam instalados emQA; não repetir. Este lote altera validação cruzada país/hierarquia, preenchimento/conferência e legenda do componente existente; **0 migrations,0 tabelas e0 novas contas**. Dois recursos de contexto criados apenas emQA não serão transferidos ao ativo.
|
||||
|
||||
Fingerprint atual `dab3606aaaf4adaa3e279326d606160bda7c5d2eb9beebc234303f4c810c7ac3` diverge do backupQA18:19 (`f1e99482dd5080b77782507cc351b79981daf0294269b65757c356cf432edb78`); essa prova permanece datada e não habilita a candidata atual. Não repetir testes de C37 fechado. Fixar futura revisão após critérios independentes, conferir/regenerar provas de compatibilidade/backup/restauro correspondentes e aplicar somente instalações ausentes na sequência030000→040000→09/190000 por ação autenticada. Correções, importação cartográfica e publicação continuam operações distintas.
|
||||
|
||||
Compatibilidade: schema/IDs/JSON existentes preservados; registos contraditórios antigos permanecem recuperáveis com mensagem e correção explícita, sem migração silenciosa. Reversão do lote: código/build anteriores, conservando drafts/versões/auditoria e sem desfazer schema; dados gravados pelo lote são compatíveis com as tabelas anteriores. Reversão ainda deve ser ensaiada na futura candidata/clone do ativo. Zero escritas ativas; C37 fechado não equivale a ativação ou fechoA/B.
|
||||
|
||||
<!-- qa-territory-resource-activation-20261010:end -->
|
||||
|
||||
<!-- c37-manual-close-20261010:begin -->
|
||||
## C37 encerrado — autenticação QA18 — 2026-10-10T20:26:29.902032+02:00
|
||||
|
||||
**Confirmação manual expressa do responsável registada. C37 está encerrado exclusivamente no âmbito da autenticação QA18.** Login → MFA → redirecionamento → sessão autenticada → atualização → logout → nova sessão/recuperação estão ligados às provas anteriores, sem repetir ensaios nem alterar conta, chave ou MFA. O logout manual de 20:17:15 foi confirmado pelo responsável e pelo rastreio: POST302→/, autenticação e estado persistido vazios.
|
||||
|
||||
Aceitação e hashes dos recibos: `c37-manual-close-20261010/acceptance.json`. Referências: `c37-qa18-chromium-20261010.json`, `logout-finalize-20261010/manual-qa18-logout.json`, `portal-logout-chromium-20261010.json` e recuperação do dossier em `investor-ab-chromium-20261010.json`. A prova QA21 do botão é complementar; a conta18 tem a sua própria confirmação. Horário local UTC+02; datas técnicas dos recibos conservadas em UTC.
|
||||
|
||||
O fecho **não encerra A/B, auditoria integral, móvel/acessibilidade integral, outros perfis ou ativação ativa**. Os cinco requisitos relacionados continuam parciais pelos seus outros critérios; contagens preservadas: 3393 requisitos/3393 linhas/6845 ocorrências. Menções históricas abaixo a C37/autenticação18 pendente são superadas somente por este fecho. C85 permanece corrigido em QA. Próximo trabalho independente: gravação/recuperação territorial e consistência país–hierarquia por consumidor, países/origem/estatísticas, documentos e workflow, preservando decisões próprias de perfis modulares/SLA.
|
||||
|
||||
<!-- c37-manual-close-20261010:end -->
|
||||
|
||||
<!-- qa-logout-activation-20261010:begin -->
|
||||
## Incremento C85 — revisão QA, sem ativação
|
||||
|
||||
Menus de sessão adicionados ao fingerprint; preparação anterior ligada ao hash antigo preservada. Checagens e backup/restauro QA renovados em 2026-10-10T18:19:43+00:00 para o código atual: 295 tabelas/103 ficheiros em `profile_restore_20261010_181928_test:55441`; nenhum novo recurso, migração, titular ou grant necessário para o botão. Bundle ativo permaneceu com SHA256 `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94`.
|
||||
|
||||
Correção futura: disponibilizar componente/build compatíveis, após revisão candidata e ensaios correspondentes; preservar a ordem 030000→040000→09/190000 e executar somente instalações ausentes. Não repetir instalações QA por causa do botão. Reversão do incremento UI: regressar ao bundle/componentes anteriores, preservando dados e MFA; não desfazer schema nem recuperar uma chave distinta. Backup ativo READ ONLY de 17:48:52 UTC não é aceitação da nova candidata; verificar compatibilidade e reversão no clone e renovar o retrato antes de qualquer corte autorizado. C37 manual parcialmente confirmado; sem autorização de ativação ativa.
|
||||
|
||||
<!-- qa-logout-activation-20261010:end -->
|
||||
|
||||
<!-- qa-visual-activation-20261010:begin -->
|
||||
## Ativação preparada, sem execução no ativo — 2026-10-10T17:57:54.604301+00:00
|
||||
|
||||
Leitura ativa `investemoz:5432`: flags de dashboard/perfis/percurso desligadas; migrações 030000, 040000 e **2026_10_09_190000_prepare_investor_path** ausentes. A extensão 09/190000 continua necessária e não foi substituída pelo Balcão 07/190000. As três já estão instaladas na QA; não repetir.
|
||||
|
||||
1. Concluir os critérios pendentes e fixar revisão, hashes, build e flags candidatos. O snapshot técnico atual não representa aceitação global.
|
||||
2. Backup ativo renovado em 2026-10-10T17:48:52+00:00: 288 tabelas / cinco ficheiros, restauro SQL em `desk_restore_20261010_174843_test:55441`, dados e bytes do arquivo conferidos. Preparar aplicação e reversão das migrações e compatibilidade da candidata neste clone fresco. Repetir o snapshot se dados/revisão mudarem antes do corte. A prova QA separada é 295 tabelas / 103 ficheiros em `profile_restore_20261010_174750_test:55441`.
|
||||
3. Disponibilizar código/build compatíveis com flags desligadas, preservando URLs, IDs, classificações, consentimentos, cinco fases e 19 etapas. Zero contas, dossiers ou oportunidades sintéticas transferidos.
|
||||
4. Prévia e aplicação autenticadas **030000 → 040000 → 09/190000**, apenas se ausentes e com dependências coerentes. 030000 prepara recursos versionados. 040000 instala quatro tabelas, uma coluna em users e três em opportunities; não cria titulares ou grants. 09/190000 prepara duas colunas e uma definição; não cria dossiers ou tarefas. Conferir contagens na prévia real. Bootstrap separado, exatamente dois titulares reais ainda não designados.
|
||||
5. Correções de interface, API e recuperação como lote próprio. Demonstrar C37 e dashboard por perfil; restauração de oportunidades continua por implementar e aceitar.
|
||||
6. Conteúdos: prévia/hash/identidade/versão → armazenamento privado → ClamD → recuperação/replay. Lote de 22 originais (17 artigos / cinco imagens), reutilizando hashes existentes; 21 recursos divulgáveis e um exemplar vazio de Mercado Financeiro privado. Falta preparar e demonstrar o mecanismo autenticado de lote; CLI --actor QA não é autenticação ativa. Divulgação separada não ativa regras/cálculos.
|
||||
7. Cartografia em lote independente: prévia, correspondências inequívocas, aplicação idempotente, auditoria e reversão. Retrato QA: 408 candidatas / 173 pendentes (26 distritos / 147 postos); Mandie/Guro ambíguo e ausência de polígonos de localidades no exemplar 2019. Publicação das camadas separada. Gravar/recuperar relações e demonstrar cada mapa consumidor.
|
||||
8. Habilitar consumidores e percurso incrementalmente com permissões e provas próprias. `ready=true` significa preparação, não instalação, divulgação ou conclusão funcional.
|
||||
|
||||
**Reversão:** desligar flags e retirar divulgação por ações auditadas, preservando originais, versões, IDs, documentos, consentimentos, relações e eventos. Não executar DROP em tabelas utilizadas ou copiar fixtures QA. Restauro completo exige ensaio em QA distinta e reconciliação de escritas posteriores ao snapshot antes do corte. Não alterar chave nem retirar MFA para recuperar acesso. O backup ativo verificado não comprova ainda aplicação/reversão da futura candidata.
|
||||
<!-- qa-visual-activation-20261010:end -->
|
||||
|
||||
# Plano consolidado de ativação — lote 3
|
||||
|
||||
<!-- qa-c71-sectors-result-20261010:begin -->
|
||||
## Preparação atual após recuperação e lote setorial — 2026-10-10T16:23:25.695297+00:00
|
||||
|
||||
C71 corrigido emQA: comparação integral e backup/restauro renovado 2026-10-10T16: 22:44+00:00 (295 tabelas/103 ficheiros), em `profile_restore_20261010_162229_test:55441`. Esta prova não é backup do ativo nem fixa candidata. C37/QA18,A/B,móvel/acessibilidade/outros consumidores e decisões próprias permanecem pendentes.
|
||||
|
||||
1. Concluir as aceitações da revisão; fixar código/manifesto/configuração e hashes. A guarda atual do legado continua restrita aQA; código preparado não é habilitação ativa.
|
||||
2. Conferir o alvo ativo por leitura e provar backup/restauro próprio dessa revisão, incluindo ficheiros e chave estável. O último retrato ativo anterior continua histórico.
|
||||
3. Disponibilizar código/build compatíveis com flags desligadas; preservarURLs,IDs,roteiros/consentimentos. Não copiar contas ou dossiers sintéticos.
|
||||
4. Prévia e aplicação autenticadas030000→040000→**2026_10_09_190000_prepare_investor_path**, apenas se ausentes e coerentes. QA já tem as 3: não repetir. O Balcão07/190 não substitui a extensão09/190; dependências/efeitos e bootstrap separado estão na tabela histórica abaixo.
|
||||
5. Conteúdo: prévia/hash/identidade/versão, armazenamento privado eClamD. **22candidatos do lote (17 artigos+5 imagens)**;17 artigos com texto no total incluindo piloto anterior,1 exemplar vazio privado. Reutilizarbytes/identidades já existentes; qualquer conflito fica pendente. A CLI com--actor é apenas ensaioQA, não instalação autenticada ativa. Ainda falta preparar/demonstrar um ponto autenticado de importação emlote com as mesmas identidades; upload unitário não foi demonstrado equivalente.
|
||||
6. Cartografia emlote distinto, por pais e correspondências inequívocas; retratoQA408 candidatas/173 pendentes, Mandie/Guroambíguo.0geometrias importadas nesta frente; medir novamente no alvo. Publicação das camadas separada.
|
||||
7. Divulgar os conteúdos autorizados em ação própria: previsão21recursos deste lote; Mercado Financeiro permanece privado/vazio e nunca apresentado como página concluída. QA demonstrou divulgação autenticada do gestor14, sem presumir MFA não observado. Aprovação/consulta/cálculo/decisão externa são estados independentes.
|
||||
8. Disponibilizar consumidores/percurso incrementalmente após provas;0fixturesQA transferidos. `ready=true` não instala, não publica e não fecha requisitos.
|
||||
|
||||
**Reversão:** retirar divulgação e ocultar consumidores/flags com auditoria, mantendo originais, versões, fontes dos dossiers eeventos. Preferir reconciliação/restauro numaQA distinta comdump íntegro/chave preservada antes de corte; não reativar o cluster danificado nem eliminar tabelas utilizadas. Conservados o original danificado e a cópia física de 75575 ficheiros (nomes/tamanhos/hashes iguais), sem afirmar restauro lógico de todos os históricos no novo runtime. Plano não executado na base ativa.
|
||||
|
||||
As secções abaixo preservam a evolução anterior; referências aC71aberto ou lote apenas preparado foram superadas pelas provas atuais, sem apagar dependências independentes.
|
||||
<!-- qa-c71-sectors-result-20261010:end -->
|
||||
|
||||
<!-- legacy-manual-sectors-20261010:begin -->
|
||||
## Complemento após confirmação manual — preparação QA
|
||||
|
||||
O relato manual do piloto confirma somente a sequência registada em AUD-LEG-03/04/06/07. C37/QA18, A/B, outros perfis, móvel/acessibilidade e cobertura integral permanecem pendentes. C71 continua a impedir novas aplicações QA e a fixação de candidata.
|
||||
|
||||
O lote seguinte tem previsão de **17 artigos +5 imagens**, biblioteca existente e zero oportunidades/territórios/contas criados. A prévia READ ONLY encontrou22 candidatos novos; reconfirmar contagens/identidades no alvo após recuperação. Arquivo do exemplar vazio de Mercado Financeiro não significa artigo educativo completo. Relações do Turismo são textuais/editoriais; não importam geometrias.
|
||||
|
||||
Sequência global030000 →040000 →09/190000 preservada, sem repetir instalações. Após recuperar QA: aplicação privada/scan, parametrização dos consumidores e divulgação separada; depois ensaios específicos. Ativação futura requer revisão candidata, prova da base ativa por leitura e backup/restauro próprio, além da autorização separada. Reversão conserva originais, versões, referências e eventos; retirar divulgação/desabilitar disponibilização não elimina fontes dos dossiers. Nenhuma aplicação ativa nesta frente.
|
||||
<!-- legacy-manual-sectors-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-browser-20261010:begin -->
|
||||
## Atualização após a prova visual do piloto
|
||||
|
||||
O piloto legado foi demonstrado no Chromium QA com Promotor QA21; C37/QA18, CRUD de oportunidades no dashboard e A/B completos continuam pendentes. Não fixar uma revisão candidata global apenas por esta prova. A identificação “InvesteMoz QA” é compilada somente no build QA; não transferir esse rótulo automaticamente para produção.
|
||||
|
||||
Preparação preservada: corrigir critérios globais→fixar revisão/hashes/configuração→conferir base ativa por leitura e renovar backup/restauro correspondente→código/bundle compatíveis com flags desligadas→030000→040000→09/190000 apenas se ausentes→importações privadas por prévia/hash/ClamD→divulgação por recurso/camada→disponibilização do percurso. Fonte editorial pública usa biblioteca; guardar contexto requer Balcão/09-190. Cartografia continua lote distinto.
|
||||
|
||||
Contagens do legado inalteradas: um artigo + uma imagem, três associações fundamentadas, zero oportunidades/geometrias criadas pelo artigo e zero contas/dossiers QA transferidos. Reversão: ocultar piloto/flag e usar bundle compatível; retirar divulgação em ação auditada preservando originais, versões, relações e histórico. Ensaio/restauro QA não substitui backup da base ativa/revisão candidata; nenhuma ativação autorizada.
|
||||
|
||||
C71 é agora pré-condição técnica: recuperar o cluster QA e renovar backup/restauro, retomar as escritas e repetir os percursos afetados; depois fixar candidata e preparar a prova específica do alvo ativo. O restauroQA anterior13:35 permanece histórico; readiness invalidada. A falha nova não autoriza alterar ou reiniciar a base ativa.
|
||||
<!-- qa-legacy-browser-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-20261010:begin -->
|
||||
## Histórico — complemento legado anterior ao ensaio Chromium
|
||||
|
||||
Preservar **030000→040000→190000**, verificar ledger/schema/dependências no alvo antesde aplicar; não repetir instalações. Leitura editorial usa a biblioteca existente; associação ao dossier exige Balcão+190000. Perfismodulares não são pré-condição da leitura pública.
|
||||
|
||||
Ordem deste complemento: demonstração visualQA/correções→revisão candidata/config/bundle→backup/restauro da base ativa correspondente→upload/importação autenticados como rascunhos privados→hash/duplicados/ClamD/metadados→associações fundamentadas→divulgação explícita por recurso→disponibilização pública/percurso. O CLI importador só aplica QA; a guarda atual do piloto impede ativo, mesmo com flag true. Habilitação ativa requer revisão/autorização própria.
|
||||
|
||||
Contagem prevista: **1 artigo +1 imagem**, **3 associações**, **0 oportunidades ou geometrias criadas pelo artigo**, **0 contas/dossiers QA transferidos**; reutilização se hash/ID já existirem, conflitos aguardam revisão. Cartografia é outro lote, sem publicação automática. Reversão: ocultar a flag/usar bundle compatível e retirar divulgação auditada; preservar originais, versões, fontes dos dossiers e histórico. Backup QA não substitui prova da base ativa + candidata. Decisões de perfis/SLA, C37, Promotor21 visual e A/B continuam abertas.
|
||||
<!-- qa-legacy-20261010:end -->
|
||||
|
||||
<!-- qa-response-20261010:begin -->
|
||||
**Preparação da futura ativação.** o restauro reportado é QA. Para uma futura ativação, conferir a identidade, schema, dados e configuração efetivos da base ativa para a revisão candidata, capturar hashes/manifesto dessa revisão e provar o seu backup/restauro. As provas QA ou `ready=true` não substituem essa ligação. Sequência 030000 → 040000 → 190000; C37, QA21 e A/B visual pendentes, candidata não fixada.
|
||||
<!-- qa-response-20261010:end -->
|
||||
|
||||
<!-- qa-installation-cartography-20261010:begin -->
|
||||
## Sequência reconciliada — preparada, não executada no ativo
|
||||
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
# 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.
|
||||
10/10/2026. Complemento da matriz existente, sobretudo AUD-OP-04, PROFILE-22/28/31. O texto original abaixo fica preservado como proposta histórica; o ensaio técnico QA seguinte identifica as recomendações adotadas experimentalmente, sem decisão formal de ativação. Não designa pessoas reais nem constitui aprovação ativa ou aceitação visual.
|
||||
|
||||
## Recomendações ensaiadas tecnicamente em QA — decisões próprias pendentes
|
||||
|
||||
Fonte: resposta do responsável após leitura integral desta proposta, com recomendações D-M1–M4, D-S1/S2 e D-R1. A implementação e os ensaios sintéticos decorrem da autorização QA existente. As recomendações não são compromissos operacionais aprovados; a confirmação posterior sobre o legado refere-se somente ao legado e não decide perfis/SLA. O contrato original de 110 definições e os registos anteriores permanecem intactos. As extensões usam o adaptador e o JSON canónico já existentes, sem novas tabelas de identidade ou oportunidades.
|
||||
|
||||
| Decisão | Reconciliação vigente em QA | Pendência concreta |
|
||||
| --- | --- | --- |
|
||||
| D-M1 | Aceites os quatro contratos como primeira versão QA. Aprovação funcional por competência é separada da administração de segurança; revogada nesta proposta a obrigação de ambos os aprovadores funcionais serem superutilizadores. As atribuições técnicas/jurídicas/editoriais existentes continuam aplicáveis. | Cerimónia autenticada de aprovação/ativação de versões e correspondência final das competências funcionais/governança. A dupla de segurança não é alterada ou automaticamente designada para funções de produto. |
|
||||
| D-M2 | Intenção/chamada pode ser disponibilizada com autorização de divulgação e rótulo explícito de preparação. Direito declarado atribuído exige o ato competente. | Demonstração visual e documentos reais específicos numa futura utilização autorizada; o exemplar sintético não é um ato oficial. |
|
||||
| D-M3 | Condições propostas exigem cenário, fonte e data. Compromisso assumido exige documento; indicação de um valor não comprova financiamento assegurado ou contrato. | Demonstração visual de cada modalidade e dos respetivos rótulos. |
|
||||
| D-M4 | Ideia sem subtipo aprovado continua guardável e desenvolvível; submissão exige classificação aprovada no servidor. | Classificação funcional dos subtipos reais. O subtipo `qa-test-subtype` é apenas uma fixture sintética de aceitação, não aprovação de um objeto real. |
|
||||
| D-S1 | Ausência de histórico e projeções identificadas são aceites por extensão QA v2 explícita; não exigir nem inventar demonstrações inexistentes. | Demonstrar o percurso completo na interface e manter a escolha explícita da versão. |
|
||||
| D-S2 | Obrigatoriedade por tipo e reabertura apenas das revisões afetadas por alterações substantivas. Correção ortográfica não deve reiniciar todas as revisões. | Implementar e demonstrar classificação/reutilização auditada por revisão. O comportamento anterior que invalida todas as revisões ao guardar ainda não é declarado corrigido por este lote. |
|
||||
| D-R1 | Preparar e testar recuperação em QA com identidades sintéticas e segregação. | Habilitação ativa depende da designação formal das pessoas responsáveis; sem bypass, terceiro titular ou escolha de pessoas reais. |
|
||||
|
||||
### Procura e oferta — contratos QA explícitos
|
||||
|
||||
- **Financiamento / `seeking_funding`:** projeto representado, finalidade e plano financeiro; reutiliza `total_investment`, `investment_breakdown`, `secured_financing`, `funding_sought` e moeda. Rubricas reconciliadas, assegurado ≤ total e pedido positivo ≤ necessidade remanescente. Financiamento assegurado positivo exige evidência.
|
||||
- **Financiamento / `funding_offer`:** entidade financiadora representada, capacidade oferecida positiva documentada, instrumento, critérios de elegibilidade e calendário/condições. Não aplica `pedido ≤ necessidade do projeto` e não declara um beneficiário aprovado. Não misturar o cenário de procura nesta ficha; limpar dados apenas por edição explícita, preservando histórico.
|
||||
- **Fornecimento / `purchase_request`:** comprador/requisitante é o Promotor representado; objeto, quantidade/unidade, destino, calendário, critérios de aceitação e preço total ou método de cotação.
|
||||
- **Fornecimento / `supply_offer`:** fornecedor é o Promotor representado; mantém especificação, quantidade, destino/calendarização, condições e critérios de aceitação; exige evidência da capacidade declarada. Não representa um pedido de compra nem um contrato celebrado.
|
||||
- Em ambas as modalidades comerciais, valores preenchidos conservam moeda e condições propostas/assumidas identificadas. Quantidade × preço unitário é reconciliada quando ambos e o total estão declarados; impostos ficam separados e não são calculados pela categoria.
|
||||
- Concessão distingue `intention_call` de `awarded_right`; “outros” usa `classified_other` com subtipo configurado e aprovado no servidor. O Promotor não pode definir gates ou autoaprovar um subtipo no pedido.
|
||||
|
||||
Versões novas: `concession-qa-20261010-v1`, `financing-qa-20261010-v1`, `supply-qa-20261010-v1`, `other-qa-20261010-v1`; histórico financeiro: `business-history-qa-20261010-v2`. A escolha é explícita. Rascunhos com os identificadores anteriores não são convertidos silenciosamente. A extensão é limitada por flag e identidade efetiva PostgreSQL de QA na porta 55441, ambiente de testes e base `*_test`; não pode ser habilitada na base ativa só por mudar a flag.
|
||||
|
||||
### SLA: experimento autorizado, compromissos ainda pendentes
|
||||
|
||||
Os prazos abaixo podem ser experimentados como configuração proposta em QA. Calendário, feriados, responsáveis/suplentes reais e compromissos operacionais não estão aprovados/configurados; não preencher `due_at` real por inferência. Pausas continuam por tarefa afetada, com espera externa separada e sem prometer prazo de entidade externa.
|
||||
|
||||
**Incidente comum:** proposta de triagem em um dia útil. **Incidente crítico:** resposta/triagem imediata, sem aguardar o relógio dos casos comuns; conter o risco por ação autorizada, registar motivo, alertar e apresentar próxima ação. A definição operacional de criticidade, canais e escala de responsáveis permanece por configurar; não inventar tempo de resolução ou pessoa de prevenção.
|
||||
|
||||
## Proposta original — histórico e divergências preservados
|
||||
|
||||
## Fundamento e compatibilidade
|
||||
|
||||
@@ -47,6 +80,8 @@ Regras comuns propostas: referências documentais pertencem à mesma oportunidad
|
||||
|
||||
## SLA operacional proposto — não é prazo legal
|
||||
|
||||
Os prazos abaixo são **propostas de compromissos internos de atendimento do portal**, não prazos de autoridades, financiadores ou outras entidades externas. A janela de 15 dias para correção pelo Promotor é uma proposta operacional de resposta/lembrete, não prazo legal nem motivo de rejeição automática. A espera externa é acompanhada separadamente; não prometer data de decisão externa nem consumir o relógio interno da tarefa pausada.
|
||||
|
||||
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 |
|
||||
@@ -78,3 +113,5 @@ O estado técnico legado `published` é preservado; o texto da interface também
|
||||
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.
|
||||
|
||||
O backup/restauro demonstrado refere-se exclusivamente à base QA. A preparação da futura ativação deve conferir a base ativa efetiva e a revisão candidata correspondente — código, dependências, configuração e manifesto — e verificar o backup/restauro dessa base e dos seus originais sob essa revisão. O ensaio QA não comprova esse passo futuro; a candidata permanece por fixar e a ativação continua separada.
|
||||
|
||||
@@ -1,9 +1,172 @@
|
||||
<!-- qa-territory-resource-c86-c87-20261010:begin -->
|
||||
## Lote territorial C86/C87 — 2026-10-10T20:48:06.634373+02:00
|
||||
|
||||
**C37 encerrado somente para autenticação QA18, por confirmação manual expressa. Não foi repetido.** Aceitação ligada aos recibos de logout/recuperação em `c37-manual-close-20261010/acceptance.json`. A/B, auditoria integral e ativação continuam independentes.
|
||||
|
||||
| Requisito/caso | Correção e prova QA | Limite do fecho |
|
||||
| --- | --- | --- |
|
||||
| OP02; GEO01/02; C86 | API antes aceitava PT com província MZ por200; agora422 junto a country_code, preservando versão/payload/auditoria. UI limpa somente a seleção administrativa no preenchimento ao mudar país; estrangeiro incompleto pode guardar e recuperar. Conferência deixou de exigir província/distrito MZ a Portugal. | Aplicado a recursos de localização; não encerra origem, filtros ou estatísticas das solicitações. Registos anteriores não foram migrados/corrigidos silenciosamente. |
|
||||
| GEO01/02/03 | QA21 oportunidade51: MZ→CaboDelgado→ANCUABE→guardar→nova sessão→campos e mapa recuperados→F5. Distrito `5b03104f-9c47-5542-8de3-e81b8d5fdd55`; dois limites disponíveis e dois polígonos renderizados. Segundo recurso PT recuperado sem IDs MZ. Troca de pai limpa descendentes. | Demais consumidores/projetos/clusters/corredores e níveis inferiores não encerrados. Tiles externos bloqueados no Chromium; mapa base não demonstrado. Não houve nova importação cartográfica ou inferência de parcelas/direitos. |
|
||||
| OP02; autorização | QA23 organização alheia: consulta403 e escrita403. PostgreSQL QA READ ONLY confirmou os2recursos v1 e o parentesco geográfico; ligações territoriais aceites permaneceram 0. | Outros perfis, documentos/derivadas e lifecycle/workflow conservam provas/pendências próprias. |
|
||||
| GEO03; C87 | Lista repetia “limites oficiais por verificar” mesmo para administrativos do INE. Texto agora distingue edição administrativa de parcela; fonte e edição2019-06-07 preservadas. Popup já distinguia o propósito, lista foi alinhada. Chromium390px e imagem inspecionada. | A aceitação oficial não foi reaberta. A edição não afirma atualidade jurídica ou disponibilidade de investimento. Outras camadas e geometria de parcela têm critérios próprios. |
|
||||
|
||||
Provas sanitizadas: `territory-resource-chromium-20261010.json`, `territory-resource-persistence-qa.json`, `territory-resource-map-shot-20261010.json` e regressões `location-country-*qa.json`. Suite de recursos5/71 passou antes do ajuste de conferência; regressão específica final1/20 passou depois. TypeScript passou após o seletor; compilação QA finalexit0. Contagens de testes não substituem os percursos acima. Falhas do harness (versão Inertia omitida, seletor por atributo incorreto e caixa dos nomes) foram preservadas como tentativas; não são falhas do portal. Nome oficial ANCUABE não foi renomeado.
|
||||
|
||||
Mesma matriz:3393requisitos/3393linhas/6845ocorrências; quatro requisitos recebem subcritérios, sem alteração de estados globais. Países/origem/estatísticas restantes,173 correspondências anteriores com Mandie/Guro ambíguo, documentos, Response/§27, workflow/restauração e decisões modulares/SLA permanecem abertos. Não iniciar novas jornadas educativas.
|
||||
|
||||
**Código preparado e demonstração QA, sem disponibilidade ativa.** Zero migrações/instalações novas e zero escritas ativas. Manifesto ativo SHA256 `75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94` intacto. O backup/restauro QA18:19 liga-se ao fingerprint anterior; não foi apresentado como prova da revisão nova. Para futura candidata: renovar verificações/backup/restauro associados à revisão, testar compatibilidade/reversão no clone do ativo e preservar **030000→040000→09/190000**, sem repetir instalações QA. O backup ativo READ ONLY17:48 permanece histórico; nenhum corte autorizado.
|
||||
|
||||
<!-- qa-territory-resource-c86-c87-20261010:end -->
|
||||
|
||||
<!-- c37-manual-close-20261010:begin -->
|
||||
## C37 encerrado — autenticação QA18 — 2026-10-10T20:26:29.902032+02:00
|
||||
|
||||
**Confirmação manual expressa do responsável registada. C37 está encerrado exclusivamente no âmbito da autenticação QA18.** Login → MFA → redirecionamento → sessão autenticada → atualização → logout → nova sessão/recuperação estão ligados às provas anteriores, sem repetir ensaios nem alterar conta, chave ou MFA. O logout manual de 20:17:15 foi confirmado pelo responsável e pelo rastreio: POST302→/, autenticação e estado persistido vazios.
|
||||
|
||||
Aceitação e hashes dos recibos: `c37-manual-close-20261010/acceptance.json`. Referências: `c37-qa18-chromium-20261010.json`, `logout-finalize-20261010/manual-qa18-logout.json`, `portal-logout-chromium-20261010.json` e recuperação do dossier em `investor-ab-chromium-20261010.json`. A prova QA21 do botão é complementar; a conta18 tem a sua própria confirmação. Horário local UTC+02; datas técnicas dos recibos conservadas em UTC.
|
||||
|
||||
O fecho **não encerra A/B, auditoria integral, móvel/acessibilidade integral, outros perfis ou ativação ativa**. Os cinco requisitos relacionados continuam parciais pelos seus outros critérios; contagens preservadas: 3393 requisitos/3393 linhas/6845 ocorrências. Menções históricas abaixo a C37/autenticação18 pendente são superadas somente por este fecho. C85 permanece corrigido em QA. Próximo trabalho independente: gravação/recuperação territorial e consistência país–hierarquia por consumidor, países/origem/estatísticas, documentos e workflow, preservando decisões próprias de perfis modulares/SLA.
|
||||
|
||||
<!-- c37-manual-close-20261010:end -->
|
||||
|
||||
<!-- qa-logout-results-20261010:begin -->
|
||||
## Logout disponível e confirmação manual — 2026-10-10T18:20:44.687863+00:00
|
||||
|
||||
**C85 corrigido em QA; nenhuma alteração no ativo.** A ação existia no menu de utilizador do dashboard, mas não na navegação do investidor. O cabeçalho comum mostra agora a conta atual e **Terminar sessão**, sem obrigar a regressar ao dashboard. Reutiliza POST `/logout` do Fortify e limpa o cache Inertia; chaves, MFA, perfis e dados guardados foram preservados.
|
||||
|
||||
O responsável confirmou o botão e o logout. Rastreio QA18 às **2026-10-10T18:17:15+00:00** (20:17:15 em Moçambique): POST logout302→/, conta18→sem autenticação e estado persistido vazio. C37 recebe apenas esse critério; F5 explícito e novo login/MFA QA18 após a saída continuam pendentes. O GET/login anterior com conta autenticada não era um novo login. A declaração geral de testes efetuados não substitui evidências dos restantes critérios.
|
||||
|
||||
Chromium QA21, contextos isolados: ação visível em investimentos, dossier, artigo e simulador; logout direto do dossier em desktop e 320px; API privada401 e página privada→login; nova sessão com MFA normal recuperou case/results/steps/tasks com hash igual e F5 manteve acesso. Seis verificações de cabeçalho em 320/390/1440px sem violações automáticas ou overflow; foco por teclado demonstrado. Não consumiu códigos QA18. Não encerra A/B, leitor de ecrã ou acessibilidade integral.
|
||||
|
||||
Provas sanitizadas: `portal-logout-chromium-20261010.json` e `logout-finalize-20261010/manual-qa18-logout.json`. Cinco requisitos receberam subcritérios; permanecem **3393 requisitos/3393 linhas/6845 ocorrências**, com estados globais inalterados.
|
||||
|
||||
Regressão de instalador/publicação: 9 testes/93 asserções e TypeScript passaram. Backup/restauro QA renovado **2026-10-10T18:19:43+00:00**, 295 tabelas/103 ficheiros, em `profile_restore_20261010_181928_test:55441`, associado ao novo fingerprint que inclui os dois menus. O backup ativo READ ONLY de 17:48:52 UTC permanece um retrato anterior; candidata global, compatibilidade/reversão no clone do ativo e ativação continuam pendentes. Preservar **030000 → 040000 → 09/190000**, sem repetir instalações QA nem copiar contas/dossiers de teste.
|
||||
|
||||
As afirmações históricas abaixo sobre logout manual ainda por testar são superadas somente por esta confirmação; nenhuma aceitação adicional inferida.
|
||||
|
||||
<!-- qa-logout-results-20261010:end -->
|
||||
|
||||
<!-- qa-visual-results-20261010:begin -->
|
||||
## Ensaios visuais e pendências atuais — 2026-10-10T17:57:54.604301+00:00
|
||||
|
||||
Trabalho exclusivo na QA da porta 18097, base `desk_restore_20261009_174726_test:55441`. Nenhuma alteração na base ativa. A mesma matriz mantém **3393 requisitos / 3393 linhas / 6845 ocorrências de origem**: 3235 parciais, 145 não implementados, 5 dependentes de decisão, 1 implementado mas não ativado e 7 concluídos e demonstrados. Os estados globais não mudaram; foram atualizados subcritérios com provas próprias. A ausência de requisitos globalmente classificados “com falha” não significa ausência de casos abertos.
|
||||
|
||||
| Requisito | Demonstrado em QA | Critérios ainda pendentes |
|
||||
| --- | --- | --- |
|
||||
| C37; UI01; PROFILE24–25 | MFA manual QA18 aceite às 17:40:21 e 17:46:42 UTC; autenticação persistida e dashboard 200. Responsável confirmou acesso. O 403 inicial era do relatório administrativo reservado. Mensagem e retorno ao painel corrigidos; Chromium QA21 provou recusa 403, retorno e F5 sem perder sessão. | F5, logout e novo login no navegador habitual QA18. Não declarar o loop anterior resolvido por inferência: o rastreio não demonstra um ciclo persistente. Uma atualização Inertia 409 isolada não é perda de autenticação. |
|
||||
| A/B; GUIDE01; SIM02; DESK01–02; CRM01 | Chromium desktop QA18: criação → gravação → nova sessão → recuperação → aplicabilidade → ferramenta → resultado no dossier → preparação no Balcão → acompanhamento interno → nova recuperação. Cinco fases e 19 etapas preservadas. A dispensa S10; B aguarda informação externa em S10 enquanto S02 avança. Resultado: equilíbrio de 20 unidades e défice A=0 / B=70.000 MZN. QA23 recebe 403/404; gestor não vê resultados financeiros privados. | Balcão demonstrado em preparação DRAFT, sem submissão ou decisão externa. Restantes critérios especializados, fiscalidade, documentos e consumidores não encerrados. Repetição final A/B móvel ainda necessária. |
|
||||
| DASH01; PROFILE05/32 | QA21, a partir do dashboard: “Minhas oportunidades” e “Nova oportunidade” → rascunho incompleto #50 → guardar → nova sessão → recuperar → editar. Promotor solicita arquivo; Publicador QA25 arquiva com fundamento; estado recuperado. | Restauração por perfil ausente no workflow. Definir e implementar ator/regra coerentes sem conceder arquivo direto ao Promotor por inferência. Workflow e contratos especializados ainda exigem demonstrações próprias. |
|
||||
| COUNTRY01–04/06/07 | Residência PT guardada no perfil e recuperada após F5. Negócio e fundiário DRAFT: país PT / origem FR guardados e recuperados. Balcão A/B: PT / ZA recuperados noutra sessão. Quatro formulários públicos apresentam 249 países cada. Gestão: filtros ZA/unknown, quatro manifestações sem origem e zero pedidos submetidos ZA, com rascunhos excluídos. | Nos formulários públicos, a presença de opções não prova submissão ou recuperação. Concluir cada gravação e estatística positiva de origem conhecida no UI. Nos rascunhos de negócio/fundiário, F5 não substitui nova sessão. |
|
||||
| GEO01–03 | Oportunidade própria QA21 #51 e criação de projeto, cluster e corredor: Cabo Delgado registado → API com uma geometria acessível → um polígono SVG visível em cada consumidor. | Seleções desse ensaio não guardadas. Recuperar relações por consumidor e testar descendentes, restantes mapas e perfis. Tiles externos bloqueados no ensaio, sem confirmação da base externa. 408 associações anteriores e 173 pendentes preservadas; Mandie/Guro ambíguo. |
|
||||
| LEG02/03/06/07; C77–C83 | Corrigidos landmarks, ARIA, hierarquia de títulos, reflow, foco das tabelas e idioma HTML pt-MZ em QA. Quinze execuções dirigidas de axe, em 320/390/1440px, sem violações automáticas ou overflow. Três páginas com skiplink primeiro e tabelas financeiras operadas por teclado. | Cobertura integral, leitor de ecrã, dispositivos reais, mais perfis e editor C74. O dossier QA21 verifica o componente móvel corrigido, sem substituir a repetição final A/B. |
|
||||
| DOC01; C84 | Retirada mensagem global falsa de ClamD pendente; scan e motor apresentados por ficheiro/versão. Os 22 scans reais anteriores permanecem demonstrados. | PDFs, exportações, partilhas, derivadas e operações restantes exigem provas próprias. Um original limpo não comprova a derivada. |
|
||||
| OPS01 | Backup QA 2026-10-10T17:48:11+00:00: 295 tabelas e 103 ficheiros restaurados. Ativo READ ONLY, backup 2026-10-10T17:48:52+00:00: 288 tabelas restauradas numa base QA distinta e cinco ficheiros conferidos no arquivo. Instalador/publicação: 9 testes / 93 asserções, além de TypeScript. | Aplicação, compatibilidade e reversão da futura revisão candidata no clone fresco do ativo. Revisão candidata global e ativação continuam por fixar. Contagens de testes não substituem critérios funcionais. |
|
||||
|
||||
**C80 corrigido:** a resposta tardia do GET do rascunho podia substituir preenchimento já iniciado. Edição e avanço ficam bloqueados até a recuperação terminar; o retorno exige o snapshot completo guardado. Chromium com resposta deliberadamente atrasada comprovou bloqueio, recuperação e libertação. A/B recuperaram os inputs corretos noutra sessão, preservando versões anteriores.
|
||||
|
||||
**Recusa manual do mapa às 17:52:33 UTC:** o pedido à oportunidade 51 foi feito com QA18, não QA21; recebeu 403 com sessão preservada. É uma prova de autorização, não de apresentação do mapa. Usar QA21 (`profiles-v1-promoter@example.invalid`) para esse consumidor; o Chromium anterior QA21 recebeu 200. Nenhuma permissão alargada para contornar a recusa.
|
||||
|
||||
**Provas privadas sanitizadas:** `investor-ab-chromium-20261010.json`, `promoter-dashboard-chromium-20261010.json`, `consumers-mobile-accessibility-chromium-20261010.json`, `visual-followup-chromium-20261010.json`, `qa-report-access-chromium-20261010.json`, `c37-qa18-post-mfa-destination-20261010.json`, `visual-persistence-qa.json`, `profile-installation-backup-qa.json` e `virtual-desk/backup-proof.json`. A tentativa antiga de QA21 na oportunidade 30 recebeu 403 e não contou como mapa; foi substituída pelo consumidor próprio 51. Tentativas e falhas de harness preservadas; sem códigos, segredos ou cookies nas evidências entregáveis.
|
||||
|
||||
Mercado Financeiro permanece privado, sem corpo inventado; rotas públicas 404. Energia e Mineração mantêm artigos distintos e Turismo não criou associações geográficas automáticas. Fontes oficiais aceites não foram reabertas. Próximo lote: C37 manual, restauração por perfil, submissão/estatística por formulário, gravação/recuperação territorial, documentos, Response/§27 e workflow. Perfis modulares/SLA continuam decisões separadas; não iniciar novas jornadas educativas.
|
||||
|
||||
As versões históricas abaixo ficam preservadas. Afirmações antigas sobre falta de navegador, A/B não testados ou ClamD global pendente são superadas apenas pelos subcritérios acima. Código preparado, QA demonstrada e disponibilidade ativa são estados distintos.
|
||||
<!-- qa-visual-results-20261010:end -->
|
||||
|
||||
# InvesteMoz — relatório executivo do lote 3
|
||||
|
||||
<!-- qa-c71-sectors-result-20261010:begin -->
|
||||
## Resultado atual — 2026-10-10T16:23:25.695297+00:00
|
||||
|
||||
**QA18097 recuperada; nenhuma alteração na base ativa.** C71 técnico corrigido: faltavam26 ficheiros de catálogos partilhados, incluindo `global/2964`, necessários ao `pg_dump`. O agente/processo que os removeu continua desconhecido. O cluster danificado foi preservado e substituído por cluster novo em diretório estável, apenas no alvoQA55441. As 295 tabelas/34.902 linhas e 31 sequências iniciais conferiram; schema/owners/ACL/triggers equivalentes após normalização explícita de 2 constraints varchar/text e slots de colunas removidas. A cópia física retida confere em 75575 ficheiros por nomes, tamanhos ehashes; isso não afirma restauro lógico de todos os bancos históricosQA.
|
||||
|
||||
**Backup/restauro posterior ao lote:** 2026-10-10T16: 22:44+00:00,295 tabelas e 103 ficheiros privados conferidos em `profile_restore_20261010_162229_test:55441`. APP_KEY, MFA e contas preservados. Não é prova de backup da base ativa nem fixa candidata global. QA/login voltou aHTTP200; manutenção arquivada. 2FAS Auth continua compatível com TOTP; a rejeição manualQA18 registada foi de palavra-passe antes do MFA, sem justificar troca de aplicação.
|
||||
|
||||
| Requisito / caso | Evidência deste lote | Lacuna concreta |
|
||||
| --- | --- | --- |
|
||||
| AUD-OPS-01 / C71 | Cluster saudável, dados/sequências/permissões preservados, backup/restauro renovado,0escritasativas. Estado global passa de com falha para parcial. | C37/QA18,A/B e outros consumidores; candidata e backup ativo/autorização própria. |
|
||||
| AUD-LEG-01/05/07 |22 ficheiros (17 artigos+5 imagens) importados privadamente, hashes recuperados,22 scans ClamD reais; replay sem duplicações/eventos/scans novos. |32 das 50 versões observadas continuam não importadas; sitemap misto/paginação/links externos/C68 e cobertura integral. |
|
||||
| AUD-LEG-02/03/06 / C72 |16 artigos novos com texto públicos, título deInfraestruturas preservado. Chromium desktop: imagens/pesquisa/artigos→MFA21→4 dossiers→retorno→logout→nova sessão→F5/replay. HTTP e persistência separados,8 dossiers no total. | Móvel/acessibilidade/mais perfis e percursosA/B completos não encerrados. |
|
||||
| AUD-LEG-04 |Leitura pública; guardar requer login. Anónimo/QA23 sem contexto privado; QA23recebe403 ao dossier alheio. Estado de consulta/proteçãoQA mantido concluído no âmbito demonstrado. | Disponibilização ativa e demais aceitações são independentes. |
|
||||
| Mercado Financeiro |OrigemHTML e respostaInertiaHTTP200 contêm corpo vazio; exemplar22 no lote fica privado na biblioteca (recurso23). `/mercado-financeiro` e respetiva exploração404,sem página vazia apresentada como entrega. | Obter conteúdo real/versão com texto; não usar Fintech nem inventar corpo. |
|
||||
| C74 |Imagens semAssunto eram recusadas porPUT422. QA aceita tema vazio sem inferir setor; interface tornaAssunto opcional. Cinco metadados vazios guardados/recuperados; teste dedicado1/11. | Editor administrativo sem ensaio visual próprio; alteração protegida pela guardaQA. |
|
||||
|
||||
**Contagens canónicas:**3393 requisitos/3393 linhas, 6845 ocorrências de origem; 3235 parciais, 145 não implementados, 5 dependentes de decisão, 1 implementado mas não ativado, 7 concluídos e demonstrados, 0 com falha. Casos adicionais e aceitações abertas continuam visíveis dentro dos requisitos parciais;0 com falha global não significa portal sem erros. BacklogCSV é projeção reconciliada dos mesmos3393 IDs, incluindo39 anteriormente omitidos, não outra matriz.
|
||||
|
||||
**RotasQA:** `/explorar/energia-mineracao` → `/energia-mineracao`, `/energia`, `/mineracao`; `/explorar/turismo` → `/turismo` e 11 artigos territoriais; `/explorar/infraestruturas` → `/infraestruturas`. Agronegócio preservado. Energia/Mineração são fontes distintas; Turismo mantém texto editorial e 0associações territoriais automáticas. Todos os dados de fonte/autoria/datas/hash/URLs/versões originais são conservados. Os artigos não criaram oportunidades (49antes/depois), geometrias ou perfis. Divulgação foi uma ação autenticada do gestorQA14 pelo mecanismo existente, separada da importaçãoCLIQA (atribuição `--actor`, não autenticação ativa); nenhum desafioMFA foi observado nessa conta. QA21/QA23 usaramMFA normalmente.
|
||||
|
||||
**Próximo trabalho executável:** restantes32 versões/acervo eC68 por ficheiro; Response/§27/validações, PDFs/derivadas epaíses/origem/estatísticas por consumidor;173 correspondências territoriais ainda abertas comMandie/Guroambíguo. Não acrescentar jornadas educativas para contornar essas pendências. Perfis modulares eSLA conservam decisões próprias. C37/QA18,A/B,móvel/acessibilidade permanecem abertos; este lote não demonstraCRUD/workflow de oportunidades nem os encerra.
|
||||
|
||||
**Ativação futura:** revisão/hashes/flags e aceitação global→leitura/backup/restauro do ativo correspondente→código/build compatíveis com flags desligadas→030000→040000→09/190000 somente se ausentes→importação privada de conteúdo/cartografia com prévia e scan→divulgação separada→disponibilização incremental. QA já tem as 3 instalações;não as repetir. Previsão desta extensão: 22 originais (reutilizar hashes existentes),21 potencialmente divulgáveis e 1 exemplar vazio privado;0 contas/dossiers/opportunities/geometrias transferidos. O CLIrecusa aplicação ativa: falta preparar/demonstrar uma ação autenticada emlote que preserve exatamente as identidades/versionamento existentes; upload unitário não foi provado equivalente. Cartografia:408 candidatas/173 pendentes no retratoQA anterior, não importadas por esta frente. `ready=true` continua preparação, não instalação/disponibilização.
|
||||
|
||||
**Reversão:** retirar divulgação em ações auditadas e ocultar consumidores/flag, preservando originais, versões, consentimentos/referências eeventos. Restauro técnico usa o novo dump íntegro e chave estável numa QA distinta,comparação antes de corte;não voltar ao cluster danificado. Nunca drop de tabelas utilizadas nem copiar fixturesQA para o ativo. Provas: `legacy-sectors-{apply,replay,http,browser,persistence}-qa.json`, `legacy-financial-origin-20261010/proof.json`, `portal-qa-recovery/20261010_154445/*-proof.json` e `profile-installation-backup-qa.json` (privadas, sem credenciais/cookies/códigos nas capturas).
|
||||
|
||||
**Versões históricas preservadas abaixo:** frases sobreC71aberto,22 ficheiros apenas preparados ou rotas apenas deagronegócio descrevem os incrementos anteriores e são substituídas, para o estado atual, pelas provas desta secção.
|
||||
<!-- qa-c71-sectors-result-20261010:end -->
|
||||
|
||||
<!-- legacy-manual-sectors-20261010:begin -->
|
||||
## Confirmação manual e reconciliação setorial — 10/10/2026
|
||||
|
||||
Responsável confirmou leitura pública → autenticação → criar/escolher dossier → guardar referência → nova sessão → recuperar dossier e abrir artigo. AUD-LEG-03/04/06/07 receberam apenas os subcritérios correspondentes; estados e contagens da matriz inalterados. Conta/perfil/dispositivo não indicados. Móvel, acessibilidade, outros perfis, cobertura integral, C37/QA18 e A/B continuam pendentes.
|
||||
|
||||
Quatro setores reconciliados: Energia & Mineração (índice + dois artigos), Turismo (artigo +11 referências territoriais), Infraestruturas (título corrigido em código) e Mercado Financeiro (corpo vazio preservado). **17 artigos +5 imagens** preparados; prévia CLI em transação READ ONLY:22 candidatos,0 duplicados,0 conflitos; nenhuma aplicação/scan/publicação nova. C72 corrigido na transformação dos originais, sem prova visual dos novos setores. C71 continua P0: recuperar integridade/backup/restauro antes de aplicação QA. Biblioteca/entidades/componentes existentes reutilizados; rotas dos restantes setores ainda por parametrizar. Ver `legado-piloto-qa.md` e inventários existentes.
|
||||
<!-- legacy-manual-sectors-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-browser-20261010:begin -->
|
||||
## Verificação do piloto na interface — 10/10/2026, exclusivamente QA
|
||||
|
||||
**O piloto existente foi demonstrado em Chromium instalado**, com contextos separados, sem usar o perfil pessoal do utilizador. O controlo nativo continua indisponível (`native pipe`, os error 2, duas sondagens); esse problema não impediu renderização, cliques e capturas por Playwright. C37/QA18 permanece pendente; este ensaio usou Promotor QA21 e outra conta sintética QA23.
|
||||
|
||||
Página pública→pesquisa com/sem resultado→artigo→carteira/mapas/biblioteca/oportunidade publicada→CTA→login/MFA da conta QA21→dossier guardado→fonte/retorno→logout→novo contexto/MFA→recuperação e F5→continuação/repetição passaram. Leitura anónima e QA23 não recebem o título/ID do contexto privado; dossier negado 403 à QA23. A criação na interface consta da tentativa inicial preservada; as repetições recuperam o mesmo dossier sem criar outro. Cinco fases / 19 etapas observadas. Persistência em transação READ ONLY: uma referência, um evento, um documento por hash; originais e scan preservados; projeto independente com zero referências.
|
||||
|
||||
Desktop 1440 px / móvel 390 px: fotografia 900×506, Noto Sans / Noto Serif carregadas, cores reais adaptadas, texto integral de 4.702 carateres, foco de 3 px por teclado e nenhum deslocamento horizontal. Sete capturas privadas. **C70 corrigido:** título de separador “Laravel” substituído por “InvesteMoz QA” apenas no build QA; bundle ativo preservado. Isto não certifica acessibilidade integral nem demonstra todas as geometrias/consumidores do portal.
|
||||
|
||||
**AUD-LEG-04 concluído e demonstrado em QA**, por consulta pública/proteção privada. AUD-LEG-01/02/03/05/06/07 continuam parciais pelas lacunas explicitadas na matriz: cobertura histórica, controlo assistivo sistemático, relações territoriais concretas, restante mapa de URLs e anexos. Nenhum I0–I3/A-B/workflow/C37 é encerrado por esta prova.
|
||||
|
||||
Recibos: `legacy-content-browser-qa.json`, `legacy-content-browser-persistence-qa.json`; testes `tools/legacy-content-browser-qa.cjs` e `tools/legacy-content-browser-persistence-qa.php`. Capturas e credenciais permanecem privadas; não foram registados Cookie, X-XSRF-TOKEN ou corpo com o código MFA. O ZIP anteriormente entregue é um snapshot anterior à verificação, não prova de execução remota.
|
||||
|
||||
**C71 / P0 — novo backup QA bloqueado:** `pg_dump` falhou às 14:48–14:49UTC: `global/2964` ausente, catálogo `pg_db_role_setting`; diagnóstico identifica 26 ficheiros de catálogos partilhados ausentes no cluster 55441, situado em Temp. Não atribuir a remoção à limpeza automática sem evidência. O backup/restauro válido de 13:35:39UTC (295 tabelas/81 ficheiros) permanece histórico. Preservação posterior READ ONLY: 295 tabelas/34.902 linhas/31 sequências; não equivale a backup/restauro verificado. Readiness QA foi invalidada e o CLI passa a devolver exit 1 controlado. Próxima ação: recuperação em ambiente isolado, preservação/conferência integral e nova prova de restauro antes de retomar escritas. Nenhum catálogo recriado às cegas nem acesso/escrita na base ativa. Ver `qa-readable-preservation.json`, `profile-installation-backup-failure-qa.json` e C71 em `casos-reproducao.md`.
|
||||
|
||||
**Contagens da mesma matriz:** 3393 requisitos/3393 linhas; 6845 ocorrências de origem. Estados: 3234 parcial; 145 não implementado; 5 dependente de decisão concreta; 1 implementado mas não ativado; 1 com falha; 7 concluído e demonstrado.
|
||||
<!-- qa-legacy-browser-20261010:end -->
|
||||
|
||||
<!-- qa-legacy-20261010:begin -->
|
||||
## Histórico — legado agronegócio antes do ensaio Chromium
|
||||
|
||||
Piloto: **http://127.0.0.1:18097/explorar/agronegocio → /agronegocio → /investimentos/a-partir-de/agronegocio**. Artigo6 e imagem5 reutilizam biblioteca; leitura/pesquisa/imagem públicas, gravação privada autenticada. HTTP/persistência demonstrados: criação e continuação, logout/novaMFA21, recuperação, replay, isolamento e retorno com contexto. ClamD real nos dois originais. Uma oportunidade publicada no setor permanece independente; nenhuma oportunidade/geo inferida do artigo.
|
||||
|
||||
Inventário: **92URLs**, **50 versões de artigos**, **2.826 referências**, **30.170 entradas anunciadas num sitemap de outro domínio**, sem afirmar recolha integral. Dois recursos importados; repetição sem duplicação. Fonte/autoria/datas/versão/hash e diferenças preservadas. C68:imagem histórica 404. Cobertura documental histórica permanece aberta.
|
||||
|
||||
Mesma matriz: **3393 requisitos únicos = 3393 linhas lógicas; 6845 ocorrências de origem**. Estados: 3236 parcial; 145 não implementado; 5 dependente de decisão concreta; 1 implementado mas não ativado; 6 concluído e demonstrado. Sete pedidos novosLEG01–07 incorporados, todos parciais. Nenhum critério anterior foi encerrado por esta frente.
|
||||
|
||||
Referências visuais reais: CSS/chunkwelcome, fotografia original, Noto Sans/Serif locais no buildQA, paleta/cartões adaptados. **Estado histórico, antes do ensaio Chromium: navegador integrado indisponível; aceitação visual então pendente**, com roteiro executável. C37/QA18, Promotor21 visual e A/B continuam abertos. Perfis modulares/SLA têm decisões independentes; a confirmação do legado não os aprova.
|
||||
|
||||
Relatório e critérios: [legado-piloto-qa.md](legado-piloto-qa.md); inventários `legado-inventario.csv`, `legado-conteudos-reconciliados.csv`, `legado-referencias.csv`, `legado-sitemap-anunciado.csv`. Recibos privados sanitizados por percurso. Ativo não alterado; candidata ainda não fixada. Blocos anteriores são evidências históricas datadas, não conclusão global atual. Merece distinção o ensaio modularQA42–49 já demonstrado porHTTP, comvisual/materialidade/Response/aprovação formal pendentes.
|
||||
<!-- qa-legacy-20261010:end -->
|
||||
|
||||
<!-- qa-response-20261010:begin -->
|
||||
## Incremento atual — Response/§27 e propostas para decisão
|
||||
|
||||
Somente QA **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Mesmo inventário/matriz; nenhum fecho integral ou escrita ativa.
|
||||
|
||||
- **AUD-OP-04:** `/minhas-oportunidades/{id}/response` integra a leitura do editor existente. Preserva IDs, códigos, referências UUID e estados atuais. Dez casos #30–39 recuperados noutra sessão MFA; Validador permitido, organização alheia 403 e anónimo 401. A versão do adaptador declara expressamente que a resposta normativa completa continua parcial.
|
||||
- **C65 / QA40:** §27 exige unicidade de número cadastral por oportunidade. Rascunho incompleto/duplicado permanece recuperável; submissão devolve 422 junto a `canonical.land_rights.parcels.1.cadastral_number`. Após correção, submissão aceite. Números ausentes não são inventados.
|
||||
- **C66 / QA41:** `succession` constava no contrato original mas era recusada pela lista semântica de venda. Lista reconciliada; criação/edição, submissão e nova sessão demonstradas. Não alterar silenciosamente outras modalidades.
|
||||
- **Ainda parcial:** algoritmo de completude, metadados/identidade de itens inline, equivalência explícita dos estados e recuperação dos autores de alteração/exclusão dos recursos. §27 não cria tabelas paralelas. Condições sem regra inequívoca continuam como decisões D-S1/S2.
|
||||
|
||||
Provas: `opportunity-response-http-qa.json`, `OpportunityCanonicalWorkflowTest`, `profile-installation-checks-qa.json` e schema original preservado. O recibo identifica separadamente a tentativa anterior em que passaram os dez critérios de leitura e o ensaio semântico posterior; falhas do harness foram conservadas, não apresentadas como falhas do portal.
|
||||
|
||||
**Propostas modulares e SLA continuam não aprovados.** O ficheiro `propostas-perfis-modulares-sla.md` explicita compromissos internos de atendimento, janela operacional do Promotor, pausas por tarefa, espera externa separada e ausência de decisão automática. Nenhum `due_at`, titular ou perfil modular ativado.
|
||||
|
||||
**Ativação:** o restauro reportado é QA. Para uma futura ativação, conferir a identidade, schema, dados e configuração efetivos da base ativa para a revisão candidata, capturar hashes/manifesto dessa revisão e provar o seu backup/restauro. As provas QA ou `ready=true` não substituem essa ligação. Sequência 030000 → 040000 → 190000; C37, QA21 e A/B visual pendentes, candidata não fixada.
|
||||
|
||||
**Próximo trabalho independente:** completar metadados/Response sem novos cadastros; PDFs gerados/exportações/derivadas por consumidor; países/origem com filtros e estatísticas por formulário; correspondências inequívocas, mantendo Mandie/Guro ambíguo. Cartografia permanece 408 resolvidas e 173 lacunas neste incremento.
|
||||
<!-- qa-response-20261010:end -->
|
||||
|
||||
<!-- 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.**
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T12:26:56+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 |
|
||||
| --- | --- | --- |
|
||||
@@ -14,7 +177,7 @@ QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_tes
|
||||
| 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.
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_123125_test:55441` → **295 tabelas com linhas/hashes iguais e 71 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.
|
||||
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
# InvestMoz — contratos e workflow de oportunidades em QA
|
||||
|
||||
<!-- qa-response-20261010:begin -->
|
||||
## Incremento atual — Response/§27 e propostas para decisão
|
||||
|
||||
Somente QA **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Mesmo inventário/matriz; nenhum fecho integral ou escrita ativa.
|
||||
|
||||
- **AUD-OP-04:** `/minhas-oportunidades/{id}/response` integra a leitura do editor existente. Preserva IDs, códigos, referências UUID e estados atuais. Dez casos #30–39 recuperados noutra sessão MFA; Validador permitido, organização alheia 403 e anónimo 401. A versão do adaptador declara expressamente que a resposta normativa completa continua parcial.
|
||||
- **C65 / QA40:** §27 exige unicidade de número cadastral por oportunidade. Rascunho incompleto/duplicado permanece recuperável; submissão devolve 422 junto a `canonical.land_rights.parcels.1.cadastral_number`. Após correção, submissão aceite. Números ausentes não são inventados.
|
||||
- **C66 / QA41:** `succession` constava no contrato original mas era recusada pela lista semântica de venda. Lista reconciliada; criação/edição, submissão e nova sessão demonstradas. Não alterar silenciosamente outras modalidades.
|
||||
- **Ainda parcial:** algoritmo de completude, metadados/identidade de itens inline, equivalência explícita dos estados e recuperação dos autores de alteração/exclusão dos recursos. §27 não cria tabelas paralelas. Condições sem regra inequívoca continuam como decisões D-S1/S2.
|
||||
|
||||
Provas: `opportunity-response-http-qa.json`, `OpportunityCanonicalWorkflowTest`, `profile-installation-checks-qa.json` e schema original preservado. O recibo identifica separadamente a tentativa anterior em que passaram os dez critérios de leitura e o ensaio semântico posterior; falhas do harness foram conservadas, não apresentadas como falhas do portal.
|
||||
|
||||
**Propostas modulares e SLA continuam não aprovados.** O ficheiro `propostas-perfis-modulares-sla.md` explicita compromissos internos de atendimento, janela operacional do Promotor, pausas por tarefa, espera externa separada e ausência de decisão automática. Nenhum `due_at`, titular ou perfil modular ativado.
|
||||
|
||||
**Ativação:** o restauro reportado é QA. Para uma futura ativação, conferir a identidade, schema, dados e configuração efetivos da base ativa para a revisão candidata, capturar hashes/manifesto dessa revisão e provar o seu backup/restauro. As provas QA ou `ready=true` não substituem essa ligação. Sequência 030000 → 040000 → 190000; C37, QA21 e A/B visual pendentes, candidata não fixada.
|
||||
|
||||
**Próximo trabalho independente:** completar metadados/Response sem novos cadastros; PDFs gerados/exportações/derivadas por consumidor; países/origem com filtros e estatísticas por formulário; correspondências inequívocas, mantendo Mandie/Guro ambíguo. Cartografia permanece 408 resolvidas e 173 lacunas neste incremento.
|
||||
<!-- qa-response-20261010:end -->
|
||||
|
||||
<!-- 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.**
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T12:26:56+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 |
|
||||
| --- | --- | --- |
|
||||
@@ -14,7 +33,7 @@ QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_tes
|
||||
| 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.
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_123125_test:55441` → **295 tabelas com linhas/hashes iguais e 71 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.
|
||||
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
<!-- qa-visual-results-20261010:begin -->
|
||||
## Ensaios visuais e pendências atuais — 2026-10-10T17:57:54.604301+00:00
|
||||
|
||||
Trabalho exclusivo na QA da porta 18097, base `desk_restore_20261009_174726_test:55441`. Nenhuma alteração na base ativa. A mesma matriz mantém **3393 requisitos / 3393 linhas / 6845 ocorrências de origem**: 3235 parciais, 145 não implementados, 5 dependentes de decisão, 1 implementado mas não ativado e 7 concluídos e demonstrados. Os estados globais não mudaram; foram atualizados subcritérios com provas próprias. A ausência de requisitos globalmente classificados “com falha” não significa ausência de casos abertos.
|
||||
|
||||
| Requisito | Demonstrado em QA | Critérios ainda pendentes |
|
||||
| --- | --- | --- |
|
||||
| C37; UI01; PROFILE24–25 | MFA manual QA18 aceite às 17:40:21 e 17:46:42 UTC; autenticação persistida e dashboard 200. Responsável confirmou acesso. O 403 inicial era do relatório administrativo reservado. Mensagem e retorno ao painel corrigidos; Chromium QA21 provou recusa 403, retorno e F5 sem perder sessão. | F5, logout e novo login no navegador habitual QA18. Não declarar o loop anterior resolvido por inferência: o rastreio não demonstra um ciclo persistente. Uma atualização Inertia 409 isolada não é perda de autenticação. |
|
||||
| A/B; GUIDE01; SIM02; DESK01–02; CRM01 | Chromium desktop QA18: criação → gravação → nova sessão → recuperação → aplicabilidade → ferramenta → resultado no dossier → preparação no Balcão → acompanhamento interno → nova recuperação. Cinco fases e 19 etapas preservadas. A dispensa S10; B aguarda informação externa em S10 enquanto S02 avança. Resultado: equilíbrio de 20 unidades e défice A=0 / B=70.000 MZN. QA23 recebe 403/404; gestor não vê resultados financeiros privados. | Balcão demonstrado em preparação DRAFT, sem submissão ou decisão externa. Restantes critérios especializados, fiscalidade, documentos e consumidores não encerrados. Repetição final A/B móvel ainda necessária. |
|
||||
| DASH01; PROFILE05/32 | QA21, a partir do dashboard: “Minhas oportunidades” e “Nova oportunidade” → rascunho incompleto #50 → guardar → nova sessão → recuperar → editar. Promotor solicita arquivo; Publicador QA25 arquiva com fundamento; estado recuperado. | Restauração por perfil ausente no workflow. Definir e implementar ator/regra coerentes sem conceder arquivo direto ao Promotor por inferência. Workflow e contratos especializados ainda exigem demonstrações próprias. |
|
||||
| COUNTRY01–04/06/07 | Residência PT guardada no perfil e recuperada após F5. Negócio e fundiário DRAFT: país PT / origem FR guardados e recuperados. Balcão A/B: PT / ZA recuperados noutra sessão. Quatro formulários públicos apresentam 249 países cada. Gestão: filtros ZA/unknown, quatro manifestações sem origem e zero pedidos submetidos ZA, com rascunhos excluídos. | Nos formulários públicos, a presença de opções não prova submissão ou recuperação. Concluir cada gravação e estatística positiva de origem conhecida no UI. Nos rascunhos de negócio/fundiário, F5 não substitui nova sessão. |
|
||||
| GEO01–03 | Oportunidade própria QA21 #51 e criação de projeto, cluster e corredor: Cabo Delgado registado → API com uma geometria acessível → um polígono SVG visível em cada consumidor. | Seleções desse ensaio não guardadas. Recuperar relações por consumidor e testar descendentes, restantes mapas e perfis. Tiles externos bloqueados no ensaio, sem confirmação da base externa. 408 associações anteriores e 173 pendentes preservadas; Mandie/Guro ambíguo. |
|
||||
| LEG02/03/06/07; C77–C83 | Corrigidos landmarks, ARIA, hierarquia de títulos, reflow, foco das tabelas e idioma HTML pt-MZ em QA. Quinze execuções dirigidas de axe, em 320/390/1440px, sem violações automáticas ou overflow. Três páginas com skiplink primeiro e tabelas financeiras operadas por teclado. | Cobertura integral, leitor de ecrã, dispositivos reais, mais perfis e editor C74. O dossier QA21 verifica o componente móvel corrigido, sem substituir a repetição final A/B. |
|
||||
| DOC01; C84 | Retirada mensagem global falsa de ClamD pendente; scan e motor apresentados por ficheiro/versão. Os 22 scans reais anteriores permanecem demonstrados. | PDFs, exportações, partilhas, derivadas e operações restantes exigem provas próprias. Um original limpo não comprova a derivada. |
|
||||
| OPS01 | Backup QA 2026-10-10T17:48:11+00:00: 295 tabelas e 103 ficheiros restaurados. Ativo READ ONLY, backup 2026-10-10T17:48:52+00:00: 288 tabelas restauradas numa base QA distinta e cinco ficheiros conferidos no arquivo. Instalador/publicação: 9 testes / 93 asserções, além de TypeScript. | Aplicação, compatibilidade e reversão da futura revisão candidata no clone fresco do ativo. Revisão candidata global e ativação continuam por fixar. Contagens de testes não substituem critérios funcionais. |
|
||||
|
||||
**C80 corrigido:** a resposta tardia do GET do rascunho podia substituir preenchimento já iniciado. Edição e avanço ficam bloqueados até a recuperação terminar; o retorno exige o snapshot completo guardado. Chromium com resposta deliberadamente atrasada comprovou bloqueio, recuperação e libertação. A/B recuperaram os inputs corretos noutra sessão, preservando versões anteriores.
|
||||
|
||||
**Recusa manual do mapa às 17:52:33 UTC:** o pedido à oportunidade 51 foi feito com QA18, não QA21; recebeu 403 com sessão preservada. É uma prova de autorização, não de apresentação do mapa. Usar QA21 (`profiles-v1-promoter@example.invalid`) para esse consumidor; o Chromium anterior QA21 recebeu 200. Nenhuma permissão alargada para contornar a recusa.
|
||||
|
||||
**Provas privadas sanitizadas:** `investor-ab-chromium-20261010.json`, `promoter-dashboard-chromium-20261010.json`, `consumers-mobile-accessibility-chromium-20261010.json`, `visual-followup-chromium-20261010.json`, `qa-report-access-chromium-20261010.json`, `c37-qa18-post-mfa-destination-20261010.json`, `visual-persistence-qa.json`, `profile-installation-backup-qa.json` e `virtual-desk/backup-proof.json`. A tentativa antiga de QA21 na oportunidade 30 recebeu 403 e não contou como mapa; foi substituída pelo consumidor próprio 51. Tentativas e falhas de harness preservadas; sem códigos, segredos ou cookies nas evidências entregáveis.
|
||||
|
||||
Mercado Financeiro permanece privado, sem corpo inventado; rotas públicas 404. Energia e Mineração mantêm artigos distintos e Turismo não criou associações geográficas automáticas. Fontes oficiais aceites não foram reabertas. Próximo lote: C37 manual, restauração por perfil, submissão/estatística por formulário, gravação/recuperação territorial, documentos, Response/§27 e workflow. Perfis modulares/SLA continuam decisões separadas; não iniciar novas jornadas educativas.
|
||||
|
||||
As versões históricas abaixo ficam preservadas. Afirmações antigas sobre falta de navegador, A/B não testados ou ClamD global pendente são superadas apenas pelos subcritérios acima. Código preparado, QA demonstrada e disponibilidade ativa são estados distintos.
|
||||
<!-- qa-visual-results-20261010:end -->
|
||||
|
||||
# Gestão de Oportunidades integrada no Dashboard
|
||||
|
||||
<!-- qa-response-20261010:begin -->
|
||||
## Incremento atual — Response/§27 e propostas para decisão
|
||||
|
||||
Somente QA **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Mesmo inventário/matriz; nenhum fecho integral ou escrita ativa.
|
||||
|
||||
- **AUD-OP-04:** `/minhas-oportunidades/{id}/response` integra a leitura do editor existente. Preserva IDs, códigos, referências UUID e estados atuais. Dez casos #30–39 recuperados noutra sessão MFA; Validador permitido, organização alheia 403 e anónimo 401. A versão do adaptador declara expressamente que a resposta normativa completa continua parcial.
|
||||
- **C65 / QA40:** §27 exige unicidade de número cadastral por oportunidade. Rascunho incompleto/duplicado permanece recuperável; submissão devolve 422 junto a `canonical.land_rights.parcels.1.cadastral_number`. Após correção, submissão aceite. Números ausentes não são inventados.
|
||||
- **C66 / QA41:** `succession` constava no contrato original mas era recusada pela lista semântica de venda. Lista reconciliada; criação/edição, submissão e nova sessão demonstradas. Não alterar silenciosamente outras modalidades.
|
||||
- **Ainda parcial:** algoritmo de completude, metadados/identidade de itens inline, equivalência explícita dos estados e recuperação dos autores de alteração/exclusão dos recursos. §27 não cria tabelas paralelas. Condições sem regra inequívoca continuam como decisões D-S1/S2.
|
||||
|
||||
Provas: `opportunity-response-http-qa.json`, `OpportunityCanonicalWorkflowTest`, `profile-installation-checks-qa.json` e schema original preservado. O recibo identifica separadamente a tentativa anterior em que passaram os dez critérios de leitura e o ensaio semântico posterior; falhas do harness foram conservadas, não apresentadas como falhas do portal.
|
||||
|
||||
**Propostas modulares e SLA continuam não aprovados.** O ficheiro `propostas-perfis-modulares-sla.md` explicita compromissos internos de atendimento, janela operacional do Promotor, pausas por tarefa, espera externa separada e ausência de decisão automática. Nenhum `due_at`, titular ou perfil modular ativado.
|
||||
|
||||
**Ativação:** o restauro reportado é QA. Para uma futura ativação, conferir a identidade, schema, dados e configuração efetivos da base ativa para a revisão candidata, capturar hashes/manifesto dessa revisão e provar o seu backup/restauro. As provas QA ou `ready=true` não substituem essa ligação. Sequência 030000 → 040000 → 190000; C37, QA21 e A/B visual pendentes, candidata não fixada.
|
||||
|
||||
**Próximo trabalho independente:** completar metadados/Response sem novos cadastros; PDFs gerados/exportações/derivadas por consumidor; países/origem com filtros e estatísticas por formulário; correspondências inequívocas, mantendo Mandie/Guro ambíguo. Cartografia permanece 408 resolvidas e 173 lacunas neste incremento.
|
||||
<!-- qa-response-20261010:end -->
|
||||
|
||||
<!-- 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.**
|
||||
QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_test:55441`. Ativo inspecionado apenas por leitura em 2026-10-10T12:26:56+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 |
|
||||
| --- | --- | --- |
|
||||
@@ -14,7 +60,7 @@ QA exclusivamente: **http://127.0.0.1:18097**, `desk_restore_20261009_174726_tes
|
||||
| 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.
|
||||
Prova de recuperação QA: dump consistente → nova base `profile_restore_20261010_123125_test:55441` → **295 tabelas com linhas/hashes iguais e 71 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.
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ export default function InteractiveInvestmentMap({
|
||||
<div
|
||||
ref={element}
|
||||
className="relative z-0 h-[460px] w-full rounded"
|
||||
role="region"
|
||||
aria-label="Mapa; navegação por setas e teclas mais e menos"
|
||||
/>
|
||||
{points === null ? (
|
||||
@@ -255,7 +256,9 @@ export default function InteractiveInvestmentMap({
|
||||
<a className="underline" href={area.url}>
|
||||
{area.title}
|
||||
</a>{' '}
|
||||
· Polígono cadastrado, limites oficiais por verificar ·
|
||||
· {area.purpose === 'administrative_boundary'
|
||||
? 'Limite administrativo da edição indicada; não representa um local de investimento'
|
||||
: 'Polígono cadastrado; comparação com limite administrativo pendente'} ·
|
||||
Fonte: {area.source_reference ?? 'Não indicada'} · Data:{' '}
|
||||
{area.source_date ?? 'Não documentada'}
|
||||
</p>
|
||||
|
||||
@@ -27,6 +27,8 @@ function resourceTerritory(resource?: Resource) {
|
||||
}
|
||||
export type CanonicalOptions = {
|
||||
version: string;
|
||||
modularProfiles?: Record<string, string>;
|
||||
historyVersion?: string | null;
|
||||
schema: Schema;
|
||||
definitions: Record<string, Schema>;
|
||||
contacts: { id: string; full_name: string }[];
|
||||
@@ -70,6 +72,53 @@ const labels: Record<string, string> = {
|
||||
real_estate: "Imobiliário",
|
||||
land_rights: "DUAT e direitos fundiários",
|
||||
business_transaction: "Negócio",
|
||||
module_profile_version: "Versão do contrato QA (seleção explícita)",
|
||||
module_details: "Condições do módulo",
|
||||
mode: "Percurso: procura, oferta ou modalidade",
|
||||
conditions_status: "Condições propostas ou compromisso documentado",
|
||||
object: "Objeto",
|
||||
representation_document_id: "Documento de representação",
|
||||
object_document_id: "Documento do objeto/procedimento",
|
||||
disclosure_authorization_document_id: "Autorização de divulgação da intenção/chamada",
|
||||
external_decision_document_id: "Ato competente do direito atribuído",
|
||||
granting_entity: "Entidade concedente declarada",
|
||||
procedure_regime: "Regime/procedimento declarado",
|
||||
scope: "Âmbito territorial ou digital",
|
||||
term_value: "Duração ou prazo",
|
||||
term_unit: "Unidade da duração",
|
||||
remuneration_obligations: "Remuneração e obrigações",
|
||||
participation_conditions: "Condições de participação",
|
||||
land_required: "Precisa de terra?",
|
||||
construction_required: "Inclui construção?",
|
||||
applicable_authorizations: "Autorizações aplicáveis e situação",
|
||||
instrument: "Instrumento de financiamento",
|
||||
offered_capacity: "Capacidade oferecida pelo financiador",
|
||||
eligibility_criteria: "Critérios declarados do financiador",
|
||||
capacity_document_id: "Evidência da capacidade declarada",
|
||||
financial_plan_document_id: "Plano financeiro do projeto",
|
||||
secured_evidence_document_id: "Evidência do financiamento assegurado",
|
||||
use_of_funds: "Finalidade do financiamento procurado",
|
||||
disbursement_calendar: "Calendário proposto de desembolso",
|
||||
repayment_periodicity: "Periodicidade de reembolso",
|
||||
proposed_rights: "Direitos propostos",
|
||||
equity_percentage: "Percentagem de capital proposta",
|
||||
terms: "Condições",
|
||||
scenario: "Cenário identificado",
|
||||
source: "Fonte identificada",
|
||||
terms_date: "Data da fonte/condições",
|
||||
commitment_document_id: "Documento do compromisso assumido",
|
||||
acceptance_criteria: "Critérios de aceitação do fornecimento",
|
||||
quotation_method: "Método de cotação",
|
||||
total_price: "Preço total (impostos identificados separadamente)",
|
||||
declared_taxes: "Impostos declarados, sem cálculo implícito",
|
||||
advance_or_guarantee: "Há adiantamento ou garantia?",
|
||||
subtype: "Subtipo para classificação funcional",
|
||||
recipients: "Destinatários",
|
||||
expected_output: "Resultado esperado",
|
||||
history_status: "Situação do histórico financeiro",
|
||||
history_declaration: "Declaração de ausência de exercícios completos",
|
||||
history_declaration_document_id: "Documento da declaração",
|
||||
projections: "Projeções identificadas — não são contas realizadas",
|
||||
transaction_type: "Modalidade",
|
||||
property_type: "Tipo de imóvel",
|
||||
construction_status: "Estado construtivo",
|
||||
@@ -173,12 +222,10 @@ const labels: Record<string, string> = {
|
||||
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)",
|
||||
@@ -568,7 +615,7 @@ export default function OpportunityCanonicalFields({
|
||||
: ["business_sale", "business_partnership", "franchise"].includes(
|
||||
category,
|
||||
)
|
||||
? ["business_transaction"]
|
||||
? ["module_profile_version", "business_transaction"]
|
||||
: category === "investment_project"
|
||||
? [
|
||||
"opportunity_type",
|
||||
@@ -594,7 +641,18 @@ export default function OpportunityCanonicalFields({
|
||||
"risks",
|
||||
"relations",
|
||||
]
|
||||
: ["module_profile_version"];
|
||||
: ["module_profile_version", "module_details", ...(category === "financing" ? ["total_investment", "investment_breakdown", "secured_financing", "funding_sought"] : [])];
|
||||
const details = (value.module_details ?? {}) as Data;
|
||||
const mode = String(details.mode ?? "");
|
||||
const modularFields = ["mode", "object", "representation_document_id", "object_document_id",
|
||||
...(category === "concession" ? ["granting_entity", "procedure_regime", "scope", "term_value", "term_unit", "remuneration_obligations", "participation_conditions", "land_required", "construction_required", "applicable_authorizations", "start_date", "end_date", "disclosure_authorization_document_id", "external_decision_document_id", "territorial_right_document_id", "construction_license_document_id"] : []),
|
||||
...(["financing", "supply"].includes(category) ? ["conditions_status", "terms", "scenario", "source", "terms_date", "commitment_document_id"] : []),
|
||||
...(category === "financing" ? ["instrument", "disbursement_calendar", "term_value", "term_unit", "repayment_periodicity", "equity_percentage", "proposed_rights",
|
||||
...(mode === "funding_offer" ? ["offered_capacity", "eligibility_criteria", "capacity_document_id"] : ["use_of_funds", "financial_plan_document_id", "secured_evidence_document_id"])] : []),
|
||||
...(category === "supply" ? ["quantity", "unit", "destination", "start_date", "end_date", "acceptance_criteria", "quotation_method", "unit_price", "total_price", "declared_taxes", "advance_or_guarantee", ...(mode === "supply_offer" ? ["capacity_document_id"] : [])] : []),
|
||||
...(category === "other" ? ["subtype", "purpose", "recipients", "expected_output", "risks"] : []),
|
||||
];
|
||||
const modes = category === "concession" ? ["intention_call", "awarded_right"] : category === "financing" ? ["seeking_funding", "funding_offer"] : category === "supply" ? ["purchase_request", "supply_offer"] : ["classified_other"];
|
||||
return (
|
||||
<fieldset disabled={disabled} className="space-y-4 rounded border p-4">
|
||||
<legend>Contrato da oportunidade — {category}</legend>
|
||||
@@ -603,7 +661,23 @@ export default function OpportunityCanonicalFields({
|
||||
aplicáveis e os recursos guardados. Validação interna não
|
||||
equivale a autorização externa.
|
||||
</p>
|
||||
{options.modularProfiles?.[category] && (
|
||||
<p className="rounded border border-amber-400 p-3">
|
||||
Primeira versão QA: {options.modularProfiles[category]}. Selecione-a explicitamente no campo de versão.
|
||||
Financiamento distingue procura do projeto de oferta do financiador; fornecimento distingue pedido de compra de oferta.
|
||||
Condições propostas não representam compromisso nem decisão externa.
|
||||
</p>
|
||||
)}
|
||||
{Array.from(new Set([...common, ...specialized])).map((key) => {
|
||||
if (key === "module_profile_version") {
|
||||
const current = String(value[key] ?? "");
|
||||
const version = options.modularProfiles?.[category] ?? (["business_sale", "business_partnership", "franchise"].includes(category) ? options.historyVersion : null);
|
||||
return <label key={key} className="block">{labels[key]}<select className={control} value={current} onChange={(e) => set(key, e.target.value || null)}>
|
||||
<option value="">Contrato original / por selecionar</option>
|
||||
{current && current !== version && <option value={current}>{current} — versão existente preservada</option>}
|
||||
{version && <option value={version}>{version}</option>}
|
||||
</select><FieldErrors path={"canonical." + key} errors={errors} /></label>;
|
||||
}
|
||||
if (
|
||||
[
|
||||
"sector_id",
|
||||
@@ -731,7 +805,11 @@ export default function OpportunityCanonicalFields({
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
const schema = options.schema.properties?.[key];
|
||||
let schema = options.schema.properties?.[key];
|
||||
if (key === "module_details" && schema?.properties) {
|
||||
schema = { ...schema, properties: Object.fromEntries(Object.entries(schema.properties).filter(([field]) => modularFields.includes(field))) };
|
||||
schema.properties!.mode = { enum: modes };
|
||||
}
|
||||
return schema ? (
|
||||
<Editor
|
||||
key={key}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ProfileAccess } from '@/types/profile-access';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { logout } from '@/routes';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
||||
import {
|
||||
@@ -11,10 +13,14 @@ import {
|
||||
|
||||
export function PortalGroupedMenu() {
|
||||
const { props, url } = usePage<{
|
||||
auth: { user: unknown; canManagePortal: boolean };
|
||||
auth: {
|
||||
user: { name?: string; email?: string } | null;
|
||||
canManagePortal: boolean;
|
||||
};
|
||||
existingBusinessEnabled?: boolean;
|
||||
educationEnabled?: boolean;
|
||||
aiEnabled?: boolean;
|
||||
legacyContentEnabled?: boolean;
|
||||
portalOperator?: string | null;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
@@ -25,6 +31,7 @@ export function PortalGroupedMenu() {
|
||||
businesses: props.existingBusinessEnabled,
|
||||
education: props.educationEnabled,
|
||||
ai: props.aiEnabled,
|
||||
legacyContent: props.legacyContentEnabled,
|
||||
opportunityManagement: props.opportunityManagement,
|
||||
profileAccess: props.profileAccess,
|
||||
});
|
||||
@@ -33,6 +40,7 @@ export function PortalGroupedMenu() {
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [branch, setBranch] = useState<string | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const root = useRef<HTMLElement>(null);
|
||||
const id = useId();
|
||||
useEffect(() => {
|
||||
@@ -150,6 +158,33 @@ export function PortalGroupedMenu() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{props.auth?.user && (
|
||||
<nav
|
||||
aria-label="Sessão da conta"
|
||||
className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<p className="min-w-0 break-words text-sm text-slate-700 [overflow-wrap:anywhere]">
|
||||
Sessão: {props.auth.user.name || props.auth.user.email}
|
||||
</p>
|
||||
<Link
|
||||
href={logout()}
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={loggingOut}
|
||||
onClick={() => {
|
||||
close();
|
||||
router.flushAll();
|
||||
}}
|
||||
onStart={() => setLoggingOut(true)}
|
||||
onFinish={() => setLoggingOut(false)}
|
||||
data-test="portal-logout-button"
|
||||
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-lg border border-slate-400 bg-white px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<LogOut aria-hidden="true" className="size-4" />
|
||||
{loggingOut ? 'A terminar sessão…' : 'Terminar sessão'}
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
<nav aria-label="Áreas do portal">
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{groups.map((group) => (
|
||||
|
||||
@@ -293,7 +293,7 @@ export function SimulationCashflowPlanner({
|
||||
{projection.vatRefundRecognized}. Estes valores não
|
||||
foram somados à caixa.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto focus-visible:outline-2" tabIndex={0} role="region" aria-label="Tabela do fluxo de caixa">
|
||||
<table className="min-w-full text-sm">
|
||||
<caption className="sr-only">
|
||||
Projecção mensal de tesouraria
|
||||
|
||||
@@ -369,7 +369,7 @@ export function SimulationFinancialTools({
|
||||
efectiva converte a taxa anual para a
|
||||
periodicidade indicada.
|
||||
</p>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
<div className="max-h-64 overflow-auto focus-visible:outline-2" tabIndex={0} role="region" aria-label="Plano de reembolso indicativo">
|
||||
<table className="min-w-full text-xs">
|
||||
<caption className="sr-only">
|
||||
Plano de reembolso indicativo
|
||||
|
||||
@@ -1154,6 +1154,7 @@ export function SimulationForm() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setReady(false);
|
||||
void (async () => {
|
||||
let local: SimulationDraft = {};
|
||||
const requestedDraftId = new URLSearchParams(
|
||||
@@ -1229,6 +1230,7 @@ export function SimulationForm() {
|
||||
}
|
||||
}
|
||||
if (payload.data) {
|
||||
if (cancelled) return;
|
||||
draftIdRef.current = payload.data.id;
|
||||
draftEtagRef.current = response.headers.get('ETag');
|
||||
setDraftVersionNo(payload.data.version_no);
|
||||
@@ -1475,7 +1477,7 @@ export function SimulationForm() {
|
||||
]);
|
||||
|
||||
async function returnToInvestment() {
|
||||
if (!contextId || !draftIdRef.current) return;
|
||||
if (!ready || !contextId || !draftIdRef.current) return;
|
||||
setContinuityError('');
|
||||
try {
|
||||
await saveQueueRef.current;
|
||||
@@ -1496,8 +1498,14 @@ export function SimulationForm() {
|
||||
'Não foi possível recuperar o rascunho guardado.',
|
||||
);
|
||||
if (
|
||||
saved.data.economic_snapshot.financial_tools_snapshot !==
|
||||
draft.financial_tools_snapshot
|
||||
simulationDraftFingerprint(
|
||||
restoreSimulationDraft(
|
||||
saved.data.economic_snapshot,
|
||||
Array.isArray(saved.data.personal_snapshot)
|
||||
? {}
|
||||
: saved.data.personal_snapshot,
|
||||
),
|
||||
) !== simulationDraftFingerprint(draft)
|
||||
)
|
||||
throw new Error(
|
||||
'Aguarde a gravação das entradas da ferramenta e tente novamente.',
|
||||
@@ -1912,6 +1920,7 @@ export function SimulationForm() {
|
||||
value: string | string[],
|
||||
label?: string | string[],
|
||||
) {
|
||||
if (!ready) return;
|
||||
setContinueLaterSaved(false);
|
||||
setServerFieldErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
@@ -2035,6 +2044,7 @@ export function SimulationForm() {
|
||||
}
|
||||
|
||||
function navigate(nextStep: number) {
|
||||
if (!ready) return;
|
||||
setOpenedTool(null);
|
||||
if (nextStep > step) {
|
||||
for (let index = step; index < nextStep; index++) {
|
||||
@@ -2390,7 +2400,12 @@ export function SimulationForm() {
|
||||
<section
|
||||
className="space-y-6 rounded-xl border bg-white p-4 sm:p-6"
|
||||
aria-labelledby="simulator-step-title"
|
||||
aria-busy={!ready}
|
||||
>
|
||||
{!ready && (
|
||||
<p role="status">A recuperar o rascunho. Aguarde para editar.</p>
|
||||
)}
|
||||
<fieldset disabled={!ready} className="min-w-0 space-y-6">
|
||||
{canSavePersonalData && draftVersionNo && draftIdRef.current && (
|
||||
<a
|
||||
className="inline-block underline"
|
||||
@@ -4336,6 +4351,7 @@ export function SimulationForm() {
|
||||
associados à conta. A submissão formal ainda não está disponível
|
||||
neste formulário.
|
||||
</p>
|
||||
</fieldset>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,6 +165,8 @@ function Sidebar({
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
role="complementary"
|
||||
aria-label="Navegação da conta"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
@@ -179,10 +181,6 @@ function Sidebar({
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
@@ -195,6 +193,10 @@ function Sidebar({
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navegação da conta</SheetTitle>
|
||||
<SheetDescription>Menu móvel do portal.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -209,6 +211,8 @@ function Sidebar({
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
role="complementary"
|
||||
aria-label="Navegação da conta"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
|
||||
@@ -55,7 +55,7 @@ export function UserMenuContent({ user }: Props) {
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut className="mr-2" />
|
||||
Log out
|
||||
Terminar sessão
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,8 @@ export const portalLinks = [
|
||||
['/apoio', 'Apoio ao Investidor'],
|
||||
] as const;
|
||||
const names: Record<string, string> = {
|
||||
'/explorar/agronegocio': 'Explorar agronegócio',
|
||||
'/agronegocio': 'Artigo de agronegócio',
|
||||
'/investidor/negocios': 'Os meus negócios',
|
||||
'/management/negocios': 'Revisão de negócios',
|
||||
...Object.fromEntries(portalLinks),
|
||||
@@ -92,13 +94,15 @@ export default function PortalNavigation({
|
||||
return (
|
||||
<MenuPlacement.Provider value={true}>
|
||||
<div className="pb-8 lg:pb-24 [&_button]:scroll-mb-24 [&_input]:scroll-mb-24 [&_select]:scroll-mb-24 [&_textarea]:scroll-mb-24">
|
||||
{portalShell && <PortalMenu primary />}
|
||||
<nav aria-label="Atalho para o conteúdo">
|
||||
<a
|
||||
href="#portal-content"
|
||||
className="sr-only focus:not-sr-only focus:block focus:bg-white focus:p-4 focus:text-emerald-900"
|
||||
>
|
||||
Saltar para o conteúdo
|
||||
</a>
|
||||
</nav>
|
||||
{portalShell && <PortalMenu primary />}
|
||||
<nav
|
||||
aria-label="Caminho de navegação"
|
||||
className="bg-background text-foreground flex flex-wrap items-center gap-2 border-b px-5 py-3 text-sm"
|
||||
@@ -127,12 +131,14 @@ export default function PortalNavigation({
|
||||
{portalShell && <JourneyCompanion />}
|
||||
{children}
|
||||
</div>
|
||||
<aside aria-label="Contacto de apoio ao investidor">
|
||||
<a
|
||||
href="/apoio"
|
||||
className="mx-3 mt-4 block max-w-[calc(100%-1.5rem)] rounded-xl bg-emerald-800 px-5 py-3 text-center text-sm font-semibold text-white shadow-xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-600"
|
||||
>
|
||||
Contactar Apoio ao Investidor
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</MenuPlacement.Provider>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ export type PortalNavAccess = {
|
||||
businesses?: boolean;
|
||||
education?: boolean;
|
||||
ai?: boolean;
|
||||
legacyContent?: boolean;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
};
|
||||
@@ -32,6 +33,15 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
children: [
|
||||
link('opportunities', 'Oportunidades', '/oportunidades'),
|
||||
link('sectors', 'Sectores', '/sectores'),
|
||||
...(access.legacyContent
|
||||
? [
|
||||
link(
|
||||
'legacy-agronegocio',
|
||||
'Explorar agronegócio',
|
||||
'/explorar/agronegocio',
|
||||
),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'structures',
|
||||
label: 'Carteira e estruturas',
|
||||
@@ -279,14 +289,64 @@ 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)] : []),
|
||||
]});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ function TopBar({
|
||||
requestCount: number;
|
||||
}) {
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur">
|
||||
<nav aria-label="Navegação das manifestações" className="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-5 py-4">
|
||||
<button
|
||||
onClick={() => onNavigate('home')}
|
||||
@@ -129,7 +129,7 @@ function TopBar({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -863,6 +863,7 @@ function InterestForm({
|
||||
<Field label="País" error={errors.country}>
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
label="País do requerente"
|
||||
value={form.country}
|
||||
onChange={(value) => update('country', value)}
|
||||
required
|
||||
@@ -876,6 +877,7 @@ function InterestForm({
|
||||
>
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
label="País de origem declarado desta solicitação"
|
||||
value={form.declared_origin_country_code ?? ''}
|
||||
onChange={(value) =>
|
||||
update('declared_origin_country_code', value)
|
||||
@@ -1100,7 +1102,7 @@ export default function PortalInvestidorApp({
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||
<TopBar onNavigate={navigate} requestCount={requestCount} />
|
||||
<PortalMenu />
|
||||
|
||||
<main>
|
||||
{view === 'home' && (
|
||||
<>
|
||||
<Hero
|
||||
@@ -1329,6 +1331,7 @@ export default function PortalInvestidorApp({
|
||||
/>
|
||||
)}
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
<style>{`
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function Dashboard({
|
||||
} | null;
|
||||
}) {
|
||||
return (
|
||||
<main className="space-y-8 p-4 md:p-8">
|
||||
<div className="space-y-8 p-4 md:p-8">
|
||||
<Head title="Painel" />
|
||||
<h1 className="text-2xl font-semibold">Painel do portal</h1>
|
||||
<ProfileNavigation />
|
||||
@@ -305,7 +305,7 @@ export default function Dashboard({
|
||||
<Link href="/" className="inline-block underline">
|
||||
Voltar ao portal público
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Dashboard.layout = { breadcrumbs: [{ title: 'Painel', href: dashboard() }] };
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function RequestPage({
|
||||
payments,
|
||||
caseVersion,
|
||||
countries,
|
||||
toolPrefill,
|
||||
}: {
|
||||
request: {
|
||||
id: string;
|
||||
@@ -80,6 +81,12 @@ export default function RequestPage({
|
||||
payments: DeskPayment[];
|
||||
caseVersion: number;
|
||||
countries: Country[];
|
||||
toolPrefill: {
|
||||
description: string;
|
||||
draft_id: string;
|
||||
draft_version: number;
|
||||
snapshot_sha256: string;
|
||||
} | null;
|
||||
events: {
|
||||
id: string;
|
||||
action: string;
|
||||
@@ -142,6 +149,29 @@ export default function RequestPage({
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
{canEdit && toolPrefill && (
|
||||
<section className={card + ' space-y-3'} aria-label="Descrição guardada no projeto">
|
||||
<h2 className="text-xl font-bold">Reutilizar dados do projeto</h2>
|
||||
<p>A descrição está vazia neste pedido. Pode reutilizar o resumo já guardado na ferramenta deste dossier, versão {toolPrefill.draft_version}.</p>
|
||||
<blockquote className="rounded border p-3 whitespace-pre-wrap">{toolPrefill.description}</blockquote>
|
||||
<p>Esta ação guarda o rascunho com os dados preenchidos nesta página. Reveja-os antes de submeter.</p>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={form.processing || !!form.data.payload.description?.trim()}
|
||||
onClick={() => {
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
version: request.version,
|
||||
submit: false,
|
||||
payload: {...data.payload, description: toolPrefill.description},
|
||||
tool_prefill: {draft_id:toolPrefill.draft_id, draft_version:toolPrefill.draft_version, snapshot_sha256:toolPrefill.snapshot_sha256},
|
||||
}));
|
||||
form.put(`/balcao-virtual/pedidos/${request.id}`, {onSuccess: () => form.setData('payload', {...form.data.payload, description:toolPrefill.description})});
|
||||
}}
|
||||
>Reutilizar descrição guardada e guardar rascunho</button>
|
||||
</section>
|
||||
)}
|
||||
<section className={card + ' space-y-3'}>
|
||||
<h2 className="text-xl font-bold">
|
||||
Verificações automáticas de preparação
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import type { LegacyArticle } from '@/pages/portal/legacy';
|
||||
|
||||
export type InvestmentContext = {
|
||||
id: string;
|
||||
@@ -10,15 +11,31 @@ export type InvestmentContext = {
|
||||
|
||||
export default function InvestmentContexts({
|
||||
contexts,
|
||||
contentSource,
|
||||
}: {
|
||||
contexts: InvestmentContext[];
|
||||
contentSource?: LegacyArticle;
|
||||
}) {
|
||||
const [key] = useState(() => globalThis.crypto.randomUUID());
|
||||
const form = useForm({ title: '', client_key: key, reuse_consent: false });
|
||||
const form = useForm({
|
||||
title: '',
|
||||
client_key: key,
|
||||
reuse_consent: false,
|
||||
content_resource_id: contentSource?.id,
|
||||
});
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl space-y-6 p-4 md:p-8">
|
||||
<Head title="Os meus investimentos" />
|
||||
<h1 className="text-2xl font-semibold">Os meus investimentos</h1>
|
||||
{contentSource && (
|
||||
<p>
|
||||
Preparar a partir de{' '}
|
||||
<Link className="underline" href={contentSource.href}>
|
||||
{contentSource.title}
|
||||
</Link>
|
||||
. A fonte e a versão serão guardadas no dossier escolhido.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Prepare cada investimento no seu dossier. Pode trabalhar em
|
||||
várias etapas em paralelo.
|
||||
@@ -36,6 +53,23 @@ export default function InvestmentContexts({
|
||||
Guardado em{' '}
|
||||
{new Date(item.updated_at).toLocaleString('pt-MZ')}
|
||||
</p>
|
||||
{contentSource && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 rounded border p-2"
|
||||
onClick={() =>
|
||||
router.post(
|
||||
`/investimentos/${item.id}/conteudos`,
|
||||
{
|
||||
version: item.version,
|
||||
resource_id: contentSource.id,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Continuar neste dossier com o artigo
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -31,6 +31,13 @@ type Path = {
|
||||
next: { code: string; title: string; href: string; action: string } | null;
|
||||
tools: Record<string, string | null>;
|
||||
results: PathResult[];
|
||||
content_sources: {
|
||||
resource_id: number;
|
||||
title: string;
|
||||
return_href: string;
|
||||
source_url: string;
|
||||
metadata_version: number;
|
||||
}[];
|
||||
documents: {
|
||||
id: string;
|
||||
document_id: string;
|
||||
@@ -349,12 +356,43 @@ export default function InvestmentPath(path: Path) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl space-y-6 p-4 md:p-8">
|
||||
<main className="mx-auto min-w-0 max-w-6xl space-y-6 p-4 [overflow-wrap:anywhere] [&_label]:min-w-0 [&_select]:min-w-0 [&_select]:max-w-full [&_input]:min-w-0 [&_input]:max-w-full md:p-8">
|
||||
<Head title={path.case.title} />
|
||||
<Link className="underline" href="/investimentos">
|
||||
Os meus investimentos
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">{path.case.title}</h1>
|
||||
{!!path.content_sources?.length && (
|
||||
<section
|
||||
className="space-y-2 rounded-xl border p-4"
|
||||
aria-label="Fontes da ideia"
|
||||
>
|
||||
<h2 className="font-semibold">
|
||||
Conteúdos associados à ideia
|
||||
</h2>
|
||||
<ul>
|
||||
{path.content_sources.map((source) => (
|
||||
<li
|
||||
key={`${source.resource_id}-${source.metadata_version}`}
|
||||
>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`${source.return_href}?context=${path.case.id}`}
|
||||
>
|
||||
{source.title}
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/documentos/${source.resource_id}`}
|
||||
>
|
||||
Exemplar e origem
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{flash?.success && <output>{flash.success}</output>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<section className="rounded-xl border p-4">
|
||||
@@ -580,7 +618,7 @@ export default function InvestmentPath(path: Path) {
|
||||
</h2>
|
||||
{path.canWrite && (
|
||||
<form
|
||||
className="grid gap-3"
|
||||
className="grid min-w-0 gap-3 [&_label]:min-w-0 [&_select]:min-w-0 [&_select]:max-w-full [&_input]:min-w-0 [&_input]:max-w-full"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
task.transform((data) => ({
|
||||
@@ -630,7 +668,7 @@ export default function InvestmentPath(path: Path) {
|
||||
<input
|
||||
required
|
||||
type="datetime-local"
|
||||
className="rounded border p-2"
|
||||
className="w-full min-w-0 rounded border p-2"
|
||||
value={task.data.due_at}
|
||||
onChange={(e) =>
|
||||
task.setData('due_at', e.target.value)
|
||||
|
||||
@@ -157,6 +157,7 @@ export default function OpportunityResources({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const foreignCountry = Boolean(payload.country_code && payload.country_code !== 'MZ');
|
||||
const set = (name: string, value: Payload[string]) =>
|
||||
setPayload((previous) => ({ ...previous, [name]: value }));
|
||||
function select(nextKind: Kind, record: Resource | null = null) {
|
||||
@@ -239,7 +240,7 @@ export default function OpportunityResources({
|
||||
}
|
||||
setMessage(data.message);
|
||||
setEditing(null);
|
||||
setPayload({});
|
||||
setPayload(kind === 'locations' ? {country_code:'MZ'} : {});
|
||||
setPath([]);
|
||||
setGeometry('');
|
||||
setFile(null);
|
||||
@@ -272,8 +273,9 @@ export default function OpportunityResources({
|
||||
</Link>
|
||||
<p>
|
||||
Recursos de preparação. Não alteram automaticamente a ficha
|
||||
pública ou relações territoriais aceites. ClamD real pendente;
|
||||
scan simulado nunca verifica ficheiros reais.
|
||||
pública ou relações territoriais aceites. O estado e o motor
|
||||
do scan são apresentados por ficheiro e versão; um scan
|
||||
simulado nunca verifica ficheiros reais.
|
||||
</p>
|
||||
<div
|
||||
id="resource-result"
|
||||
@@ -300,9 +302,8 @@ export default function OpportunityResources({
|
||||
<p className="rounded border p-3">
|
||||
Pode guardar anexos para preparação. Cada ficheiro permanece
|
||||
sem download ou pré-visualização enquanto o seu scan não
|
||||
estiver concluído. ClamD pendente não impede guardar
|
||||
metadados, locais ou rascunhos; testes com scanner simulado
|
||||
não verificam os seus ficheiros.
|
||||
estiver concluído. Guardar metadados, locais ou rascunhos
|
||||
é independente do scan e não publica ficheiros.
|
||||
</p>
|
||||
)}
|
||||
<section>
|
||||
@@ -411,10 +412,37 @@ 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>}
|
||||
<Field label="País da localização" error={errors.country_code}>
|
||||
<select
|
||||
className={control}
|
||||
disabled={readOnly}
|
||||
value={String(payload.country_code ?? '')}
|
||||
onChange={(event) => {
|
||||
const country = event.target.value;
|
||||
set('country_code', country || null);
|
||||
if (country && country !== 'MZ' && path.length) {
|
||||
setPath([]);
|
||||
setMessage('A seleção territorial de Moçambique foi retirada do preenchimento ao mudar de país. Guarde para registar a alteração; a geometria própria foi preservada.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{countries.map(c=><option key={c.code} value={c.code}>{c.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{foreignCountry && (
|
||||
<p>A hierarquia abaixo corresponde a Moçambique e não é aplicável ao país escolhido. Pode guardar uma localização incompleta noutro país sem esta hierarquia.</p>
|
||||
)}
|
||||
{foreignCountry && path.length > 0 && (
|
||||
<p role="alert">
|
||||
Este registo conserva uma seleção territorial de Moçambique incompatível com o país. Escolha Moçambique ou{' '}
|
||||
<button type="button" className="underline" disabled={readOnly} onClick={() => setPath([])}>
|
||||
retirar a seleção territorial deste preenchimento
|
||||
</button>. O registo guardado só muda quando guardar.
|
||||
</p>
|
||||
)}
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={path.at(-1)?.id}
|
||||
territoryId={foreignCountry ? undefined : path.at(-1)?.id}
|
||||
management
|
||||
endpoint={basePath === '/minhas-oportunidades' ? '/minhas-oportunidades/territory-geometries' : undefined}
|
||||
/>
|
||||
@@ -424,6 +452,7 @@ export default function OpportunityResources({
|
||||
index={index}
|
||||
parent={path[index - 1]?.id ?? ''}
|
||||
selected={path[index]}
|
||||
disabled={readOnly || foreignCountry}
|
||||
endpoint={basePath === '/minhas-oportunidades' ? '/minhas-oportunidades/territory-options' : '/management/territory-options'}
|
||||
onChange={(value) =>
|
||||
setPath((previous) =>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function Management({
|
||||
aiEnabled?: boolean;
|
||||
}>().props;
|
||||
return (
|
||||
<main className="space-y-6 p-4 md:p-8">
|
||||
<div className="space-y-6 p-4 md:p-8">
|
||||
<Head title={title} />
|
||||
{auth?.canManagePortal && basePath !== '/minhas-oportunidades' ? <nav
|
||||
className="flex flex-wrap gap-4 text-sm underline"
|
||||
@@ -116,7 +116,7 @@ export function Management({
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
|
||||
export type LegacyArticle = {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
legacy_id: number;
|
||||
legacy_category: string;
|
||||
published_at: string;
|
||||
modified_at: string;
|
||||
source_url: string;
|
||||
href: string;
|
||||
landing_href: string;
|
||||
start_href: string;
|
||||
group: { label: string; heading: string };
|
||||
related_articles: { id: number; slug: string; title: string; href: string }[];
|
||||
html: string;
|
||||
library_href: string;
|
||||
image: { href: string; alt: string; source_url: string } | null;
|
||||
sector: { name: string; slug: string } | null;
|
||||
territorial_references: string[];
|
||||
territorial_status: string;
|
||||
opportunities: {
|
||||
title: string;
|
||||
slug: string;
|
||||
province: string;
|
||||
summary: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function LegacySector({
|
||||
article,
|
||||
articles = [article],
|
||||
view,
|
||||
query = '',
|
||||
match = true,
|
||||
contentContext,
|
||||
}: {
|
||||
article: LegacyArticle;
|
||||
articles?: LegacyArticle[];
|
||||
view: 'landing' | 'article';
|
||||
query?: string;
|
||||
match?: boolean;
|
||||
contentContext?: { id: string; title: string } | null;
|
||||
}) {
|
||||
const search = useForm({ q: query });
|
||||
return (
|
||||
<main className="legacy-agronegocio mx-auto max-w-6xl space-y-8 rounded-2xl bg-[#f6f3ea] p-4 sm:p-8">
|
||||
<Head
|
||||
title={
|
||||
view === 'landing' ? `Explorar ${article.group.label}` : article.title
|
||||
}
|
||||
/>
|
||||
<style>{`
|
||||
.legacy-agronegocio { color: #10352b; font-family: 'Noto Sans', system-ui, sans-serif; }
|
||||
.legacy-agronegocio h1, .legacy-agronegocio h2 { font-family: 'Noto Serif', Georgia, serif; }
|
||||
.legacy-agronegocio a, .legacy-agronegocio button { outline-offset: 4px; }
|
||||
.legacy-agronegocio a:focus-visible, .legacy-agronegocio button:focus-visible { outline: 3px solid #b22222; }
|
||||
.legacy-article { font-size: 1.0625rem; line-height: 1.8; overflow-wrap: anywhere; }
|
||||
.legacy-article p, .legacy-article ul, .legacy-article ol { margin-block: 1.2em; }
|
||||
.legacy-article ul { list-style: disc; padding-inline-start: 1.6em; }
|
||||
.legacy-article ol { list-style: decimal; padding-inline-start: 1.6em; }
|
||||
.legacy-article a { text-decoration: underline; }
|
||||
`}</style>
|
||||
<nav className="flex flex-wrap gap-4" aria-label={article.group.label}>
|
||||
<Link href={article.landing_href} className="underline">
|
||||
Explorar {article.group.label}
|
||||
</Link>
|
||||
<Link href="/sectores" className="underline">
|
||||
Todos os setores
|
||||
</Link>
|
||||
<Link href={article.library_href} className="underline">
|
||||
Exemplar na biblioteca
|
||||
</Link>
|
||||
</nav>
|
||||
<header className="overflow-hidden rounded-2xl border bg-white">
|
||||
{article.image && (
|
||||
<img
|
||||
className="aspect-video w-full object-cover"
|
||||
src={article.image.href}
|
||||
alt={article.image.alt}
|
||||
width={900}
|
||||
height={506}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-3 p-5 sm:p-8">
|
||||
<p className="text-sm font-semibold tracking-wide text-[#b22222] uppercase">
|
||||
Conhecer · preparar · acompanhar
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold sm:text-4xl">
|
||||
{view === 'landing'
|
||||
? article.group.heading
|
||||
: article.title}
|
||||
</h1>
|
||||
<p>
|
||||
Explore o setor e prepare a sua ideia com o contexto
|
||||
guardado no dossier.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Fonte: {article.author} · Publicado em{' '}
|
||||
<time dateTime={article.published_at}>
|
||||
{article.published_at.slice(0, 10)}
|
||||
</time>{' '}
|
||||
· Atualizado em{' '}
|
||||
<time dateTime={article.modified_at}>
|
||||
{article.modified_at.slice(0, 10)}
|
||||
</time>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
{view === 'landing' ? (
|
||||
<section className="space-y-4" aria-labelledby="learn-title">
|
||||
<h2 id="learn-title" className="text-2xl font-semibold">
|
||||
Compreender o setor
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
search.get(article.landing_href);
|
||||
}}
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
<label className="grid flex-1 gap-1">
|
||||
Pesquisar no conteúdo
|
||||
<input
|
||||
className="min-w-0 rounded border p-3"
|
||||
value={search.data.q}
|
||||
onChange={(e) =>
|
||||
search.setData('q', e.target.value)
|
||||
}
|
||||
maxLength={120}
|
||||
type="search"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="self-end rounded bg-[#b22222] px-5 py-3 text-white"
|
||||
disabled={search.processing}
|
||||
>
|
||||
Pesquisar
|
||||
</button>
|
||||
</form>
|
||||
{match ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{articles.map((item) => (
|
||||
<article key={item.id} className="space-y-3 rounded-xl border bg-white p-5">
|
||||
<h3 className="text-xl font-semibold">
|
||||
<Link className="underline" href={item.href}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<p>
|
||||
Conteúdo editorial preservado do portal Tempo.
|
||||
</p>
|
||||
<Link
|
||||
className="inline-block rounded bg-[#10352b] px-5 py-3 text-white"
|
||||
href={item.href}
|
||||
>
|
||||
Ler o artigo
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p role="status">
|
||||
Nenhum conteúdo corresponde à pesquisa.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section
|
||||
aria-label="Artigo educativo"
|
||||
className="legacy-article max-w-3xl"
|
||||
dangerouslySetInnerHTML={{ __html: article.html }}
|
||||
/>
|
||||
)}
|
||||
{view === 'article' && article.related_articles.length > 0 && (
|
||||
<section className="space-y-3" aria-labelledby="related-title">
|
||||
<h2 id="related-title" className="text-2xl font-semibold">Outras leituras no setor</h2>
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{article.related_articles.map((item) => (
|
||||
<li key={item.id}><Link className="underline" href={item.href}>{item.title}</Link></li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className="space-y-4 rounded-xl border bg-white p-5"
|
||||
aria-labelledby="locations-title"
|
||||
>
|
||||
<h2 id="locations-title" className="text-2xl font-semibold">
|
||||
Contexto territorial
|
||||
</h2>
|
||||
<ul className="flex flex-wrap gap-3">
|
||||
{article.territorial_references.map((name) => (
|
||||
<li key={name} className="rounded border px-3 py-2">
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>{article.territorial_status}</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link href="/carteira?kind=corridor" className="underline">
|
||||
Consultar os corredores cadastrados
|
||||
</Link>
|
||||
<Link href="/zonas" className="underline">
|
||||
Consultar localizações e mapas disponíveis
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="space-y-4"
|
||||
aria-labelledby="opportunities-title"
|
||||
>
|
||||
<h2 id="opportunities-title" className="text-2xl font-semibold">
|
||||
Oportunidades publicadas no setor
|
||||
</h2>
|
||||
<p>
|
||||
Cadastros independentes do artigo, com a sua própria autoria
|
||||
e situação de publicação.
|
||||
</p>
|
||||
{article.opportunities.length ? (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{article.opportunities.map((o) => (
|
||||
<li
|
||||
key={o.slug}
|
||||
className="rounded-xl border bg-white p-5"
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/oportunidades/${o.slug}`}
|
||||
>
|
||||
{o.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<p>{o.province}</p>
|
||||
<p>{o.summary}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>
|
||||
Ainda não há oportunidades publicadas neste setor. Pode
|
||||
desenvolver uma ideia no seu dossier.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<section
|
||||
className="space-y-3 rounded-xl bg-[#10352b] p-6 text-white"
|
||||
aria-labelledby="prepare-title"
|
||||
>
|
||||
<h2 id="prepare-title" className="text-2xl font-semibold">
|
||||
Preparar a sua ideia
|
||||
</h2>
|
||||
<p>
|
||||
A leitura é pública. Para guardar dados pessoais, escolha ou
|
||||
crie um dossier na sua conta.
|
||||
</p>
|
||||
<Link
|
||||
className="inline-block rounded bg-white px-5 py-3 font-semibold text-[#10352b]"
|
||||
href={
|
||||
contentContext
|
||||
? `/investimentos/${contentContext.id}`
|
||||
: article.start_href
|
||||
}
|
||||
>
|
||||
{contentContext
|
||||
? `Continuar: ${contentContext.title}`
|
||||
: 'Iniciar ou continuar uma ideia'}
|
||||
</Link>
|
||||
</section>
|
||||
<footer className="space-y-2 border-t pt-4 text-sm">
|
||||
<p>
|
||||
Conteúdo editorial de {article.author}. Categoria original:{' '}
|
||||
{article.legacy_category}. Não representa uma oportunidade
|
||||
concreta, autorização de investimento ou cálculo.
|
||||
</p>
|
||||
<a
|
||||
className="underline"
|
||||
href={article.source_url}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Consultar a página original
|
||||
</a>
|
||||
{article.image && (
|
||||
<p>
|
||||
<a
|
||||
className="underline"
|
||||
href={article.image.source_url}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Origem da imagem
|
||||
</a>{' '}
|
||||
· Autoria individual e data de criação não indicadas no
|
||||
legado.
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +161,9 @@ export default function Manage({
|
||||
requirementCatalogue,
|
||||
requirementAssociationsEnabled,
|
||||
disclosureAuthorization,
|
||||
legacyContentEnabled = false,
|
||||
}: {
|
||||
legacyContentEnabled?: boolean;
|
||||
documents: DocumentFile[];
|
||||
resources: Resource[];
|
||||
requirementCatalogue: { key: string; label: string; tax: string }[];
|
||||
@@ -533,9 +535,9 @@ export default function Manage({
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Assunto">
|
||||
<Field label={legacyContentEnabled ? 'Assunto (opcional)' : 'Assunto'}>
|
||||
<input
|
||||
required
|
||||
required={!legacyContentEnabled}
|
||||
className={control}
|
||||
value={form.data.topic}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -264,6 +264,7 @@ function RequestForm({
|
||||
{field === 'country' ? (
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
label="País do requerente"
|
||||
value={form.data.country}
|
||||
onChange={(value) =>
|
||||
form.setData('country', value)
|
||||
@@ -290,6 +291,7 @@ function RequestForm({
|
||||
País de origem declarado desta solicitação
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
label="País de origem declarado desta solicitação"
|
||||
value={form.data.declared_origin_country_code}
|
||||
onChange={(value) =>
|
||||
form.setData('declared_origin_country_code', value)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
<html lang="{{ str_replace('_', '-', config('portal.interface_language', app()->getLocale())) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InvestorPathController as Path;
|
||||
use App\Http\Controllers\LegacyContentController;
|
||||
use App\Http\Middleware\PrivateInvestmentResponse;
|
||||
use App\Http\Middleware\VirtualDeskInstalled;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, VirtualDeskInstalled::class, 'throttle:60,1'])->prefix('investimentos')->group(function () {
|
||||
Route::get('/', [Path::class, 'index']);
|
||||
Route::get('/a-partir-de/{legacySlug}', [LegacyContentController::class, 'start'])->whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
|
||||
Route::post('/{id}/conteudos', [LegacyContentController::class, 'attach'])->whereUuid('id');
|
||||
Route::post('/', [Path::class, 'create']);
|
||||
Route::get('/{id}', [Path::class, 'show'])->whereUuid('id');
|
||||
Route::post('/{id}/aplicabilidade', [Path::class, 'facts'])->whereUuid('id');
|
||||
|
||||
+8
-3
@@ -22,11 +22,13 @@ use App\Http\Controllers\InvestmentStructureController;
|
||||
use App\Http\Controllers\InvestmentStructureRelationController;
|
||||
use App\Http\Controllers\InvestmentTerritoryController;
|
||||
use App\Http\Controllers\InvestorWorkspaceController;
|
||||
use App\Http\Controllers\LegacyContentController;
|
||||
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\PortalProfileInstallationController;
|
||||
use App\Http\Controllers\PortalServicesController;
|
||||
use App\Http\Controllers\PublicBusinessOpportunityController;
|
||||
use App\Http\Controllers\PublicGeographyController;
|
||||
@@ -51,6 +53,8 @@ Route::get('/Simulador', [PortalServicesController::class, 'page'])->defaults('p
|
||||
Route::get('/documentos/{id}', [PortalServicesController::class, 'showDocument'])->whereNumber('id')->name('services.document');
|
||||
Route::get('/documentos/{id}/view', [PortalServicesController::class, 'viewDocument'])->whereNumber('id')->name('services.document.view');
|
||||
Route::get('/documentos/{id}/download', [PortalServicesController::class, 'download'])->whereNumber('id')->name('services.download');
|
||||
Route::get('/explorar/{sectorSlug}', [LegacyContentController::class, 'landing'])->whereIn('sectorSlug', array_keys(config('legacy_content.groups', [])));
|
||||
Route::get('/{legacySlug}', [LegacyContentController::class, 'article'])->whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
|
||||
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk');
|
||||
@@ -112,9 +116,9 @@ Route::get('/api/v1/investment/territory-geometries', [InvestmentTerritoryContro
|
||||
|
||||
// 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::get('/management/profile-install', [PortalProfileInstallationController::class, 'preview']);
|
||||
Route::post('/management/profile-install', [PortalProfileInstallationController::class, 'apply']);
|
||||
Route::post('/management/profile-install/bootstrap', [PortalProfileInstallationController::class, 'bootstrap']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(function () {
|
||||
@@ -138,6 +142,7 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
|
||||
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::get('/minhas-oportunidades/{id}/response', [OpportunityManagementController::class, 'canonicalResponse'])->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');
|
||||
|
||||
@@ -50,6 +50,36 @@ class InvestorPathTest extends TestCase
|
||||
'incomes' => [['label' => 'Receitas QA', 'amount' => '100']], 'expenses' => [['label' => 'Custos QA', 'amount' => '50']]]])]];
|
||||
}
|
||||
|
||||
public function test_existing_empty_desk_draft_can_explicitly_reuse_current_contextual_summary_without_overwriting_data(): void
|
||||
{
|
||||
$service = $this->actingAs($this->manager)->postJson('/management/balcao/servicos', ['code' => 'project-preparation', 'confirm' => true])->assertCreated()->json('id');
|
||||
$id = $this->context('Dossier anterior à ferramenta');
|
||||
$support = $this->postJson('/balcao-virtual/dossiers/'.$id.'/pedidos', ['service_version_id' => $service, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
|
||||
$data = [...$this->snapshot('Resumo posterior'), 'context' => $id];
|
||||
$draft = $this->postJson('/api/v1/simulation-drafts', $data, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
|
||||
$initial = $this->getJson('/balcao-virtual/pedidos/'.$support)->assertOk()->assertJsonPath('request.payload.description', '')->json();
|
||||
$preview = $initial['toolPrefill'] ?? null;
|
||||
$this->assertNotNull($preview, 'The existing empty draft needs an explicit contextual reuse action.');
|
||||
$this->assertSame($draft, $preview['draft_id']);
|
||||
$this->assertSame($data['economic_snapshot']['project_summary'], $preview['description']);
|
||||
$this->actingAs($this->manager)->getJson('/balcao-virtual/pedidos/'.$support)->assertJsonPath('toolPrefill', null);
|
||||
$this->actingAs($this->owner);
|
||||
$payload = [...$initial['request']['payload'], 'description' => $preview['description'], 'country' => 'PT', 'declared_origin_country_code' => 'ZA'];
|
||||
$source = ['draft_id' => $preview['draft_id'], 'draft_version' => $preview['draft_version'], 'snapshot_sha256' => $preview['snapshot_sha256']];
|
||||
$this->putJson('/balcao-virtual/pedidos/'.$support, ['version' => $initial['request']['version'], 'payload' => $payload, 'tool_prefill' => [...$source, 'draft_id' => (string) Str::uuid()]])->assertUnprocessable();
|
||||
$this->putJson('/balcao-virtual/pedidos/'.$support, ['version' => $initial['request']['version'], 'payload' => $payload, 'tool_prefill' => [...$source, 'draft_version' => $source['draft_version'] + 1]])->assertUnprocessable();
|
||||
$this->getJson('/balcao-virtual/pedidos/'.$support)->assertJsonPath('request.version', $initial['request']['version'])->assertJsonPath('request.payload.description', '');
|
||||
$this->assertDatabaseCount('desk_revisions', 0);
|
||||
$this->putJson('/balcao-virtual/pedidos/'.$support, ['version' => $initial['request']['version'], 'payload' => $payload, 'tool_prefill' => $source])->assertOk()->assertJsonPath('status', 'draft');
|
||||
$recovered = $this->getJson('/balcao-virtual/pedidos/'.$support)->assertJsonPath('request.payload.description', $preview['description'])->assertJsonPath('request.payload.country', 'PT')->assertJsonPath('request.payload.declared_origin_country_code', 'ZA')->assertJsonPath('toolPrefill', null)->json();
|
||||
$this->assertSame('Dossier anterior à ferramenta', $recovered['request']['payload']['title']);
|
||||
$this->assertDatabaseCount('desk_requests', 1);
|
||||
$this->assertDatabaseCount('desk_revisions', 1);
|
||||
$this->assertDatabaseHas('desk_events', ['request_id' => $support, 'action' => 'request_prefill_reused']);
|
||||
$this->putJson('/balcao-virtual/pedidos/'.$support, ['version' => $recovered['request']['version'], 'payload' => [...$payload, 'description' => 'Tentativa de substituir texto existente'], 'tool_prefill' => $source])->assertUnprocessable();
|
||||
$this->actingAs(User::factory()->create())->putJson('/balcao-virtual/pedidos/'.$support, ['version' => $recovered['request']['version'], 'payload' => $payload, 'tool_prefill' => $source])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_two_independent_investments_recover_tool_evidence_support_and_internal_tasks_without_external_calls(): void
|
||||
{
|
||||
$service = $this->actingAs($this->manager)->postJson('/management/balcao/servicos', ['code' => 'project-preparation', 'confirm' => true])->assertCreated()->json('id');
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\LegacyContent;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LegacyContentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private int $resource;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
Storage::fake('local');
|
||||
config(['portal.legacy_content_qa_enabled' => true, 'investor-path.enabled' => true]);
|
||||
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'source_url' => 'https://investemoz.tempo.co.mz/agronegocio',
|
||||
'content_html' => '<p>Conhecer corredores</p><script>secret()</script><a href="javascript:alert(1)" onclick="bad()">Link</a>',
|
||||
'title' => 'Agronegocio', 'author' => 'Autor sintético', 'legacy_id' => 171985, 'legacy_category' => 'Oportunidades',
|
||||
'published_at' => '2026-04-06T09:19:07+00:00', 'modified_at' => '2026-04-20T12:41:43+00:00'], JSON_THROW_ON_ERROR);
|
||||
$id = (string) Str::uuid();
|
||||
Storage::disk('local')->put('legacy/'.$id, $bytes);
|
||||
DB::table('documents')->insert(['id' => $id, 'source_system' => 'synthetic-test', 'external_id' => 'legacy-tempo-post-171985-20260420',
|
||||
'file_name' => 'source.json', 'media_type' => 'application/json', 'storage_key' => 'legacy/'.$id,
|
||||
'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes), 'malware_scan_status' => 'clean',
|
||||
'size_bytes' => strlen($bytes), 'status' => 'active', 'rights_status' => 'authorized', 'data_classification' => 'public']);
|
||||
$this->resource = DB::table('portal_resources')->insertGetId(['document_id' => $id, 'title' => 'Agronegocio',
|
||||
'category' => 'education', 'topic' => 'agronegocio', 'published' => true, 'rights_status' => 'authorized',
|
||||
'source_url' => 'https://investemoz.tempo.co.mz/agronegocio', 'source_reference' => 'Exemplar sintético; não oficial.', 'metadata_version' => 1]);
|
||||
}
|
||||
|
||||
public function test_public_read_search_and_sanitization_do_not_create_opportunities_and_private_sources_are_hidden(): void
|
||||
{
|
||||
$count = DB::table('opportunities')->count();
|
||||
$this->get('/agronegocio')->assertOk()->assertInertia(fn (Assert $page) => $page->component('portal/legacy')
|
||||
->where('article.id', $this->resource)->where('article.calculation_use', false));
|
||||
$html = app(LegacyContent::class)->article()['html'];
|
||||
$this->assertStringNotContainsString('script', $html);
|
||||
$this->assertStringNotContainsString('javascript', $html);
|
||||
$this->assertStringNotContainsString('onclick', $html);
|
||||
$this->get('/explorar/agronegocio?q=corredores')->assertOk()->assertInertia(fn (Assert $page) => $page->where('match', true));
|
||||
$this->get('/explorar/agronegocio?q=absent')->assertOk()->assertInertia(fn (Assert $page) => $page->where('match', false));
|
||||
$this->getJson('/investimentos/a-partir-de/agronegocio')->assertUnauthorized();
|
||||
$this->assertSame($count, DB::table('opportunities')->count());
|
||||
DB::table('portal_resources')->where('id', $this->resource)->update(['published' => false]);
|
||||
$this->get('/agronegocio')->assertNotFound();
|
||||
config(['portal.legacy_content_qa_enabled' => false]);
|
||||
$this->get('/explorar/agronegocio')->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_original_heading_text_is_preserved_with_an_accessible_article_hierarchy(): void
|
||||
{
|
||||
$source = '<h3>Turismo</h3><h3>Territórios</h3><h4>Localizações</h4><script>unsafe()</script>';
|
||||
$html = app(LegacyContent::class)->sanitize($source);
|
||||
$this->assertSame('<h2>Turismo</h2><h2>Territórios</h2><h3>Localizações</h3>', $html);
|
||||
$this->assertSame('<h2>Secção</h2><h3>Subsecção</h3>', app(LegacyContent::class)->sanitize('<h1>Secção</h1><h3>Subsecção</h3>'));
|
||||
$this->assertStringContainsString('<h3>Turismo</h3>', $source);
|
||||
}
|
||||
|
||||
public function test_source_is_saved_once_in_existing_dossier_recovered_and_isolated_from_other_accounts_and_projects(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$data = ['title' => 'Ideia sintética', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true, 'content_resource_id' => $this->resource];
|
||||
$case = $this->actingAs($owner)->postJson('/investimentos', $data)->assertCreated()->json('id');
|
||||
$first = $this->getJson('/investimentos/'.$case)->assertOk()->json();
|
||||
$this->assertCount(19, $first['steps']);
|
||||
$this->assertCount(5, $first['phases']);
|
||||
$this->assertSame($this->resource, $first['content_sources'][0]['resource_id']);
|
||||
$this->postJson('/investimentos/'.$case.'/conteudos', ['version' => $first['case']['version'], 'resource_id' => $this->resource])->assertOk();
|
||||
$again = $this->getJson('/investimentos/'.$case)->assertOk()->json();
|
||||
$this->assertSame($first['case']['version'], $again['case']['version']);
|
||||
$this->assertSame($first['content_sources'], $again['content_sources']);
|
||||
$this->assertSame($case, $this->postJson('/investimentos', $data)->assertCreated()->json('id'));
|
||||
$this->actingAs($other)->getJson('/investimentos/'.$case)->assertForbidden();
|
||||
$this->postJson('/investimentos/'.$case.'/conteudos', ['version' => $first['case']['version'], 'resource_id' => $this->resource])->assertForbidden();
|
||||
$this->actingAs($owner);
|
||||
unset($data['content_resource_id']);
|
||||
$data['client_key'] = (string) Str::uuid();
|
||||
$independent = $this->postJson('/investimentos', $data)->assertCreated()->json('id');
|
||||
$this->assertSame([], $this->getJson('/investimentos/'.$independent)->assertOk()->json('content_sources'));
|
||||
$before = DB::table('desk_cases')->count();
|
||||
$data['client_key'] = (string) Str::uuid();
|
||||
$data['content_resource_id'] = $this->resource + 100;
|
||||
$this->postJson('/investimentos', $data)->assertUnprocessable();
|
||||
$this->assertSame($before, DB::table('desk_cases')->count());
|
||||
}
|
||||
|
||||
private function sectorArticle(string $slug, string $title, string $body): int
|
||||
{
|
||||
$identity = config('legacy_content.articles.'.$slug);
|
||||
$document = (string) Str::uuid();
|
||||
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'source_url' => $identity['source_url'],
|
||||
'content_html' => $body, 'title' => $title, 'author' => 'Autor sintético', 'legacy_id' => 123,
|
||||
'legacy_category' => 'Turismo', 'published_at' => '2026-04-01T00:00:00+00:00', 'modified_at' => '2026-04-02T00:00:00+00:00'], JSON_THROW_ON_ERROR);
|
||||
Storage::disk('local')->put('legacy/'.$document, $bytes);
|
||||
DB::table('documents')->insert(['id' => $document, 'source_system' => 'synthetic-test', 'external_id' => $identity['external_id'],
|
||||
'file_name' => 'source.json', 'media_type' => 'application/json', 'storage_key' => 'legacy/'.$document,
|
||||
'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes), 'malware_scan_status' => 'clean',
|
||||
'size_bytes' => strlen($bytes), 'status' => 'active', 'rights_status' => 'authorized', 'data_classification' => 'public']);
|
||||
return DB::table('portal_resources')->insertGetId(['document_id' => $document, 'title' => $title, 'category' => 'education',
|
||||
'topic' => $identity['sector_slug'], 'published' => true, 'rights_status' => 'authorized',
|
||||
'source_url' => $identity['source_url'], 'source_reference' => 'Exemplar sintético.', 'metadata_version' => 1]);
|
||||
}
|
||||
|
||||
public function test_energy_and_mining_remain_distinct_and_each_reference_returns_to_its_article(): void
|
||||
{
|
||||
$this->sectorArticle('energia-mineracao', 'Energia e Mineração', '<a href="https://investemoz.tempo.co.mz/energia/">Energia</a><a href="https://investemoz.tempo.co.mz/mineracao">Mineração</a>');
|
||||
$energy = $this->sectorArticle('energia', 'Energia', '<p>Rede e geração.</p>');
|
||||
$mining = $this->sectorArticle('mineracao', 'Mineração', '<p>Recursos minerais.</p>');
|
||||
$this->get('/explorar/energia-mineracao?q=geração')->assertOk()->assertInertia(fn (Assert $page) =>
|
||||
$page->has('articles', 1)->where('articles.0.id', $energy));
|
||||
$index = app(LegacyContent::class)->article('energia-mineracao');
|
||||
$this->assertStringContainsString('href="/energia"', $index['html']);
|
||||
$this->assertStringContainsString('href="/mineracao"', $index['html']);
|
||||
$owner = User::factory()->create();
|
||||
$case = $this->actingAs($owner)->postJson('/investimentos', ['title' => 'Energia sintética', 'client_key' => (string) Str::uuid(),
|
||||
'reuse_consent' => true, 'content_resource_id' => $energy])->assertCreated()->json('id');
|
||||
$first = $this->getJson('/investimentos/'.$case)->assertOk()->json();
|
||||
$this->postJson('/investimentos/'.$case.'/conteudos', ['version' => $first['case']['version'], 'resource_id' => $mining])->assertOk();
|
||||
$sources = $this->getJson('/investimentos/'.$case)->assertOk()->json('content_sources');
|
||||
$this->assertSame(['/energia', '/mineracao'], array_column($sources, 'return_href'));
|
||||
$this->assertSame([$energy, $mining], array_column($sources, 'resource_id'));
|
||||
DB::table('portal_resources')->where('id', $mining)->update(['published' => false]);
|
||||
$this->get('/mineracao')->assertNotFound();
|
||||
$this->assertStringContainsString('https://investemoz.tempo.co.mz/mineracao', app(LegacyContent::class)->article('energia-mineracao')['html']);
|
||||
}
|
||||
|
||||
public function test_tourism_is_textual_and_empty_financial_source_is_not_a_published_article(): void
|
||||
{
|
||||
$this->sectorArticle('turismo', 'Turismo', '<p>Província da Zambézia e turismo.</p>');
|
||||
$tourism = app(LegacyContent::class)->article('turismo');
|
||||
$this->assertSame([], $tourism['territorial_references']);
|
||||
$this->assertFalse(DB::table('portal_resource_links')->where('target_type', 'territory')->exists());
|
||||
$finance = $this->sectorArticle('mercado-financeiro', 'Mercado Financeiro', '');
|
||||
$this->get('/mercado-financeiro')->assertNotFound();
|
||||
$this->get('/explorar/mercado-financeiro')->assertNotFound();
|
||||
$this->actingAs(User::factory()->create())->postJson('/investimentos', ['title' => 'Ideia', 'client_key' => (string) Str::uuid(),
|
||||
'reuse_consent' => true, 'content_resource_id' => $finance])->assertUnprocessable();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ namespace Tests\Feature;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityCanonicalResponse;
|
||||
use App\Services\OpportunityCategoryFields;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityModularProfiles;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
@@ -81,6 +84,69 @@ class OpportunityCanonicalWorkflowTest extends TestCase
|
||||
$this->assertNotEmpty($schema['$defs']['businessTransactionCreate']['allOf']);
|
||||
}
|
||||
|
||||
public function test_read_response_preserves_ids_resources_permissions_and_does_not_invent_validation(): void
|
||||
{
|
||||
[$id, $promoter, $validator, , $payload, $document] = $this->fixture();
|
||||
$url = '/minhas-oportunidades/'.$id.'/response';
|
||||
$record = Opportunity::findOrFail($id);
|
||||
$before = $record->getRawOriginal();
|
||||
$response = $this->loginFixture($promoter)->getJson($url)->assertOk()
|
||||
->assertHeader('Cache-Control', 'no-store, private')->json();
|
||||
$this->assertSame(OpportunityCanonicalResponse::VERSION, $response['adapter_version']);
|
||||
$this->assertSame($id, $response['data']['id']);
|
||||
$this->assertSame($record->reference, $response['data']['opportunity_code']);
|
||||
$this->assertSame($document, $response['data']['documents'][0]['id']);
|
||||
$this->assertSame($payload['locations'], $response['resource_references']['locations']);
|
||||
$this->assertNull($response['data']['approved_by']);
|
||||
$this->assertFalse($response['compatibility']['normative_response_complete']);
|
||||
$this->assertFalse($response['validation_result']['revalidated_on_read']);
|
||||
$this->assertArrayNotHasKey('storage_key', $response['data']['documents'][0]);
|
||||
$this->assertArrayNotHasKey('completion_percentage', $response['data']);
|
||||
$this->assertSame($before, $record->fresh()->getRawOriginal());
|
||||
$this->loginFixture($validator)->getJson($url)->assertOk();
|
||||
$otherOrg = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $otherOrg, 'name' => 'Other synthetic QA organization', 'organization_type' => 'private']);
|
||||
$other = $this->actor('promoter', $otherOrg);
|
||||
$this->loginFixture($other)->getJson($url)->assertForbidden();
|
||||
$this->action($promoter, $id, 'submit');
|
||||
$submitted = $this->loginFixture($promoter)->getJson($url)->assertOk()->json();
|
||||
$this->assertSame($promoter->id, $submitted['data']['submitted_by']);
|
||||
$this->assertNotNull($submitted['data']['submitted_at']);
|
||||
$this->assertNull($submitted['data']['approved_by']);
|
||||
$this->assertNotNull($submitted['validation_result']['submission_hash']);
|
||||
$this->assertEquals($payload['real_estate'], $submitted['data']['real_estate']);
|
||||
$this->assertArrayNotHasKey('id', $submitted['data']['real_estate']);
|
||||
}
|
||||
|
||||
public function test_cadastral_uniqueness_and_original_succession_modality_are_enforced_without_filling_drafts(): void
|
||||
{
|
||||
$fields = app(OpportunityCategoryFields::class);
|
||||
$record = new Opportunity;
|
||||
$record->id = 1;
|
||||
$payload = ['opportunity_category' => 'land_rights', 'land_rights' => ['duat_status' => 'none',
|
||||
'operation_type' => 'investment_with_duat', 'exploitation_plan_required' => false, 'communities_affected' => false,
|
||||
'parcels' => [['cadastral_number' => 'QA-001'], ['cadastral_number' => 'QA-001']]]];
|
||||
$errors = [];
|
||||
$fields->validate($payload, $record, [], $errors);
|
||||
$this->assertSame(['canonical.land_rights.parcels.1.cadastral_number'], array_keys($errors));
|
||||
foreach ([[null, null], ['QA-001', 'QA-002']] as $numbers) {
|
||||
$payload['land_rights']['parcels'] = array_map(fn ($number) => ['cadastral_number' => $number], $numbers);
|
||||
$errors = [];
|
||||
$fields->validate($payload, $record, [], $errors);
|
||||
$this->assertSame([], $errors);
|
||||
}
|
||||
$business = ['transaction_type' => 'succession', 'available_financial_years' => 1,
|
||||
'financial_periods' => [['year' => (int) date('Y') - 1]], 'litigation_contingencies' => [['type' => 'none']],
|
||||
'confidentiality_level' => 'internal', 'valuation' => ['value' => 100, 'date' => today()->toDateString()]];
|
||||
$errors = [];
|
||||
$fields->validate(['opportunity_category' => 'business_sale', 'currency' => 'MZN', 'visibility' => 'internal', 'business_transaction' => $business], $record, [], $errors);
|
||||
$this->assertSame([], $errors);
|
||||
$errors = [];
|
||||
$fields->validate(['opportunity_category' => 'business_partnership', 'currency' => 'MZN', 'visibility' => 'internal', 'business_transaction' => $business], $record, [], $errors);
|
||||
$this->assertArrayHasKey('canonical.business_transaction.transaction_type', $errors);
|
||||
$this->assertSame(['land_rights' => ['parcels' => [['cadastral_number' => null]]]], app(OpportunityCanonicalContract::class)->draft(['land_rights' => ['parcels' => [['cadastral_number' => null]]]]));
|
||||
}
|
||||
|
||||
public function test_correction_approval_publication_material_revision_and_suspension_preserve_authorization(): void
|
||||
{
|
||||
[$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture();
|
||||
@@ -204,8 +270,129 @@ class OpportunityCanonicalWorkflowTest extends TestCase
|
||||
$this->action($validator, $id, 'approve', [], 422);
|
||||
}
|
||||
|
||||
public function test_qa_modular_paths_use_distinct_semantics_and_existing_independent_workflow(): void
|
||||
{
|
||||
config(['portal.opportunity_modular_profiles_qa_enabled' => true]);
|
||||
foreach (['concession' => ['intention_call', 'awarded_right'], 'financing' => ['seeking_funding', 'funding_offer'],
|
||||
'supply' => ['purchase_request', 'supply_offer'], 'other' => ['classified_other']] as $category => $modes) {
|
||||
foreach ($modes as $mode) {
|
||||
[$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture($category, $mode);
|
||||
$this->assertEquals($payload, app(OpportunityManagement::class)->values(Opportunity::findOrFail($id))['canonical']);
|
||||
if ($category === 'other') {
|
||||
$this->action($promoter, $id, 'submit', [], 422);
|
||||
config(['portal.opportunity_other_qa_subtypes.qa-example' => ['approved' => true, 'version' => 'synthetic-acceptance-v1',
|
||||
'decision_reference' => 'Synthetic policy test only; not a real approved business subtype', 'gates' => ['technical_review', 'legal_territorial_review']]]);
|
||||
}
|
||||
$this->action($promoter, $id, 'submit');
|
||||
$this->action($validator, $id, 'start_review');
|
||||
$this->action($validator, $id, 'request_correction', ['correction_items' => ['canonical.executive_summary']]);
|
||||
$payload['executive_summary'] .= ' Correção QA recuperável.';
|
||||
$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');
|
||||
foreach (app(OpportunityCanonicalContract::class)->gates(Opportunity::findOrFail($id)) as $gate) {
|
||||
$this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
|
||||
}
|
||||
$this->action($validator, $id, 'approve');
|
||||
$this->action($publisher, $id, 'publish');
|
||||
$response = $this->loginFixture($promoter)->getJson('/minhas-oportunidades/'.$id.'/response')->assertOk()->json();
|
||||
$this->assertSame($mode, $response['data']['module_details']['mode']);
|
||||
$this->assertSame('restricted', $response['publication']['scope']);
|
||||
$this->assertSame(0, Opportunity::published()->whereKey($id)->count());
|
||||
$this->assertLessThanOrEqual(500, mb_strlen(Opportunity::findOrFail($id)->summary));
|
||||
$this->assertSame($payload['executive_summary'], $response['data']['executive_summary']);
|
||||
$this->assertNotEmpty(Opportunity::findOrFail($id)->workflow_context['approval']['gates']);
|
||||
if ($mode === 'intention_call') {
|
||||
$this->assertStringContainsString('não é direito atribuído', Opportunity::findOrFail($id)->summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_modular_semantic_failures_are_field_local_and_never_block_incomplete_draft_recovery(): void
|
||||
{
|
||||
config(['portal.opportunity_modular_profiles_qa_enabled' => true]);
|
||||
foreach ([['financing', 'seeking_funding', 'funding_sought', 999999],
|
||||
['financing', 'funding_offer', 'total_investment', 100],
|
||||
['concession', 'awarded_right', 'module_details.external_decision_document_id', null],
|
||||
['supply', 'supply_offer', 'module_details.total_price', 99]] as [$category, $mode, $path, $value]) {
|
||||
[$id, $promoter, , , $payload] = $this->fixture($category, $mode);
|
||||
data_set($payload, $path, $value);
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
|
||||
$this->getJson('/minhas-oportunidades/'.$id.'/response')->assertOk()->assertJsonPath('data.module_details.mode', $mode);
|
||||
$this->assertEquals($payload, app(OpportunityManagement::class)->values(Opportunity::findOrFail($id))['canonical']);
|
||||
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'action' => 'submit', 'reason' => self::REASON])->assertUnprocessable()->assertJsonValidationErrors('canonical.'.$path);
|
||||
}
|
||||
$service = app(OpportunityCanonicalContract::class);
|
||||
$this->assertSame(['module_details' => ['mode' => 'funding_offer']], $service->draft(['module_details' => ['mode' => 'funding_offer']]));
|
||||
config(['portal.opportunity_modular_profiles_qa_enabled' => false]);
|
||||
$this->assertFalse(app(OpportunityModularProfiles::class)->enabled());
|
||||
$this->assertArrayNotHasKey('module_details', $service->schema(true)['$defs']['opportunityCreate']['properties']);
|
||||
}
|
||||
|
||||
public function test_new_entity_history_is_opt_in_and_does_not_fabricate_accounts(): void
|
||||
{
|
||||
config(['portal.opportunity_modular_profiles_qa_enabled' => true]);
|
||||
$schema = app(OpportunityCanonicalContract::class)->schema();
|
||||
$this->assertSame(0, $schema['$defs']['businessTransactionCreate']['properties']['available_financial_years']['minimum']);
|
||||
$bt = ['transaction_type' => 'succession', 'available_financial_years' => 0, 'financial_periods' => [],
|
||||
'history_status' => 'no_completed_years', 'history_declaration' => 'Entidade sintética recém-criada sem exercícios completos.',
|
||||
'history_declaration_document_id' => 'qa-doc', 'projections' => ['scenario' => 'Projeção QA', 'source' => 'Plano QA', 'date' => '2026-10-10', 'document_id' => 'qa-doc'],
|
||||
'litigation_contingencies' => [['type' => 'none']], 'confidentiality_level' => 'internal', 'valuation' => ['value' => 100, 'date' => '2026-10-10']];
|
||||
$p = ['opportunity_category' => 'business_sale', 'module_profile_version' => OpportunityModularProfiles::HISTORY_VERSION, 'business_transaction' => $bt, 'visibility' => 'internal', 'currency' => 'MZN'];
|
||||
$errors = [];
|
||||
app(OpportunityCategoryFields::class)->validate($p, new Opportunity, ['documents' => ['qa-doc' => []]], $errors);
|
||||
$this->assertSame([], $errors);
|
||||
unset($p['module_profile_version']);
|
||||
$errors = [];
|
||||
app(OpportunityCategoryFields::class)->validate($p, new Opportunity, ['documents' => ['qa-doc' => []]], $errors);
|
||||
$this->assertArrayHasKey('canonical.module_profile_version', $errors);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload
|
||||
* @return array<string,mixed> */
|
||||
private function modularPayload(string $category, string $mode, array $payload, string $document): array
|
||||
{
|
||||
unset($payload['real_estate']);
|
||||
$payload['opportunity_category'] = $category;
|
||||
$payload['module_profile_version'] = OpportunityModularProfiles::PROFILES[$category];
|
||||
$m = ['mode' => $mode, 'object' => 'Objeto sintético QA', 'representation_document_id' => $document, 'object_document_id' => $document];
|
||||
if ($category === 'concession') {
|
||||
$m += ['granting_entity' => 'Entidade sintética, não autoridade real', 'procedure_regime' => 'Procedimento sintético QA', 'scope' => 'Distrito QA',
|
||||
'term_value' => 12, 'term_unit' => 'months', 'remuneration_obligations' => 'Obrigações sintéticas QA', 'participation_conditions' => 'Condições sintéticas QA',
|
||||
'land_required' => false, 'construction_required' => false, 'disclosure_authorization_document_id' => $document];
|
||||
if ($mode === 'awarded_right') {
|
||||
$m['external_decision_document_id'] = $document;
|
||||
}
|
||||
}
|
||||
if (in_array($category, ['financing', 'supply'], true)) {
|
||||
$m += ['conditions_status' => 'proposed', 'terms' => 'Condições indicativas QA, sem compromisso', 'scenario' => 'Cenário QA', 'source' => 'Fonte sintética QA', 'terms_date' => '2026-10-10'];
|
||||
}
|
||||
if ($category === 'financing') {
|
||||
$m += ['instrument' => 'debt', 'term_value' => 12, 'term_unit' => 'months', 'repayment_periodicity' => 'mensal', 'disbursement_calendar' => 'Calendário proposto QA'];
|
||||
if ($mode === 'seeking_funding') {
|
||||
$payload += ['total_investment' => 1000, 'investment_breakdown' => [['label' => 'QA', 'amount' => 1000, 'currency' => 'MZN']], 'secured_financing' => [], 'funding_sought' => 500];
|
||||
$m += ['use_of_funds' => 'Preparação QA', 'financial_plan_document_id' => $document];
|
||||
} else {
|
||||
$m += ['offered_capacity' => 100000, 'eligibility_criteria' => 'Critérios propostos QA', 'capacity_document_id' => $document];
|
||||
}
|
||||
}
|
||||
if ($category === 'supply') {
|
||||
$m += ['quantity' => 10, 'unit' => 'unidade', 'destination' => 'Distrito QA', 'start_date' => '2026-10-11', 'end_date' => '2026-11-11',
|
||||
'acceptance_criteria' => 'Critérios QA', 'unit_price' => 10, 'total_price' => 100, 'advance_or_guarantee' => false];
|
||||
if ($mode === 'supply_offer') {
|
||||
$m['capacity_document_id'] = $document;
|
||||
}
|
||||
}
|
||||
if ($category === 'other') {
|
||||
$m += ['subtype' => 'qa-example', 'purpose' => 'Ensaio QA', 'recipients' => 'Destinatários sintéticos', 'expected_output' => 'Resultado sintético', 'risks' => 'Riscos declarados QA'];
|
||||
}
|
||||
$payload['module_details'] = $m;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** @return array{int,User,User,User,array<string,mixed>,string} */
|
||||
private function fixture(): array
|
||||
private function fixture(string $category = 'real_estate', string $mode = ''): array
|
||||
{
|
||||
$org = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $org, 'name' => 'Synthetic QA organization', 'organization_type' => 'private']);
|
||||
@@ -214,11 +401,11 @@ class OpportunityCanonicalWorkflowTest extends TestCase
|
||||
$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']);
|
||||
DB::table('reference_catalog_versions')->insert(['id' => $version, 'catalog_code' => 'qa-only-'.$org, '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]);
|
||||
$sector = DB::table('sectors')->insertGetId(['name' => 'QA '.$i, 'slug' => 'qa-'.$org.'-'.$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];
|
||||
@@ -229,7 +416,7 @@ class OpportunityCanonicalWorkflowTest extends TestCase
|
||||
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();
|
||||
$draft = $this->loginFixture($promoter)->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => $org, 'opportunity_category' => $category, 'reason' => self::REASON])->assertCreated()->json();
|
||||
$id = $draft['id'];
|
||||
$location = (string) Str::uuid();
|
||||
$provinceId = (string) Str::uuid();
|
||||
@@ -250,6 +437,9 @@ class OpportunityCanonicalWorkflowTest extends TestCase
|
||||
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];
|
||||
if ($category !== 'real_estate') {
|
||||
$payload = $this->modularPayload($category, $mode, $payload, $document);
|
||||
}
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $draft['etag'], 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
|
||||
|
||||
return [$id, $promoter, $validator, $publisher, $payload, $document];
|
||||
|
||||
@@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\Support\CleanScannerFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -96,6 +97,38 @@ class OpportunityResourcesTest extends TestCase
|
||||
$this->assertDatabaseHas('opportunity_location_drafts', ['id' => $id, 'deleted_at' => null]);
|
||||
}
|
||||
|
||||
public function test_foreign_country_rejects_mozambican_hierarchy_without_changing_the_saved_draft(): void
|
||||
{
|
||||
[$user, $o] = $this->setupDraft();
|
||||
$province = (string) Str::uuid();
|
||||
$district = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert([
|
||||
['id' => $province, 'code' => $province, 'name' => 'Synthetic QA Mozambique province', 'location_type' => 'province', 'parent_id' => null, 'data_classification' => 'public'],
|
||||
['id' => $district, 'code' => $district, 'name' => 'Synthetic QA Mozambique district', 'location_type' => 'district', 'parent_id' => $province, 'data_classification' => 'public'],
|
||||
]);
|
||||
$payload = ['title' => 'Synthetic QA saved location', 'country_code' => 'MZ', 'province_id' => $province, 'district_id' => $district, 'role' => 'market'];
|
||||
$id = $this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o, $payload))->assertCreated()->json('id');
|
||||
$before = DB::table('opportunity_location_drafts')->where('id', $id)->first();
|
||||
$auditBefore = DB::table('audit_events')->where('source_system', 'opportunity-resources')->count();
|
||||
|
||||
$input = [...$this->payload($o->fresh(), [...$payload, 'country_code' => 'PT']), 'version' => 1];
|
||||
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations/'.$id, $input)
|
||||
->assertUnprocessable()->assertJsonValidationErrors('country_code');
|
||||
$after = DB::table('opportunity_location_drafts')->where('id', $id)->first();
|
||||
$this->assertSame($before->payload, $after->payload);
|
||||
$this->assertSame(1, $after->version);
|
||||
$this->assertSame($auditBefore, DB::table('audit_events')->where('source_system', 'opportunity-resources')->count());
|
||||
|
||||
$foreign = $this->postJson('/management/opportunities/'.$o->id.'/resources/locations',
|
||||
$this->payload($o->fresh(), ['title' => 'Synthetic QA foreign incomplete location', 'country_code' => 'PT', 'role' => 'market']))->assertCreated()->json('id');
|
||||
$saved = json_decode(DB::table('opportunity_location_drafts')->where('id', $foreign)->value('payload'), true);
|
||||
$this->assertSame('PT', $saved['country_code']);
|
||||
$this->assertEmpty($saved['province_id'] ?? null);
|
||||
$this->assertSame(0, DB::table('opportunity_locations')->count());
|
||||
$this->get('/management/opportunities/'.$o->id.'/resources')->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('assessment', fn ($issues) => collect($issues)->every(fn ($issue) => ! str_contains($issue, 'província') && ! str_contains($issue, 'distrito'))));
|
||||
}
|
||||
|
||||
public function test_invalid_upload_and_cross_opportunity_links_do_not_create_resources(): void
|
||||
{
|
||||
[$user,$o] = $this->setupDraft();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Illuminate\Session\Store;
|
||||
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalQaMfaTracePrivacyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_diagnostic_distinguishes_replay_without_consuming_code_or_authenticating(): void
|
||||
{
|
||||
require_once base_path('tools/portal-qa-auth-trace.php');
|
||||
config(['cache.default' => 'array']);
|
||||
$engine = new Google2FA;
|
||||
$secret = $engine->generateSecretKey();
|
||||
User::factory()->create(['id' => 18, 'email' => 'investor-mfa-qa@example.invalid', 'two_factor_secret' => encrypt($secret), 'two_factor_confirmed_at' => now()]);
|
||||
$code = $engine->getCurrentOtp($secret);
|
||||
$request = Request::create('/two-factor-challenge', 'POST', ['code' => $code]);
|
||||
$session = new Store('qa_trace_unit', new ArraySessionHandler(120));
|
||||
$session->start();
|
||||
$session->put('login.id', 18);
|
||||
$request->setLaravelSession($session);
|
||||
$request->attributes->set('qa_auth_trace', 'unit');
|
||||
$trace = new \PortalQaSessionTrace;
|
||||
$trace->handle($request, fn () => new Response('controlled'));
|
||||
$first = $request->attributes->get('qa_mfa_input_check');
|
||||
$this->assertTrue($first['secret_decrypts']);
|
||||
$this->assertTrue($first['matches_configured_time_window']);
|
||||
$this->assertFalse($first['previously_used_in_cache']);
|
||||
$this->assertTrue($first['accepted_by_replay_check']);
|
||||
$this->assertFalse(auth()->check());
|
||||
// Fortify must still accept the unused code: the diagnostic cannot consume it.
|
||||
$this->assertTrue(app(TwoFactorAuthenticationProvider::class)->verify($secret, $code));
|
||||
$trace->handle($request, fn () => new Response('controlled'));
|
||||
$second = $request->attributes->get('qa_mfa_input_check');
|
||||
$this->assertTrue($second['matches_configured_time_window']);
|
||||
$this->assertTrue($second['previously_used_in_cache']);
|
||||
$this->assertFalse($second['accepted_by_replay_check']);
|
||||
$this->assertFalse(auth()->check());
|
||||
$encoded = json_encode($second, JSON_THROW_ON_ERROR);
|
||||
foreach ([$secret, $code, md5($code)] as $value) $this->assertStringNotContainsString($value, $encoded);
|
||||
$this->assertSame(18, $session->get('login.id'));
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,30 @@ class PortalServicesTest extends TestCase
|
||||
$this->assertDatabaseCount('portal_resource_revisions', 8);
|
||||
}
|
||||
|
||||
public function test_qa_library_preserves_an_original_without_inventing_a_topic_or_auto_publishing(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$manager = User::factory()->create();
|
||||
config(['portal.managers' => [$manager->email], 'portal.legacy_content_qa_enabled' => false]);
|
||||
$this->actingAs($manager)->post('/management/portal-services/upload', [
|
||||
'file' => UploadedFile::fake()->createWithContent('original-image-reference.txt', 'Synthetic original without a thematic classification.'),
|
||||
])->assertSessionHasNoErrors();
|
||||
$document = DB::table('documents')->firstOrFail();
|
||||
$payload = [...$this->documentResourcePayload($document->id, 'Original sem assunto'), 'topic' => '', 'category' => 'media',
|
||||
'associations' => [], 'data_classification' => 'public'];
|
||||
$this->post('/management/portal-services/resources', $payload)->assertSessionHasErrors('topic');
|
||||
$this->assertDatabaseCount('portal_resources', 0);
|
||||
config(['portal.legacy_content_qa_enabled' => true]);
|
||||
$this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors();
|
||||
$resource = DB::table('portal_resources')->firstOrFail();
|
||||
$this->assertSame('', $resource->topic);
|
||||
$this->assertFalse($resource->published);
|
||||
$this->assertDatabaseCount('portal_resource_links', 0);
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
|
||||
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertOk();
|
||||
}
|
||||
|
||||
public function test_manager_classifies_a_reviewed_document_public_before_separate_publication(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Services\LegacyContent;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
// Read-only verification; never run migrations, installers, seeders or key generation.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
DB::statement('SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY');
|
||||
$actual = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
|
||||
if ($actual->database !== 'investemoz' || (int) $actual->port !== 5432) {
|
||||
throw new RuntimeException('Expected active read-only identity; no mutation performed.');
|
||||
}
|
||||
$result = ['target' => $actual, 'transaction_read_only' => DB::selectOne('SHOW transaction_read_only')->transaction_read_only,
|
||||
'legacy_available' => app(LegacyContent::class)->available(),
|
||||
'legacy_documents' => DB::table('documents')->where('external_id', 'like', 'legacy-tempo-%')->count(),
|
||||
'installed_queried_migrations' => DB::table('migrations')->where(function ($query) {
|
||||
$query->where('migration', 'like', '%030000%')->orWhere('migration', 'like', '%040000%')->orWhere('migration', 'like', '%190000%');
|
||||
})->pluck('migration'),
|
||||
'active_bundle_sha256' => hash_file('sha256', public_path('build/manifest.json')), 'active_writes' => 0];
|
||||
$result['required_extensions_installed'] = [];
|
||||
foreach (['2026_10_09_030000_extend_opportunity_resources', '2026_10_10_040000_create_portal_profile_authorization', '2026_10_09_190000_prepare_investor_path'] as $migration) {
|
||||
$result['required_extensions_installed'][$migration] = DB::table('migrations')->where('migration', $migration)->exists();
|
||||
}
|
||||
file_put_contents(storage_path('app/private/investmoz-v2/portal-audit/legacy-active-readonly.json'), json_encode($result, JSON_PRETTY_PRINT));
|
||||
echo json_encode($result);
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
require_once __DIR__.'/portal-qa-environment.php';
|
||||
$target = portalQaEnvironment();
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
portalQaAssertTarget($target);
|
||||
$directory = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
$browser = json_decode(file_get_contents($directory.'/legacy-content-browser-qa.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (($browser['result'] ?? null) !== 'passed' || ($browser['target'] ?? null) !== $target['database'].':55441') {
|
||||
throw new RuntimeException('Completed browser proof on the same QA required.');
|
||||
}
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
DB::statement('SET TRANSACTION READ ONLY');
|
||||
$case = DB::table('desk_cases')->where('id', $browser['dossier_id'])->firstOrFail();
|
||||
$payload = app(VirtualDesk::class)->decode($case->payload);
|
||||
$references = $payload['investor_path']['content_sources'] ?? [];
|
||||
$events = DB::table('desk_events')->where('case_id', $case->id)->where('action', 'legacy_content_referenced')->count();
|
||||
if ((int) $case->user_id !== 21 || count($references) !== 1 || $events !== 1 || (int) $references[0]['resource_id'] !== 6) {
|
||||
throw new RuntimeException('Browser association is not isolated/idempotent.');
|
||||
}
|
||||
$resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->whereIn('r.id', [5, 6])->get(['r.id', 'r.published', 'r.metadata_version', 'd.sha256', 'd.storage_key', 'd.malware_scan_status', 'd.malware_scanned_sha256']);
|
||||
$originals = [];
|
||||
foreach ($resources as $row) {
|
||||
$sha = trim($row->sha256);
|
||||
$file = Storage::disk('local')->path($row->storage_key);
|
||||
$verified = hash_equals($sha, hash_file('sha256', $file));
|
||||
$duplicates = DB::table('documents')->where('sha256', $row->sha256)->whereNull('deleted_at')->count();
|
||||
if (! $verified || $duplicates !== 1 || ! $row->published || $row->malware_scan_status !== 'clean'
|
||||
|| ! hash_equals($sha, trim($row->malware_scanned_sha256))) {
|
||||
throw new RuntimeException('Original/public resource integrity changed.');
|
||||
}
|
||||
$originals[] = ['resource_id' => $row->id, 'original_hash_verified' => true, 'same_bytes_as_scan' => true,
|
||||
'document_count_for_hash' => $duplicates, 'metadata_version' => $row->metadata_version];
|
||||
}
|
||||
$http = json_decode(file_get_contents($directory.'/legacy-content-http-qa.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$independent = DB::table('desk_cases')->where('id', $http['dossier']['independent_id'])->firstOrFail();
|
||||
$independentPayload = app(VirtualDesk::class)->decode($independent->payload);
|
||||
if (($independentPayload['investor_path']['content_sources'] ?? []) !== []) {
|
||||
throw new RuntimeException('Unexpected reference in independent project.');
|
||||
}
|
||||
$result = ['verified_at' => now()->toIso8601String(), 'target' => $target['database'].':55441',
|
||||
'transaction_read_only' => true, 'actor_id' => 21, 'dossier_id' => $case->id, 'version' => $case->version,
|
||||
'reference_count' => count($references), 'reference_event_count' => $events, 'originals' => $originals,
|
||||
'independent_project_reference_count' => 0, 'active_access' => false, 'database_writes' => 0];
|
||||
file_put_contents($directory.'/legacy-content-browser-persistence-qa.json', json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
} finally {
|
||||
DB::rollBack();
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/** Real Chromium render/click acceptance on the existing, isolated QA server. */
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium } = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const PRIVATE = path.join(ROOT, 'storage/app/private/investmoz-v2/portal-audit');
|
||||
const BASE = 'http://127.0.0.1:18097';
|
||||
const TARGET = 'desk_restore_20261009_174726_test:55441';
|
||||
const receiptPath = path.join(PRIVATE, 'legacy-content-browser-qa.json');
|
||||
const actors = JSON.parse(fs.readFileSync(path.join(PRIVATE, 'profiles-v1-actors-private.json'), 'utf8'));
|
||||
assert.equal(actors.promoter.id, 21, 'Use the designated synthetic Promotor QA21');
|
||||
const previous = fs.existsSync(receiptPath) ? JSON.parse(fs.readFileSync(receiptPath, 'utf8')) : {};
|
||||
if (previous.started_at) {
|
||||
const archive = path.join(PRIVATE, 'legacy-browser-attempt-' + previous.started_at.replace(/[^0-9TZ-]/g, '-') + '.json');
|
||||
if (!fs.existsSync(archive)) fs.copyFileSync(receiptPath, archive);
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const screenshotDir = path.join(PRIVATE, 'legacy-browser-' + stamp);
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
const proof = { started_at: new Date().toISOString(), base: BASE, target: TARGET,
|
||||
browser: 'installed Chromium 1243, isolated headless contexts', native_control: 'pipe unavailable; two probes',
|
||||
interface_observed: true, active_writes: 0, checks: {}, events: [], screenshots: [],
|
||||
dossier_id: previous.dossier_id || null, continuation_id: previous.continuation_id || null,
|
||||
title: previous.title || 'QA visual — ideia de agronegócio ' + stamp, last_step: 'initialization' };
|
||||
let browser;
|
||||
function save() { fs.writeFileSync(receiptPath, JSON.stringify(proof, null, 2)); }
|
||||
function step(name) { proof.last_step = name; save(); console.log(JSON.stringify({ step: name })); }
|
||||
function totp(secret) {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let bits = 0, value = 0; const bytes = [];
|
||||
for (const char of secret.toUpperCase().replace(/=+$/, '')) {
|
||||
const digit = alphabet.indexOf(char); assert(digit >= 0);
|
||||
value = (value << 5) | digit; bits += 5;
|
||||
if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 255); bits -= 8; }
|
||||
}
|
||||
const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30000)));
|
||||
const digest = crypto.createHmac('sha1', Buffer.from(bytes)).update(counter).digest();
|
||||
const offset = digest[19] & 15;
|
||||
return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1000000).padStart(6, '0');
|
||||
}
|
||||
async function context(viewport = { width: 1440, height: 1000 }) {
|
||||
const ctx = await browser.newContext({ viewport, locale: 'pt-MZ', reducedMotion: 'reduce' });
|
||||
ctx.qaConfirmed = false;
|
||||
await ctx.route('**/*', async (route) => {
|
||||
const request = route.request(), url = new URL(request.url());
|
||||
if (url.origin !== BASE) { proof.events.push({ external_request_blocked: url.origin }); return route.abort(); }
|
||||
if (!['GET', 'HEAD'].includes(request.method())) assert(ctx.qaConfirmed, 'Confirm actual QA before any write');
|
||||
await route.continue();
|
||||
});
|
||||
ctx.on('response', (response) => {
|
||||
const request = response.request(), url = new URL(response.url());
|
||||
if (url.origin === BASE && !url.pathname.startsWith('/build-qa-')) {
|
||||
const headers = response.headers();
|
||||
proof.events.push({ method: request.method(), path: url.pathname, status: response.status(),
|
||||
target: headers['x-investmoz-qa-target'] || null,
|
||||
redirect_path: headers.location ? new URL(headers.location, BASE).pathname : null });
|
||||
}
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
async function open(page, route) {
|
||||
const response = await page.goto(BASE + route, { waitUntil: 'networkidle' });
|
||||
assert.equal(response.headers()['x-investmoz-qa-target'], TARGET);
|
||||
page.context().qaConfirmed = true;
|
||||
return response;
|
||||
}
|
||||
async function screenshot(page, name, fullPage = true) {
|
||||
const file = path.join(screenshotDir, name + '.png');
|
||||
await page.screenshot({ path: file, fullPage });
|
||||
proof.screenshots.push({ name, path: path.relative(ROOT, file).replaceAll('\\', '/') });
|
||||
}
|
||||
async function login(page, role) {
|
||||
const account = actors[role];
|
||||
if (!page.url().startsWith(BASE + '/login')) await open(page, '/login');
|
||||
await page.locator('#email').fill(account.email);
|
||||
await page.locator('#password').fill(account.password);
|
||||
await page.locator('[data-test="login-button"]').click();
|
||||
await page.waitForURL((url) => url.pathname === '/two-factor-challenge', { timeout: 15000 });
|
||||
assert(account.totp_secret, 'Normal MFA is required; never bypass it');
|
||||
await page.locator('input[name="code"]').fill(totp(account.totp_secret));
|
||||
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
||||
await page.waitForURL((url) => !['/login', '/two-factor-challenge'].includes(url.pathname), { timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
const cookies = await page.context().cookies(BASE);
|
||||
proof.checks['mfa_' + role] = { actor_id: account.id, final_path: new URL(page.url()).pathname,
|
||||
cookies: cookies.map(({ name, domain, path, secure, sameSite, httpOnly }) => ({ name, domain, path, secure, sameSite, httpOnly })) };
|
||||
}
|
||||
async function logout(page) {
|
||||
await open(page, '/dashboard');
|
||||
const triggers = page.locator('button[aria-haspopup="menu"]');
|
||||
const count = await triggers.count();
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
await triggers.nth(i).click();
|
||||
const action = page.locator('[data-test="logout-button"]');
|
||||
if (await action.isVisible()) { await action.click(); await page.waitForURL(BASE + '/'); return; }
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
throw new Error('Visible logout action missing on dashboard');
|
||||
}
|
||||
async function assertDossier(page, id) {
|
||||
await page.waitForURL(BASE + '/investimentos/' + id);
|
||||
assert.equal(new URL(page.url()).pathname, '/investimentos/' + id);
|
||||
await page.getByRole('heading', { name: proof.title, exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('heading', { name: /^Fase \d+ ·/ }).count(), 5);
|
||||
assert.equal(await page.locator('article[id^="S"]').count(), 19);
|
||||
assert.equal(await page.getByRole('region', { name: 'Fontes da ideia' }).locator('li').count(), 1);
|
||||
await page.getByRole('heading', { name: 'Próxima acção', exact: true }).waitFor();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
browser = await chromium.launch({ headless: true,
|
||||
executablePath: 'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe' });
|
||||
step('public desktop rendering');
|
||||
const anonymous = await context(), page = await anonymous.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
const first = await open(page, '/explorar/agronegocio'); assert.equal(first.status(), 200);
|
||||
assert.equal(await page.title(), 'Explorar agronegócio - InvesteMoz QA');
|
||||
proof.checks.qa_browser_title = true;
|
||||
await page.getByRole('heading', { name: 'Investir no agronegócio', exact: true }).waitFor();
|
||||
await page.locator('main img').waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
proof.checks.desktop = await page.locator('main').evaluate((main) => ({
|
||||
background: getComputedStyle(main).backgroundColor, text: getComputedStyle(main).color,
|
||||
font: getComputedStyle(main).fontFamily, headingFont: getComputedStyle(main.querySelector('h1')).fontFamily,
|
||||
notoSansLoaded: document.fonts.check('16px "Noto Sans"'), notoSerifLoaded: document.fonts.check('600 36px "Noto Serif"'),
|
||||
photoLoaded: main.querySelector('img').complete && main.querySelector('img').naturalWidth === 900,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > innerWidth,
|
||||
}));
|
||||
assert(proof.checks.desktop.photoLoaded); assert(!proof.checks.desktop.horizontalOverflow);
|
||||
await screenshot(page, 'public-landing-desktop');
|
||||
step('visible search hit and miss');
|
||||
await page.getByRole('searchbox').fill('corredores');
|
||||
await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
||||
await page.waitForURL(/q=corredores/); await page.getByRole('link', { name: 'Ler o artigo', exact: true }).waitFor();
|
||||
await page.getByRole('searchbox').fill('zzzz-no-content-qa');
|
||||
await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
||||
await page.getByText('Nenhum conteúdo corresponde à pesquisa.', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('link', { name: 'Ler o artigo', exact: true }).count(), 0);
|
||||
await screenshot(page, 'public-search-no-result');
|
||||
await page.getByRole('searchbox').fill('');
|
||||
await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Ler o artigo', exact: true }).waitFor();
|
||||
proof.checks.search_hit_miss_clear = true;
|
||||
step('keyboard article navigation and focus');
|
||||
const read = page.getByRole('link', { name: 'Ler o artigo', exact: true }); await read.focus();
|
||||
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
|
||||
proof.checks.focus = await read.evaluate((el) => ({ focused: el === document.activeElement,
|
||||
outlineStyle: getComputedStyle(el).outlineStyle, outlineWidth: getComputedStyle(el).outlineWidth }));
|
||||
await page.keyboard.press('Enter'); await page.waitForURL(BASE + '/agronegocio');
|
||||
const article = page.getByRole('region', { name: 'Artigo educativo' }); await article.waitFor();
|
||||
assert((await article.innerText()).length > 500);
|
||||
await screenshot(page, 'public-article-desktop');
|
||||
proof.checks.public_article = { characters_rendered: (await article.innerText()).length, source_visible: true };
|
||||
step('consumer links through clicks');
|
||||
proof.checks.consumer_links = [];
|
||||
for (const name of ['Consultar os corredores cadastrados', 'Consultar localizações e mapas disponíveis', 'Exemplar na biblioteca']) {
|
||||
const consumer = page.getByRole('link', { name, exact: true });
|
||||
const destination = new URL(await consumer.getAttribute('href'), BASE);
|
||||
await consumer.click(); await page.waitForURL(destination.href); await page.waitForLoadState('networkidle');
|
||||
proof.checks.consumer_links.push({ name, path: new URL(page.url()).pathname,
|
||||
rendered_heading: (await page.locator('h1').allTextContents()).filter(Boolean).length > 0 });
|
||||
await page.goBack({ waitUntil: 'networkidle' }); await page.waitForURL(BASE + '/agronegocio'); await article.waitFor();
|
||||
}
|
||||
const opportunity = page.locator('section[aria-labelledby="opportunities-title"] a').first();
|
||||
if (await opportunity.count()) {
|
||||
const destination = new URL(await opportunity.getAttribute('href'), BASE);
|
||||
await opportunity.click(); await page.waitForURL(destination.href); await page.waitForLoadState('networkidle');
|
||||
assert(new URL(page.url()).pathname.startsWith('/oportunidades/'));
|
||||
proof.checks.published_opportunity_clicked = new URL(page.url()).pathname;
|
||||
await page.goBack({ waitUntil: 'networkidle' }); await article.waitFor();
|
||||
}
|
||||
step('mobile public rendering');
|
||||
const mobileCtx = await context({ width: 390, height: 844 }), mobile = await mobileCtx.newPage();
|
||||
await open(mobile, '/explorar/agronegocio'); await mobile.getByRole('heading', { name: 'Investir no agronegócio', exact: true }).waitFor();
|
||||
proof.checks.mobile = await mobile.evaluate(() => ({ width: innerWidth, contentWidth: document.documentElement.scrollWidth,
|
||||
photoLoaded: !!document.querySelector('main img')?.naturalWidth }));
|
||||
assert(proof.checks.mobile.contentWidth <= proof.checks.mobile.width);
|
||||
await screenshot(mobile, 'public-landing-mobile');
|
||||
await mobile.getByRole('link', { name: 'Ler o artigo', exact: true }).click(); await mobile.getByRole('region', { name: 'Artigo educativo' }).waitFor();
|
||||
assert(await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
await screenshot(mobile, 'public-article-mobile'); await mobileCtx.close();
|
||||
step('public CTA, normal Promotor QA21 login and MFA');
|
||||
await page.getByRole('link', { name: 'Iniciar ou continuar uma ideia', exact: true }).click();
|
||||
await page.waitForURL(BASE + '/login'); await login(page, 'promoter');
|
||||
assert.equal(new URL(page.url()).pathname, '/investimentos/a-partir-de/agronegocio');
|
||||
await page.getByRole('heading', { name: 'Os meus investimentos', exact: true }).waitFor();
|
||||
step('create and save dossier using UI');
|
||||
const createdThisRun = !proof.dossier_id;
|
||||
if (createdThisRun) {
|
||||
await page.getByLabel('Título', { exact: true }).fill(proof.title);
|
||||
await page.getByLabel('Autorizo reutilizar o meu perfil neste dossier privado.').check();
|
||||
await page.getByRole('button', { name: 'Criar e guardar dossier', exact: true }).click();
|
||||
await page.waitForURL(/\/investimentos\/[0-9a-f-]{36}$/);
|
||||
proof.dossier_id = new URL(page.url()).pathname.split('/').pop(); save();
|
||||
} else await page.getByRole('link', { name: proof.title, exact: true }).click();
|
||||
await assertDossier(page, proof.dossier_id); await screenshot(page, 'private-dossier-created', false);
|
||||
proof.checks.created_from_ui = true;
|
||||
proof.checks.created_this_run = createdThisRun;
|
||||
if (!createdThisRun) {
|
||||
// Creation may be an earlier observed action, not another insert on every rerun.
|
||||
const files = fs.readdirSync(PRIVATE).filter(name => name.startsWith('legacy-browser-attempt-') && name.endsWith('.json'));
|
||||
const creation = files.map(name => ({ name, receipt: JSON.parse(fs.readFileSync(path.join(PRIVATE, name), 'utf8')) }))
|
||||
.find(({ receipt }) => receipt.dossier_id === proof.dossier_id
|
||||
&& receipt.events?.some(event => event.method === 'POST' && event.path === '/investimentos' && event.status === 302));
|
||||
assert(creation, 'Existing dossier must have retained evidence of UI creation');
|
||||
proof.creation_evidence = { receipt: creation.name, started_at: creation.receipt.started_at,
|
||||
action: 'POST /investimentos through UI; subsequent retries recover this same dossier',
|
||||
whole_attempt_result: creation.receipt.result };
|
||||
}
|
||||
step('source return and same dossier context');
|
||||
await page.getByRole('region', { name: 'Fontes da ideia' }).locator('a').first().click(); await article.waitFor();
|
||||
await page.getByRole('link', { name: 'Continuar: ' + proof.title, exact: true }).click();
|
||||
await assertDossier(page, proof.dossier_id); proof.checks.private_return_context = true;
|
||||
step('visible logout and new browser session');
|
||||
await logout(page); await anonymous.close();
|
||||
const freshCtx = await context(), fresh = await freshCtx.newPage();
|
||||
await open(fresh, '/investimentos/' + proof.dossier_id); assert.equal(new URL(fresh.url()).pathname, '/login');
|
||||
await login(fresh, 'promoter'); await assertDossier(fresh, proof.dossier_id);
|
||||
await fresh.reload({ waitUntil: 'networkidle' }); await assertDossier(fresh, proof.dossier_id);
|
||||
await screenshot(fresh, 'private-dossier-new-session', false); proof.checks.logout_new_session_reload = true;
|
||||
step('continue existing dossier and repeat without duplicate');
|
||||
await open(fresh, '/investimentos/a-partir-de/agronegocio');
|
||||
const card = fresh.getByRole('listitem').filter({ has: fresh.getByRole('link', { name: proof.title, exact: true }) });
|
||||
await card.getByRole('button', { name: 'Continuar neste dossier com o artigo', exact: true }).click();
|
||||
await assertDossier(fresh, proof.dossier_id);
|
||||
proof.checks.existing_dossier_replay_single_visible_reference = true;
|
||||
step('anonymous context does not expose private dossier');
|
||||
const publicCtx = await context(), publicPage = await publicCtx.newPage();
|
||||
await open(publicPage, '/agronegocio?context=' + proof.dossier_id); await publicPage.getByRole('region', { name: 'Artigo educativo' }).waitFor();
|
||||
assert.equal(await publicPage.getByText(proof.title, { exact: false }).count(), 0);
|
||||
await publicPage.getByRole('link', { name: 'Iniciar ou continuar uma ideia', exact: true }).waitFor();
|
||||
proof.checks.anonymous_private_context_hidden = true; await publicCtx.close();
|
||||
step('other synthetic account denied and public source retained');
|
||||
const otherCtx = await context(), other = await otherCtx.newPage();
|
||||
await open(other, '/login'); await login(other, 'outsider');
|
||||
const denied = await open(other, '/investimentos/' + proof.dossier_id); assert.equal(denied.status(), 403);
|
||||
await open(other, '/agronegocio?context=' + proof.dossier_id); await other.getByRole('region', { name: 'Artigo educativo' }).waitFor();
|
||||
assert.equal(await other.getByText(proof.title, { exact: false }).count(), 0);
|
||||
proof.checks.other_account_denied = { actor_id: actors.outsider.id, dossier_status: 403, public_article_visible: true };
|
||||
await otherCtx.close(); await logout(fresh); await freshCtx.close();
|
||||
proof.result = 'passed'; proof.completed_at = new Date().toISOString(); save();
|
||||
console.log(JSON.stringify({ result: proof.result, actor_id: 21, dossier_id: proof.dossier_id,
|
||||
interface_observed: true, checks: Object.keys(proof.checks), screenshots: proof.screenshots.length, active_writes: 0 }));
|
||||
})().catch(async (error) => {
|
||||
let message = error.message;
|
||||
for (const account of Object.values(actors)) {
|
||||
for (const key of ['password', 'totp_secret', 'secret']) {
|
||||
if (typeof account[key] === 'string' && account[key]) message = message.replaceAll(account[key], '[REDACTED]');
|
||||
}
|
||||
}
|
||||
message = message.replace(/\b\d{6}\b/g, '[MFA OMITIDO]');
|
||||
proof.result = 'failed'; proof.failure = { step: proof.last_step, type: error.name,
|
||||
message: message.slice(0, 2500), pages: [] };
|
||||
if (browser) for (const ctx of browser.contexts()) for (const page of ctx.pages()) {
|
||||
proof.failure.pages.push({ path: new URL(page.url()).pathname,
|
||||
headings: await page.locator('h1').allTextContents().catch(() => []),
|
||||
error_count: await page.getByRole('alert').count().catch(() => 0) });
|
||||
}
|
||||
save();
|
||||
console.error(JSON.stringify({ step: proof.failure.step, type: proof.failure.type, message: 'Sanitized diagnostic saved privately' })); process.exitCode = 1;
|
||||
}).finally(async () => { if (browser) await browser.close(); });
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Inventory the public legacy graph. Never emit HTML, cookies or CSRF values."""
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / 'storage/app/private/investmoz-v2/portal-audit/legacy-discovery'
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
BASE = 'https://investemoz.tempo.co.mz'
|
||||
HEADERS = {'User-Agent': 'Mozilla/5.0 InvestMoz-QA-content-inventory', 'Accept': 'text/html,application/json,image/*,*/*;q=0.8'}
|
||||
|
||||
|
||||
def fetch(url):
|
||||
record = {'url': url, 'captured_at_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=HEADERS), timeout=35) as response:
|
||||
data = response.read(20 * 1024 * 1024 + 1)
|
||||
if len(data) > 20 * 1024 * 1024:
|
||||
raise ValueError('Inventory per-file size limit exceeded')
|
||||
mime = response.headers.get_content_type()
|
||||
record.update(status=response.status, final_url=response.url, media_type=mime, bytes=len(data), sha256=hashlib.sha256(data).hexdigest())
|
||||
suffix = Path(urllib.parse.urlsplit(url).path).suffix or ('.html' if mime == 'text/html' else '.bin')
|
||||
path = OUT / (record['sha256'] + suffix)
|
||||
path.write_bytes(data)
|
||||
record['private_file'] = str(path.relative_to(ROOT)).replace('\\', '/')
|
||||
if mime == 'text/html':
|
||||
text = data.decode('utf-8', errors='replace')
|
||||
match = re.search(r'data-page="([^"]+)"', text)
|
||||
if match:
|
||||
page = json.loads(html.unescape(match.group(1)))
|
||||
# Only public application content, not shared authentication/session props.
|
||||
record['component'] = page.get('component')
|
||||
public = {k: v for k, v in page.get('props', {}).items() if k not in ['auth', 'errors', 'flash', 'csrf_token', 'ziggy', 'csrf']}
|
||||
record['public_props'] = public
|
||||
title = re.search(r'<title[^>]*>(.*?)</title>', text, re.S)
|
||||
record['title'] = html.unescape(title.group(1)) if title else None
|
||||
record['references'] = sorted(set(html.unescape(x) for x in re.findall(r'(?:href|src)=[\"\']([^\"\']+)', text)))
|
||||
elif mime in ['text/css', 'application/javascript', 'text/javascript']:
|
||||
text = data.decode('utf-8', errors='replace')
|
||||
record['references'] = sorted(set(re.findall(r'(?:[\"\'])(/(?:images|storage|uploads|build)/[^\"\'\s]+)[\"\']', text)))
|
||||
except Exception as error:
|
||||
record.update(status=getattr(error, 'code', None), error=type(error).__name__)
|
||||
return record
|
||||
|
||||
|
||||
def urls(value):
|
||||
if isinstance(value, dict):
|
||||
for item in value.values():
|
||||
yield from urls(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
yield from urls(item)
|
||||
elif isinstance(value, str):
|
||||
for match in re.findall(r'(?:https?://[^\s<>"\']+|/(?:storage|images|uploads|build)/[^\s<>"\']+)', html.unescape(value)):
|
||||
yield match
|
||||
yield from re.findall(r'(?:href|src)=[\"\']([^\"\']+)', html.unescape(value))
|
||||
|
||||
|
||||
pending = {BASE + '/', BASE + '/portal-do-investidor', BASE + '/agronegocio', BASE + '/robots.txt', BASE + '/sitemap.xml'}
|
||||
records = {}
|
||||
for depth in range(5):
|
||||
batch = sorted(pending - records.keys())
|
||||
if not batch:
|
||||
break
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
|
||||
for record in pool.map(fetch, batch):
|
||||
records[record['url']] = record
|
||||
pending = set()
|
||||
for record in records.values():
|
||||
refs = list(record.get('references', [])) + list(urls(record.get('public_props', {})))
|
||||
props = record.get('public_props', {})
|
||||
# The legacy exposes public articles as slugs, not necessarily HTML links in the SPA shell.
|
||||
def slugs(value):
|
||||
if isinstance(value, dict):
|
||||
if isinstance(value.get('slug'), str):
|
||||
yield '/' + value['slug'].lstrip('/')
|
||||
for item in value.values():
|
||||
yield from slugs(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
yield from slugs(item)
|
||||
refs += list(slugs(props))
|
||||
for ref in refs:
|
||||
url = urllib.parse.urljoin(record['url'], ref)
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
is_media = parsed.netloc == 'contentmanager.tempo.co.mz' and parsed.path.startswith('/wp-content/uploads/')
|
||||
if (parsed.netloc != 'investemoz.tempo.co.mz' and not is_media) or parsed.query or parsed.fragment or re.search(r'/(?:login|register|logout|admin|dashboard|search|pesquisa|api)(?:/|$)', parsed.path):
|
||||
continue
|
||||
pending.add(url)
|
||||
print(json.dumps({'depth': depth, 'inventoried': len(records), 'remaining_discovered': len(pending - records.keys())}), flush=True)
|
||||
|
||||
inventory = {'source': BASE, 'captured_at_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'records': list(records.values()), 'unfetched_discovered': sorted(pending - records.keys()), 'note': 'Accessible anonymous graph; query/fragment and authentication endpoints excluded. Raw snapshots remain private and are never served.'}
|
||||
(OUT / 'inventory.json').write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'records': len(records), 'ok': sum(r.get('status') == 200 for r in records.values()), 'html': sum(r.get('media_type') == 'text/html' for r in records.values()), 'pending': len(inventory['unfetched_discovered'])}))
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Public reading and authenticated library release/dossier continuity on the guarded QA server."""
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from portal_profiles_http_session import BASE, EXPECTED, PRIVATE, Session, actors, events
|
||||
|
||||
receipt = json.loads((PRIVATE / 'legacy-import-qa.json').read_text(encoding='utf-8'))
|
||||
manifest = json.loads(Path('storage/app/private/document-import-staging/legacy-agronegocio/manifest.json').read_text(encoding='utf-8'))
|
||||
rows = receipt['resources']
|
||||
article = next(r for r in rows if r['file_name'] == 'agronegocio-source.txt')
|
||||
image = next(r for r in rows if r['file_name'] == 'Agronegocio.png')
|
||||
proof = {'url': BASE, 'target': EXPECTED, 'started_at': datetime.now(timezone.utc).isoformat(), 'interface_observed': False, 'active_writes': 0}
|
||||
anonymous = Session('outsider')
|
||||
actors['library_manager'] = json.loads((PRIVATE / 'resource-http-actors-private.json').read_text(encoding='utf-8'))['manager']
|
||||
manager = Session('library_manager').login()
|
||||
for row in rows:
|
||||
record = next(r for r in manifest['documents'] if r['source_url'] == row['source_url'])
|
||||
if not row['published']:
|
||||
anonymous.call('GET', f"/documentos/{row['id']}/view", status=404, binary=True)
|
||||
# Separate authenticated metadata/disclosure and publication actions; importer never publishes.
|
||||
data = {k: record[k] for k in ['title', 'issuer', 'category', 'topic', 'document_type', 'source_reference', 'source_url', 'language', 'version_label', 'description']}
|
||||
data.update({'document_id': row['document_id'], 'document_date': record.get('document_date'), 'metadata_version': row['metadata_version'],
|
||||
'territorial_scope': 'unknown', 'source_confirmation': 'identified', 'legal_status': 'not_applicable',
|
||||
'rights_status': 'authorized', 'data_classification': 'public', 'independent_review': False, 'legislation': False, 'essential': False,
|
||||
'source_reference': record['source_reference'] + '; utilização e divulgação exclusivamente no piloto QA autorizadas pelo responsável em 10/10/2026; sem habilitação ativa.'})
|
||||
if row['id'] == article['id']:
|
||||
data['associations'] = [{'target_type': 'theme', 'target_key': 'agronegocio', 'relationship': 'supports', 'applicability': 'support', 'basis_reference': row['source_url'] + '; título e conteúdo do artigo', 'review_status': 'confirmed'},
|
||||
{'target_type': 'document', 'target_key': str(image['id']), 'relationship': 'related_to', 'applicability': 'support', 'basis_reference': 'thumbnail do post 171985: ' + image['source_url'], 'review_status': 'confirmed'},
|
||||
{'target_type': 'stage', 'target_key': 'S01', 'relationship': 'supports', 'applicability': 'support', 'basis_reference': 'Associação editorial do piloto QA: compreender o setor para sistematizar a ideia; não é requisito legal.', 'review_status': 'confirmed'}]
|
||||
manager.call('PUT', f"/management/portal-services/resources/{row['id']}", data, status=302, binary=True)
|
||||
manager.call('POST', f"/management/portal-services/resources/{row['id']}", {'published': True}, binary=True)
|
||||
|
||||
landing = anonymous.call('GET', '/explorar/agronegocio', inertia=True)['props']
|
||||
content = anonymous.call('GET', '/agronegocio', inertia=True)['props']['article']
|
||||
assert landing['legacyContentEnabled'] and content['id'] == article['id'] and content['image']
|
||||
assert len(content['territorial_references']) == 6 and content['calculation_use'] is False
|
||||
search_hit = anonymous.call('GET', '/explorar/agronegocio?q=corredores', inertia=True)['props']
|
||||
search_miss = anonymous.call('GET', '/explorar/agronegocio?q=zzzz-no-content-qa', inertia=True)['props']
|
||||
assert search_hit['match'] and not search_miss['match']
|
||||
assert hashlib.sha256(anonymous.call('GET', content['image']['href'], binary=True)).hexdigest() == image['sha256'].strip()
|
||||
assert hashlib.sha256(anonymous.call('GET', f"/documentos/{article['id']}/download", binary=True)).hexdigest() == article['sha256'].strip()
|
||||
anonymous.call('GET', '/investimentos/a-partir-de/agronegocio', status=401, binary=True)
|
||||
proof['public'] = {'resource_id': content['id'], 'image_resource_id': image['id'], 'anonymous_read': True, 'image_hash_verified': True,
|
||||
'original_hash_verified': True, 'search_hit_and_miss': True, 'territorial_references': content['territorial_references'],
|
||||
'published_sector_opportunities': len(content['opportunities']), 'no_opportunity_created_from_article': True}
|
||||
for link in ['/carteira?kind=corridor', '/zonas', content['library_href']] + ['/oportunidades/' + o['slug'] for o in content['opportunities']]:
|
||||
anonymous.call('GET', link, inertia=True)
|
||||
proof['public']['consumer_links_checked'] = ['/carteira?kind=corridor', '/zonas', content['library_href']]
|
||||
|
||||
owner = Session('promoter').login()
|
||||
choice = owner.call('GET', '/investimentos/a-partir-de/agronegocio', inertia=True)['props']
|
||||
assert choice['contentSource']['id'] == article['id']
|
||||
previous = PRIVATE / 'legacy-content-http-qa.json'
|
||||
old = json.loads(previous.read_text(encoding='utf-8')) if previous.exists() else {}
|
||||
case = old.get('dossier', {}).get('id') or owner.call('POST', '/investimentos', {'title': 'Synthetic QA — ideia agronegócio', 'client_key': str(uuid.uuid4()), 'reuse_consent': True, 'content_resource_id': article['id']}, status=201)['id']
|
||||
first = owner.call('GET', '/investimentos/' + case)
|
||||
assert first['content_sources'][0]['resource_id'] == article['id'] and len(first['steps']) == 19 and len(first['phases']) == 5
|
||||
body = {'version': first['case']['version'], 'resource_id': article['id']}
|
||||
owner.call('POST', f'/investimentos/{case}/conteudos', body)
|
||||
replay = owner.call('GET', '/investimentos/' + case)
|
||||
assert replay['case']['version'] == first['case']['version'] and len(replay['content_sources']) == 1
|
||||
owner.call('POST', '/logout', {}, status=204)
|
||||
new_session = Session('promoter').login()
|
||||
recovered = new_session.call('GET', '/investimentos/' + case)
|
||||
assert recovered['content_sources'] == first['content_sources']
|
||||
empty = old.get('dossier', {}).get('independent_id') or new_session.call('POST', '/investimentos', {'title': 'Synthetic QA — outra ideia sem artigo', 'client_key': str(uuid.uuid4()), 'reuse_consent': True}, status=201)['id']
|
||||
assert new_session.call('GET', '/investimentos/' + empty)['content_sources'] == []
|
||||
continuing = old.get('dossier', {}).get('continue_id') or new_session.call('POST', '/investimentos', {'title': 'Synthetic QA — ideia existente para continuar', 'client_key': str(uuid.uuid4()), 'reuse_consent': True}, status=201)['id']
|
||||
before_continue = new_session.call('GET', '/investimentos/' + continuing)
|
||||
new_session.call('POST', f'/investimentos/{continuing}/conteudos', {'version': before_continue['case']['version'], 'resource_id': article['id']})
|
||||
continued = Session('promoter').login().call('GET', '/investimentos/' + continuing)
|
||||
assert len(continued['content_sources']) == 1 and continued['content_sources'][0]['resource_id'] == article['id']
|
||||
other = Session('outsider').login()
|
||||
other.call('GET', '/investimentos/' + case, status=403, binary=True)
|
||||
other.call('POST', f'/investimentos/{case}/conteudos', body, status=403, binary=True)
|
||||
other.call('PUT', f"/management/portal-services/resources/{article['id']}", {}, status=403, binary=True)
|
||||
assert new_session.call('GET', '/agronegocio?context=' + case, inertia=True)['props']['contentContext']['id'] == case
|
||||
assert other.call('GET', '/agronegocio?context=' + case, inertia=True)['props']['contentContext'] is None
|
||||
assert anonymous.call('GET', '/agronegocio?context=' + case, inertia=True)['props']['contentContext'] is None
|
||||
proof['public']['private_return_context_authorized_and_hidden'] = True
|
||||
proof['dossier'] = {'id': case, 'independent_id': empty, 'continue_id': continuing, 'existing_context_attached_and_recovered': True, 'new_mfa_session_recovered': True, 'replay_single_reference_no_version_increment': True,
|
||||
'outside_account_read_write_denied': True, 'public_source_return_href': recovered['content_sources'][0]['return_href'], 'phases': 5, 'steps': 19}
|
||||
proof.update(result='passed', events=events, completed_at=datetime.now(timezone.utc).isoformat())
|
||||
(PRIVATE / 'legacy-content-http-qa.json').write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'result': proof['result'], 'public_resources': [article['id'], image['id']], 'events': len(events), 'new_session_recovery': True, 'interface_observed': False, 'active_writes': 0}))
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Preserve uncollected references and inspect the real public visual assets."""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'tools'))
|
||||
# Import helpers without executing the discovery crawl again.
|
||||
namespace = {'__file__': str(ROOT / 'tools/legacy-content-discovery.py')}
|
||||
source = (ROOT / 'tools/legacy-content-discovery.py').read_text(encoding='utf-8')
|
||||
exec(source[:source.index('\npending = ')], namespace)
|
||||
private = ROOT / 'storage/app/private/investmoz-v2/portal-audit/legacy-discovery'
|
||||
path = private / 'inventory.json'
|
||||
inventory = json.loads(path.read_text(encoding='utf-8'))
|
||||
records = {r['url']: r for r in inventory['records']}
|
||||
visual_url = 'https://investemoz.tempo.co.mz/build/assets/welcome-DyF43GB5.js'
|
||||
if visual_url not in records:
|
||||
records[visual_url] = namespace['fetch'](visual_url)
|
||||
for record in list(records.values()):
|
||||
if record.get('media_type') in ['application/javascript', 'text/javascript']:
|
||||
text = (ROOT / record['private_file']).read_text(encoding='utf-8')
|
||||
for ref in re.findall(r'/(?:images|assets/logos)/[^\"\'\s]+', text):
|
||||
url = urllib.parse.urljoin(record['url'], ref)
|
||||
if url not in records:
|
||||
records[url] = namespace['fetch'](url)
|
||||
references = set()
|
||||
for record in records.values():
|
||||
for ref in list(record.get('references', [])) + list(namespace['urls'](record.get('public_props', {}))):
|
||||
url = urllib.parse.urljoin(record['url'], ref)
|
||||
if urllib.parse.urlsplit(url).scheme in ['http', 'https']:
|
||||
references.add((record['url'], url))
|
||||
doc = ROOT / 'docs/investmoz/v2/auditoria-portal/legado-referencias.csv'
|
||||
with doc.open('w', encoding='utf-8-sig', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['Página de origem', 'Referência', 'Recolha', 'Nota'])
|
||||
for origin, url in sorted(references):
|
||||
w.writerow([origin, url, records.get(url, {}).get('status', 'não recolhido'), 'Referência observada; não constitui relação territorial/oportunidade ou validação jurídica.'])
|
||||
inventory['records'] = list(records.values())
|
||||
inventory['reference_count'] = len(references)
|
||||
path.write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'records': len(records), 'references': len(references), 'failed': [{'url': r['url'], 'status': r.get('status')} for r in records.values() if r.get('status') != 200]}))
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Reconcile the observed graph and prepare a stable library import, never publish."""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit/legacy-discovery'
|
||||
DOCS = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
STAGE = ROOT / 'storage/app/private/document-import-staging/legacy-agronegocio'
|
||||
STAGE.mkdir(parents=True, exist_ok=True)
|
||||
inventory = json.loads((PRIVATE / 'inventory.json').read_text(encoding='utf-8'))
|
||||
rows = inventory['records']
|
||||
article = next(r for r in rows if r['url'] == 'https://investemoz.tempo.co.mz/agronegocio')
|
||||
post, seo = article['public_props']['post'], article['public_props']['seo']
|
||||
image = next(r for r in rows if r['url'] == post['thumbnail'])
|
||||
assert article['status'] == image['status'] == 200 and image['media_type'] == 'image/png'
|
||||
content = {'format': 'investmoz-legacy-article-v1', 'source_url': article['url'], 'legacy_id': post['id'],
|
||||
'slug': post['slug'], 'title': post['title'], 'author': post['author'], 'legacy_category': post['category'],
|
||||
'published_at': seo['publishedTime'], 'modified_at': seo['modifiedTime'], 'content_html': post['content'],
|
||||
'image_source_url': image['url'], 'image_sha256': image['sha256'],
|
||||
'references': re.findall(r'href=[\"\']([^\"\']+)', post['content'])}
|
||||
(STAGE / 'agronegocio-source.txt').write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
shutil.copyfile(ROOT / image['private_file'], STAGE / 'Agronegocio.png')
|
||||
manifest = {'documents': [
|
||||
{'external_id': 'legacy-tempo-post-171985-20260420', 'relative_path': 'legacy-agronegocio/agronegocio-source.txt',
|
||||
'file_name': 'agronegocio-source.txt', 'title': 'Agronegocio', 'issuer': post['author'],
|
||||
'document_date': seo['publishedTime'][:10], 'version_label': 'Legado modificado ' + seo['modifiedTime'],
|
||||
'source_url': article['url'], 'source_reference': 'Tempo; post 171985; categoria original Oportunidades; artigo educativo. Publicado ' + seo['publishedTime'] + '; modificado ' + seo['modifiedTime'],
|
||||
'category': 'education', 'topic': 'agronegocio', 'document_type': 'guide', 'language': 'pt',
|
||||
'description': 'Corredores de desenvolvimento do agronegócio em Moçambique. Conteúdo editorial do legado; não constitui uma oportunidade concreta nem decisão externa.'},
|
||||
{'external_id': 'legacy-tempo-agronegocio-image-' + image['sha256'][:16], 'relative_path': 'legacy-agronegocio/Agronegocio.png',
|
||||
'file_name': 'Agronegocio.png', 'title': 'Imagem Agronegocio — legado Tempo', 'issuer': 'Tempo (alojamento; autoria individual não indicada)',
|
||||
'version_label': image['sha256'][:32], 'source_url': image['url'], 'source_reference': 'Imagem ligada ao post 171985. Autoria individual e data de criação não indicadas; não inferidas.',
|
||||
'category': 'media', 'topic': 'agronegocio', 'document_type': 'reference_data', 'language': 'pt',
|
||||
'description': 'Imagem original do artigo Agronegocio; manter o original sem alterações.'}
|
||||
]}
|
||||
(STAGE / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
with (DOCS / 'legado-inventario.csv').open('w', encoding='utf-8-sig', newline='') as f:
|
||||
fields = ['URL', 'HTTP', 'Tipo', 'Título', 'ID legado', 'Autor', 'Categoria legado', 'Publicado', 'Modificado', 'Bytes', 'SHA256', 'Destino proposto', 'Situação']
|
||||
w = csv.DictWriter(f, fieldnames=fields)
|
||||
w.writeheader()
|
||||
for r in rows:
|
||||
p = r.get('public_props', {}).get('post', {})
|
||||
s = r.get('public_props', {}).get('seo', {})
|
||||
w.writerow(dict(zip(fields, [r['url'], r.get('status'), r.get('media_type'), p.get('title', r.get('title')), p.get('id'), p.get('author'), p.get('category'), s.get('publishedTime'), s.get('modifiedTime'), r.get('bytes'), r.get('sha256'), '/agronegocio' if r['url'] == article['url'] else 'biblioteca / correspondência por rever', 'piloto preparado' if r['url'] in [article['url'], image['url']] else 'inventariado; não importado'])))
|
||||
posts = {}
|
||||
for r in rows:
|
||||
p = r.get('public_props', {}).get('post')
|
||||
if p:
|
||||
s = r.get('public_props', {}).get('seo', {})
|
||||
key = str(p['id']) + '|' + str(s.get('modifiedTime')) + '|' + hashlib.sha256(p.get('content', '').encode()).hexdigest()
|
||||
posts.setdefault(key, {'post': p, 'seo': s, 'urls': []})['urls'].append(r['url'])
|
||||
with (DOCS / 'legado-conteudos-reconciliados.csv').open('w', encoding='utf-8-sig', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['ID legado', 'Título', 'Autor', 'Categoria', 'Publicado', 'Modificado', 'SHA256 conteúdo', 'URLs observadas', 'Recurso QA', 'Situação'])
|
||||
for key, item in posts.items():
|
||||
p, s = item['post'], item['seo']
|
||||
w.writerow([p['id'], p['title'], p.get('author'), p.get('category'), s.get('publishedTime'), s.get('modifiedTime'), key.rsplit('|', 1)[1], ' | '.join(item['urls']), '6' if p['id'] == 171985 else '', 'recurso único QA; não é oportunidade' if p['id'] == 171985 else 'inventariado; cadastro/versão por reconciliar'])
|
||||
sitemap = next(r for r in rows if r['url'].endswith('/sitemap.xml'))
|
||||
tree = ET.fromstring((ROOT / sitemap['private_file']).read_bytes())
|
||||
ns = {'s': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
|
||||
with (DOCS / 'legado-sitemap-anunciado.csv').open('w', encoding='utf-8-sig', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['URL anunciada', 'Última alteração anunciada', 'Situação'])
|
||||
for item in tree.findall('s:url', ns):
|
||||
w.writerow([item.findtext('s:loc', namespaces=ns), item.findtext('s:lastmod', namespaces=ns), 'anunciado no sitemap; domínio infopublica; conteúdo não recolhido'])
|
||||
print(json.dumps({'graph_records': len(rows), 'reconciled_observed_post_versions': len(posts), 'sitemap_announced': len(tree.findall('s:url', ns)), 'pilot_documents': 2, 'article_content_sha256': hashlib.sha256(post['content'].encode()).hexdigest(), 'original_image_sha256': image['sha256']}))
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
require_once __DIR__.'/portal-qa-environment.php';
|
||||
$target = portalQaEnvironment();
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
portalQaAssertTarget($target);
|
||||
$private = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
$receipt = json_decode(file_get_contents($private.'/legacy-content-http-qa.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$ids = [$receipt['public']['resource_id'], $receipt['public']['image_resource_id']];
|
||||
$rows = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->whereIn('r.id', $ids)->get(['r.id', 'r.source_url', 'r.published', 'r.metadata_version', 'r.rights_status',
|
||||
'd.id as document_id', 'd.external_id', 'd.media_type', 'd.sha256', 'd.malware_scan_status', 'd.malware_scanned_sha256',
|
||||
'd.malware_engine', 'd.malware_engine_version', 'd.malware_scanned_at', 'd.data_classification', 'd.storage_key']);
|
||||
$result = ['target' => $target, 'resources' => [], 'dossiers' => [], 'active_writes' => 0, 'interface_observed' => false];
|
||||
foreach ($rows as $row) {
|
||||
$path = Storage::disk('local')->path($row->storage_key);
|
||||
unset($row->storage_key);
|
||||
$row->original_hash_verified = hash_equals(trim($row->sha256), hash_file('sha256', $path));
|
||||
$row->duplicate_document_count = DB::table('documents')->where('sha256', $row->sha256)->whereNull('deleted_at')->count();
|
||||
$row->audit_actions = DB::table('audit_events')->where('entity_type', 'portal_resources')->whereRaw("after_data->>'record_id' = ?", [(string) $row->id])->pluck('action');
|
||||
$result['resources'][] = $row;
|
||||
}
|
||||
foreach (['id', 'independent_id', 'continue_id'] as $key) {
|
||||
$case = DB::table('desk_cases')->where('id', $receipt['dossier'][$key])->firstOrFail();
|
||||
$payload = app(VirtualDesk::class)->decode($case->payload);
|
||||
$result['dossiers'][$key] = ['id' => $case->id, 'user_id' => $case->user_id, 'version' => $case->version,
|
||||
'reference_count' => count($payload['investor_path']['content_sources'] ?? []),
|
||||
'event_count' => DB::table('desk_events')->where('case_id', $case->id)->where('action', 'legacy_content_referenced')->count()];
|
||||
}
|
||||
$result['totals'] = ['opportunities' => DB::table('opportunities')->count(),
|
||||
'documents' => DB::table('documents')->count(), 'library_resources' => DB::table('portal_resources')->count()];
|
||||
file_put_contents($private.'/legacy-persistence-qa.json', json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
// Rehearsal of the existing library importer only. Publication remains an authenticated action.
|
||||
require_once __DIR__.'/portal-qa-environment.php';
|
||||
$target = portalQaEnvironment();
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
portalQaAssertTarget($target);
|
||||
$private = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
$actors = json_decode(file_get_contents($private.'/resource-http-actors-private.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$actor = User::findOrFail($actors['manager']['id']);
|
||||
config(['portal.managers' => ['opportunity-manager@example.invalid', 'geo-manager-qa@example.invalid']]);
|
||||
if (($argv[1] ?? '') === 'apply') {
|
||||
// Guard above precedes every write. Existing importer previews before any application.
|
||||
Artisan::call('documents:import-batch', ['manifest' => 'legacy-agronegocio/manifest.json']);
|
||||
$preview = Artisan::output();
|
||||
$exit = Artisan::call('documents:import-batch', ['manifest' => 'legacy-agronegocio/manifest.json', '--apply' => true, '--actor' => (string) $actor->id]);
|
||||
file_put_contents($private.'/legacy-import-preview-qa.txt', $preview.Artisan::output());
|
||||
if ($exit !== 0) {
|
||||
throw new RuntimeException('Legacy QA library import failed; inspect private preview.');
|
||||
}
|
||||
}
|
||||
$resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->whereIn('d.external_id', ['legacy-tempo-post-171985-20260420', 'legacy-tempo-agronegocio-image-'.substr(hash_file('sha256', storage_path('app/private/document-import-staging/legacy-agronegocio/Agronegocio.png')), 0, 16)])
|
||||
->get(['r.id', 'r.document_id', 'r.title', 'r.source_url', 'r.metadata_version', 'r.published', 'd.sha256', 'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.status', 'd.file_name']);
|
||||
$proof = ['target' => $target, 'resources' => $resources, 'active_writes' => 0, 'interface_observed' => false];
|
||||
file_put_contents($private.'/legacy-import-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
echo json_encode($proof, JSON_UNESCAPED_UNICODE);
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Reconcile observed sector sources and stage originals privately; never write a database."""
|
||||
import csv
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import urllib.parse
|
||||
from collections import Counter
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
OUT = PRIVATE / 'legacy-sector-reconciliation-20261010'
|
||||
STAGE = ROOT / 'storage/app/private/document-import-staging/legacy-setores-20261010'
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
STAGE.mkdir(parents=True, exist_ok=True)
|
||||
csv.field_size_limit(5_000_000)
|
||||
|
||||
|
||||
def load_csv(name):
|
||||
return list(csv.DictReader((DOC / name).open(encoding='utf-8-sig', newline='')))
|
||||
|
||||
|
||||
class Markup(HTMLParser):
|
||||
def __init__(self, text):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.tags, self.links, self.text = Counter(), [], []
|
||||
self.feed(text)
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.tags[tag] += 1
|
||||
for key, value in attrs:
|
||||
if key in ['href', 'src'] and value:
|
||||
self.links.append(value)
|
||||
|
||||
def handle_data(self, data):
|
||||
self.text.append(data)
|
||||
|
||||
|
||||
inventory_file = PRIVATE / 'legacy-discovery/inventory.json'
|
||||
inventory = json.loads(inventory_file.read_text(encoding='utf-8'))
|
||||
records = {r['url']: r for r in inventory['records']}
|
||||
# Inventory every existing captured file before selecting this increment.
|
||||
for record in records.values():
|
||||
if record.get('status') == 200:
|
||||
path = ROOT / record['private_file']
|
||||
assert path.is_file() and hashlib.sha256(path.read_bytes()).hexdigest() == record['sha256']
|
||||
|
||||
home = records['https://investemoz.tempo.co.mz/']['public_props']
|
||||
sector_cards = home['dynamicOpportunities']
|
||||
assert len(sector_cards) == 5
|
||||
remaining = [card for card in sector_cards if card['slug'] != 'agronegocio']
|
||||
|
||||
# Use the existing anonymous collector for current sources, without rerunning its crawl.
|
||||
namespace = {'__file__': str(ROOT / 'tools/legacy-content-discovery.py')}
|
||||
collector = (ROOT / 'tools/legacy-content-discovery.py').read_text(encoding='utf-8')
|
||||
exec(collector[:collector.index('\npending = ')], namespace)
|
||||
fresh = [namespace['fetch'](card['href']) for card in remaining]
|
||||
(OUT / 'current-public-captures.json').write_text(json.dumps(fresh, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
assert all(r.get('status') == 200 and r.get('public_props', {}).get('post') for r in fresh), 'Current sector source unavailable; preserve old captures.'
|
||||
current = {r['url']: r for r in fresh}
|
||||
|
||||
preservation = json.loads((PRIVATE / 'qa-readable-preservation.json').read_text(encoding='utf-8'))
|
||||
saved_root = Path(preservation['directory'])
|
||||
saved = json.loads((saved_root / 'manifest.json').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def saved_table(name):
|
||||
proof = saved['tables']['"public"."' + name + '"']
|
||||
data = (saved_root / proof['file']).read_bytes()
|
||||
assert hashlib.sha256(data).hexdigest() == proof['sha256']
|
||||
return [json.loads(line) for line in data.splitlines()]
|
||||
|
||||
|
||||
masters = saved_table('sectors')
|
||||
library = saved_table('documents')
|
||||
resources = saved_table('portal_resources')
|
||||
|
||||
|
||||
def normalized(value):
|
||||
import unicodedata
|
||||
return re.sub(r'\s+', ' ', ''.join(c for c in unicodedata.normalize('NFD', value.replace('&', ' e ')) if not unicodedata.combining(c))).strip().casefold()
|
||||
|
||||
|
||||
selected, summaries = {}, []
|
||||
for card in remaining:
|
||||
record = current[card['href']]
|
||||
post = record['public_props']['post']
|
||||
markup = Markup(post['content'])
|
||||
matches = [s for s in masters if normalized(s['name']) == normalized(post['title']) and s['is_active']]
|
||||
assert len(matches) == 1, 'Sector master correspondence must be unambiguous.'
|
||||
selected[record['url']] = record
|
||||
linked = []
|
||||
for ref in markup.links:
|
||||
url = urllib.parse.urljoin(record['url'], ref)
|
||||
child = records.get(url)
|
||||
if child and child.get('public_props', {}).get('post'):
|
||||
selected[url] = child
|
||||
linked.append({'url': url, 'legacy_id': child['public_props']['post']['id'], 'title': child['public_props']['post']['title']})
|
||||
summaries.append({'slug': card['slug'], 'title': post['title'], 'url': record['url'], 'legacy_id': post['id'],
|
||||
'master_sector': {k: matches[0][k] for k in ['id', 'name', 'slug']},
|
||||
'characters': len(re.sub(r'\s+', ' ', ' '.join(markup.text)).strip()), 'linked_articles': linked,
|
||||
'tags': dict(markup.tags), 'body_sha256': hashlib.sha256(post['content'].encode()).hexdigest(),
|
||||
'same_body_as_initial_capture': post['content'] == records[record['url']]['public_props']['post']['content'],
|
||||
'same_modified_as_initial_capture': record['public_props']['seo'].get('modifiedTime') == records[record['url']]['public_props']['seo'].get('modifiedTime')})
|
||||
|
||||
versions, images, manifest, article_details = {}, {}, [], []
|
||||
for record in selected.values():
|
||||
post, seo = record['public_props']['post'], record['public_props']['seo']
|
||||
body_hash = hashlib.sha256(post['content'].encode()).hexdigest()
|
||||
key = (str(post['id']), seo.get('modifiedTime'), body_hash)
|
||||
if key in versions:
|
||||
versions[key]['urls'].append(record['url'])
|
||||
continue
|
||||
image_url = urllib.parse.urljoin(record['url'], post['thumbnail'])
|
||||
image = records[image_url]
|
||||
assert image['status'] == 200 and image['media_type'] == 'image/png'
|
||||
images.setdefault(image['sha256'], image)
|
||||
content = {'format': 'investmoz-legacy-article-v1', 'source_url': record['url'], 'legacy_id': post['id'],
|
||||
'slug': post['slug'], 'title': post['title'], 'author': post['author'], 'legacy_category': post['category'],
|
||||
'published_at': seo['publishedTime'], 'modified_at': seo['modifiedTime'], 'content_html': post['content'],
|
||||
'image_source_url': image_url, 'image_sha256': image['sha256'], 'references': Markup(post['content']).links}
|
||||
file_name = f"post-{post['id']}-{seo['modifiedTime'][:10].replace('-', '')}.txt"
|
||||
data = json.dumps(content, ensure_ascii=False, indent=2).encode()
|
||||
(STAGE / file_name).write_bytes(data)
|
||||
identity = f"legacy-tempo-post-{post['id']}-{seo['modifiedTime'][:10].replace('-', '')}"
|
||||
duplicate = next((d['id'] for d in library if d['sha256'].strip() == hashlib.sha256(data).hexdigest()), None)
|
||||
conflicts = [d['id'] for d in library if d.get('external_id') == identity and d['sha256'].strip() != hashlib.sha256(data).hexdigest()]
|
||||
topic = next((s['master_sector']['slug'] for s in summaries if s['url'] == record['url']), '')
|
||||
if post['category'] == 'Turismo':
|
||||
topic = 'turismo' # Original category, not a territorial geometry association.
|
||||
if record['url'].rstrip('/').endswith('/energia'):
|
||||
topic = 'energia-especifica'
|
||||
if record['url'].rstrip('/').endswith('/mineracao'):
|
||||
topic = 'mineracao'
|
||||
manifest.append({'external_id': identity, 'relative_path': STAGE.name + '/' + file_name, 'file_name': file_name,
|
||||
'title': post['title'], 'issuer': post['author'], 'document_date': seo['publishedTime'][:10],
|
||||
'version_label': 'Legado modificado ' + seo['modifiedTime'], 'source_url': record['url'],
|
||||
'source_reference': f"Tempo; post {post['id']}; categoria original {post['category']}; publicado {seo['publishedTime']}; modificado {seo['modifiedTime']}. Conteúdo editorial preservado.",
|
||||
'category': 'education', 'topic': topic, 'document_type': 'guide' if post['content'].strip() else 'reference_data',
|
||||
'language': 'pt', 'description': 'Exemplar editorial do legado; divulgação separada. ' + ('Corpo vazio no original; não substituir por texto inventado.' if not post['content'].strip() else 'Não representa uma oportunidade concreta ou aprovação externa.')})
|
||||
details = {'id': post['id'], 'title': post['title'], 'category': post['category'], 'urls': [record['url']],
|
||||
'published': seo['publishedTime'], 'modified': seo['modifiedTime'], 'body_sha256': body_hash,
|
||||
'library_bytes_sha256': hashlib.sha256(data).hexdigest(), 'duplicate_document': duplicate, 'identity_conflicts': conflicts,
|
||||
'file': file_name, 'tags': dict(Markup(post['content']).tags)}
|
||||
versions[key] = details
|
||||
article_details.append(details)
|
||||
|
||||
for image in images.values():
|
||||
name = 'image-' + image['sha256'][:16] + '.png'
|
||||
shutil.copyfile(ROOT / image['private_file'], STAGE / name)
|
||||
assert hashlib.sha256((STAGE / name).read_bytes()).hexdigest() == image['sha256']
|
||||
manifest.append({'external_id': 'legacy-tempo-image-' + image['sha256'][:16], 'relative_path': STAGE.name + '/' + name,
|
||||
'file_name': name, 'title': 'Imagem original — ' + Path(urllib.parse.urlsplit(image['url']).path).name,
|
||||
'issuer': 'Tempo (alojamento; autoria individual não indicada)', 'version_label': image['sha256'][:32],
|
||||
'source_url': image['url'], 'source_reference': 'Imagem explicitamente referenciada no thumbnail legado; criação e autoria individual não indicadas.',
|
||||
'category': 'media', 'topic': '', 'document_type': 'reference_data', 'language': 'pt',
|
||||
'description': 'Original preservado. Fundo genérico não identifica um local, parcela ou oportunidade.'})
|
||||
(STAGE / 'manifest.json').write_text(json.dumps({'documents': manifest}, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
inventory_rows = load_csv('legado-inventario.csv')
|
||||
content_rows = load_csv('legado-conteudos-reconciliados.csv')
|
||||
for row in inventory_rows:
|
||||
if row['URL'] in selected or row['SHA256'] in images:
|
||||
row['Destino proposto'] = 'Biblioteca existente; adapter/componente LegacyContent e portal/legacy; URL original preservado'
|
||||
row['Situação'] = 'Reconciliado por identidade/versão/hash; lote privado preparado; não importado; scan/divulgação/UI pendentes; C71 bloqueia aplicação'
|
||||
for row in content_rows:
|
||||
key = (row['ID legado'], row['Modificado'], row['SHA256 conteúdo'])
|
||||
if key in versions:
|
||||
row['Situação'] = 'Reconciliado; original preparado na biblioteca existente; não importado; sem recurso QA novo; visual/scan/divulgação pendentes'
|
||||
|
||||
result = {'database_access': False, 'database_writes': 0, 'active_access': False,
|
||||
'master_snapshot_at': preservation['created_at'], 'master_snapshot_target': preservation['target'],
|
||||
'initial_inventory_files_verified': len(records), 'initial_inventory_sha256': hashlib.sha256(inventory_file.read_bytes()).hexdigest(),
|
||||
'sector_summaries': summaries, 'articles': article_details, 'image_count': len(images), 'manifest_count': len(manifest),
|
||||
'manifest_path': str((STAGE / 'manifest.json').relative_to(ROOT)), 'import_applied': False,
|
||||
'scan_verified': False, 'public_disclosure_applied': False, 'interface_accepted': False,
|
||||
'csv_updates': {'legado-inventario.csv': inventory_rows, 'legado-conteudos-reconciliados.csv': content_rows}}
|
||||
(OUT / 'reconciliation.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'sectors': len(summaries), 'articles': len(article_details), 'images': len(images), 'manifest_items': len(manifest),
|
||||
'unchanged_current_sector_bodies': sum(s['same_body_as_initial_capture'] for s in summaries), 'database_writes': 0, 'import_applied': False}))
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Verify the untouched origin field independently of the editorial adapter."""
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
directory = root / 'storage/app/private/investmoz-v2/portal-audit/legacy-financial-origin-20261010'
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
url = 'https://investemoz.tempo.co.mz/mercado-financeiro'
|
||||
agent = 'Mozilla/5.0 InvestMoz-QA-content-inventory'
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers={'Accept': 'text/html', 'User-Agent': agent}), timeout=35) as response:
|
||||
raw = response.read()
|
||||
status_html = response.status
|
||||
match = re.search(r'data-page="([^"]+)"', raw.decode('utf-8'))
|
||||
assert match
|
||||
page = json.loads(html.unescape(match.group(1)))
|
||||
post = page['props']['post']
|
||||
assert post['id'] == 171994
|
||||
headers = {'Accept': 'application/json', 'User-Agent': agent, 'X-Inertia': 'true', 'X-Requested-With': 'XMLHttpRequest', 'X-Inertia-Version': page['version']}
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=35) as response:
|
||||
raw_json = response.read()
|
||||
status_json = response.status
|
||||
data = json.loads(raw_json)
|
||||
assert data['props']['post']['id'] == post['id']
|
||||
# Preserve anonymous HTTP snapshots privately; never serve shared session props.
|
||||
(directory / 'origin-html-private.bin').write_bytes(raw)
|
||||
(directory / 'origin-inertia-private.bin').write_bytes(raw_json)
|
||||
proof = {'captured_at': datetime.now(timezone.utc).isoformat(), 'source_url': url,
|
||||
'legacy_id': post['id'], 'html_http_status': status_html, 'inertia_http_status': status_json,
|
||||
'origin_html_bytes_sha256': hashlib.sha256(raw).hexdigest(),
|
||||
'origin_inertia_bytes_sha256': hashlib.sha256(raw_json).hexdigest(),
|
||||
'initial_origin_content_type': type(post['content']).__name__,
|
||||
'initial_origin_content_length': len(post['content']),
|
||||
'inertia_origin_content_length': len(data['props']['post']['content']),
|
||||
'contents_identical': data['props']['post']['content'] == post['content'],
|
||||
'origin_content_sha256': hashlib.sha256(post['content'].encode()).hexdigest(),
|
||||
'extraction_or_sanitization_applied': False, 'publication_applied': False,
|
||||
'conclusion': 'Origin returns an empty string in both raw server payloads; not an extraction loss.'}
|
||||
assert proof['initial_origin_content_length'] == 0 and proof['inertia_origin_content_length'] == 0
|
||||
(directory / 'proof.json').write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps(proof, ensure_ascii=False))
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
// Exercise the existing presentation adapter against staged originals; no app/DB bootstrap.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$root = dirname(__DIR__).'/storage/app/private/investmoz-v2/portal-audit/legacy-sector-reconciliation-20261010';
|
||||
$reconciliation = json_decode(file_get_contents($root.'/reconciliation.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$stage = dirname(__DIR__).'/storage/app/private/document-import-staging/legacy-setores-20261010';
|
||||
$adapter = new App\Services\LegacyContent;
|
||||
$checks = [];
|
||||
foreach ($reconciliation['articles'] as $article) {
|
||||
$bytes = file_get_contents($stage.'/'.$article['file']);
|
||||
if (hash('sha256', $bytes) !== $article['library_bytes_sha256']) {
|
||||
throw new RuntimeException('Staged source bytes changed.');
|
||||
}
|
||||
$source = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
$projected = $adapter->sanitize($source['content_html']);
|
||||
$doc = new DOMDocument;
|
||||
$old = libxml_use_internal_errors(true);
|
||||
$doc->loadHTML('<?xml encoding="UTF-8"><html><body>'.$source['content_html'].'</body></html>', LIBXML_NONET);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($old);
|
||||
$headings = [];
|
||||
foreach ($doc->getElementsByTagName('h1') as $heading) {
|
||||
$headings[] = str_contains(html_entity_decode(strip_tags($projected), ENT_QUOTES | ENT_HTML5, 'UTF-8'), $heading->textContent);
|
||||
}
|
||||
$checks[] = ['legacy_id' => $article['id'], 'original_sha256_preserved' => true,
|
||||
'source_h1_count' => count($headings), 'heading_text_preserved' => ! in_array(false, $headings, true),
|
||||
'projected_h1_count' => preg_match_all('/<h1\b/i', $projected), 'projection_sha256' => hash('sha256', $projected)];
|
||||
}
|
||||
$malicious = $adapter->sanitize('<h1 onmouseover="bad()">Título<script>bad()</script><a href="javascript:bad()">Fonte</a></h1>');
|
||||
$security = ! preg_match('/<script|javascript:|onmouseover/i', $malicious);
|
||||
$passed = $security && ! in_array(false, array_column($checks, 'heading_text_preserved'), true);
|
||||
$phase = $argv[1] ?? 'after';
|
||||
if (! in_array($phase, ['before', 'after'], true)) {
|
||||
exit(2);
|
||||
}
|
||||
$proof = ['result' => $passed ? 'passed' : 'failed', 'phase' => $phase, 'checks' => $checks,
|
||||
'active_markup_removed' => $security, 'database_access' => false, 'interface_accepted' => false];
|
||||
file_put_contents($root.'/content-check-'.$phase.'.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['result' => $proof['result'], 'phase' => $phase, 'articles' => count($checks),
|
||||
'missing_heading_articles' => array_values(array_column(array_filter($checks, fn ($c) => ! $c['heading_text_preserved']), 'legacy_id')),
|
||||
'active_markup_removed' => $security, 'database_access' => false, 'interface_accepted' => false], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
exit($passed ? 0 : 1);
|
||||
@@ -0,0 +1,128 @@
|
||||
// Actual desktop Chromium UI on guarded QA. No QA18 credentials, cookies, or MFA values in evidence.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium } = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const ROOT = path.resolve(__dirname, '..'), PRIVATE = path.join(ROOT, 'storage/app/private/investmoz-v2/portal-audit');
|
||||
const BASE = 'http://127.0.0.1:18097', TARGET = 'desk_restore_20261009_174726_test:55441';
|
||||
const actors = JSON.parse(fs.readFileSync(path.join(PRIVATE, 'profiles-v1-actors-private.json'), 'utf8'));
|
||||
assert.equal(actors.promoter.id, 21);
|
||||
const receipt = path.join(PRIVATE, 'legacy-sectors-browser-qa.json');
|
||||
const old = fs.existsSync(receipt) ? JSON.parse(fs.readFileSync(receipt, 'utf8')) : {};
|
||||
if (old.started_at) fs.copyFileSync(receipt, path.join(PRIVATE, 'legacy-sectors-browser-attempt-' + old.started_at.replaceAll(':', '-') + '.json'));
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-'), shots = path.join(PRIVATE, 'legacy-sectors-browser-' + stamp);
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const proof = { started_at: new Date().toISOString(), base: BASE, target: TARGET, browser: 'Chromium 1243, isolated headless desktop contexts',
|
||||
interface_observed: true, actor_id: 21, active_writes: 0, dossiers: old.dossiers || {}, checks: {}, screenshots: [], events: [], phase: 'initialization' };
|
||||
let browser;
|
||||
function save() { fs.writeFileSync(receipt, JSON.stringify(proof, null, 2)); }
|
||||
function phase(value) { proof.phase = value; save(); console.log(JSON.stringify({ phase: value })); }
|
||||
function otp(secret) {
|
||||
let bits = 0, value = 0; const bytes = [], alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
for (const char of secret.toUpperCase().replace(/=+$/, '')) { const digit = alphabet.indexOf(char); assert(digit >= 0); value = (value << 5) | digit; bits += 5; if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 255); bits -= 8; } }
|
||||
const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30000)));
|
||||
const digest = crypto.createHmac('sha1', Buffer.from(bytes)).update(counter).digest(), offset = digest[19] & 15;
|
||||
return String((digest.readUInt32BE(offset) & 0x7fffffff) % 1000000).padStart(6, '0');
|
||||
}
|
||||
async function context() {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'pt-MZ', reducedMotion: 'reduce' });
|
||||
ctx.qaConfirmed = false;
|
||||
await ctx.route('**/*', async (route) => { const request = route.request(), url = new URL(request.url());
|
||||
if (url.origin !== BASE) return route.abort();
|
||||
if (!['GET', 'HEAD'].includes(request.method())) assert(ctx.qaConfirmed, 'Actual QA guard precedes writes');
|
||||
await route.continue(); });
|
||||
ctx.on('response', (response) => { const url = new URL(response.url()); if (url.origin === BASE && !url.pathname.startsWith('/build-qa-')) {
|
||||
const headers = response.headers(); proof.events.push({ method: response.request().method(), path: url.pathname, status: response.status(),
|
||||
target: headers['x-investmoz-qa-target'] || null, redirect_path: headers.location ? new URL(headers.location, BASE).pathname : null }); save(); } });
|
||||
ctx.on('requestfailed', request => { const url = new URL(request.url()); proof.events.push({ request_failed: url.pathname, reason: request.failure()?.errorText }); save(); });
|
||||
return ctx;
|
||||
}
|
||||
async function open(page, url) { const response = await page.goto(BASE + url, { waitUntil: 'domcontentloaded', timeout: 45000 }); assert.equal(response.headers()['x-investmoz-qa-target'], TARGET); page.context().qaConfirmed = true; return response; }
|
||||
async function shot(page, name, fullPage = true) { const file = path.join(shots, name + '.png'); await page.screenshot({ path: file, fullPage }); proof.screenshots.push(path.relative(ROOT, file).replaceAll('\\', '/')); }
|
||||
async function login(page, role) {
|
||||
const account = actors[role]; if (new URL(page.url()).pathname !== '/login') await open(page, '/login');
|
||||
await page.locator('#email').fill(account.email); await page.locator('#password').fill(account.password);
|
||||
await page.locator('[data-test="login-button"]').click(); await page.waitForURL(BASE + '/two-factor-challenge');
|
||||
await page.locator('input[name="code"]').fill(otp(account.totp_secret)); await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
||||
await page.waitForURL(url => !['/login', '/two-factor-challenge'].includes(url.pathname)); await page.waitForLoadState('networkidle');
|
||||
}
|
||||
async function logout(page) { await open(page, '/dashboard'); const triggers = page.locator('button[aria-haspopup="menu"]');
|
||||
for (let i = await triggers.count() - 1; i >= 0; i--) { await triggers.nth(i).click(); const action = page.locator('[data-test="logout-button"]');
|
||||
if (await action.isVisible()) { await action.click(); await page.waitForURL(BASE + '/'); return; } await page.keyboard.press('Escape'); }
|
||||
throw new Error('Visible logout missing'); }
|
||||
async function dossier(page, entry) { await page.waitForURL(BASE + '/investimentos/' + entry.id); await page.getByRole('heading', { name: entry.title, exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('heading', { name: /^Fase \d+ ·/ }).count(), 5); assert.equal(await page.locator('article[id^="S"]').count(), 19);
|
||||
const refs = page.getByRole('region', { name: 'Fontes da ideia' }); assert.equal(await refs.locator('li').count(), 1);
|
||||
assert.equal(new URL(await refs.locator('a').first().getAttribute('href'), BASE).pathname, entry.href);
|
||||
await page.getByRole('heading', { name: 'Próxima acção', exact: true }).waitFor(); }
|
||||
(async () => {
|
||||
browser = await chromium.launch({ headless: true, executablePath: 'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe' });
|
||||
phase('public sector pages, images and search');
|
||||
const ctx = await context(), page = await ctx.newPage(); page.setDefaultTimeout(15000);
|
||||
for (const [group, expected] of [['energia-mineracao', 3], ['turismo', 12], ['infraestruturas', 1]]) {
|
||||
assert.equal((await open(page, '/explorar/' + group)).status(), 200);
|
||||
assert.equal(await page.getByRole('link', { name: 'Ler o artigo', exact: true }).count(), expected);
|
||||
const image = page.locator('main img'); await image.waitFor(); await image.evaluate(img => img.decode()); assert(await image.evaluate(img => img.complete && img.naturalWidth > 0));
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); await shot(page, 'public-' + group);
|
||||
const articleLinks = await page.getByRole('link', { name: 'Ler o artigo', exact: true }).evaluateAll(links => links.map(a => a.getAttribute('href')));
|
||||
for (const href of articleLinks) { await open(page, href); const body = page.getByRole('region', { name: 'Artigo educativo' }); await body.waitFor(); assert((await body.innerText()).trim().length > 0);
|
||||
await page.locator('main img').evaluate(img => img.decode()); assert(await page.locator('main img').evaluate(img => img.complete && img.naturalWidth > 0)); }
|
||||
await open(page, '/explorar/' + group); await page.getByRole('searchbox').fill('zzzz-no-content-qa'); await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
||||
await page.getByText('Nenhum conteúdo corresponde à pesquisa.', { exact: true }).waitFor(); assert.equal(await page.getByRole('link', { name: 'Ler o artigo', exact: true }).count(), 0);
|
||||
}
|
||||
await open(page, '/explorar/energia-mineracao'); await page.getByRole('searchbox').fill('Energia'); await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
||||
await page.locator('h3 a[href="/energia"]').waitFor();
|
||||
await open(page, '/energia-mineracao'); const body = page.getByRole('region', { name: 'Artigo educativo' });
|
||||
await body.locator('a[href="/energia"]').click(); await page.waitForURL(BASE + '/energia');
|
||||
await open(page, '/energia-mineracao'); await body.locator('a[href="/mineracao"]').click(); await page.waitForURL(BASE + '/mineracao');
|
||||
await open(page, '/infraestruturas'); await page.getByRole('region', { name: 'Artigo educativo' }).getByRole('heading', { name: '2. Sectores Prioritários de Investimento', exact: true }).waitFor();
|
||||
await shot(page, 'infra-original-heading');
|
||||
assert.equal((await open(page, '/mercado-financeiro')).status(), 404);
|
||||
proof.checks.public_read_search_images_distinct_articles = true; save();
|
||||
phase('public CTA, QA21 normal MFA, UI creation or continuation');
|
||||
for (const href of ['/energia', '/mineracao', '/turismo', '/infraestruturas']) {
|
||||
await open(page, href); await page.getByRole('link', { name: 'Iniciar ou continuar uma ideia', exact: true }).click();
|
||||
await page.waitForURL(url => ['/login', '/investimentos/a-partir-de' + href].includes(url.pathname));
|
||||
if (new URL(page.url()).pathname === '/login') await login(page, 'promoter');
|
||||
await page.waitForURL(BASE + '/investimentos/a-partir-de' + href);
|
||||
const entry = proof.dossiers[href] ||= { title: 'QA visual — ideia ' + href.slice(1) + ' ' + stamp, href };
|
||||
if (!entry.id) { await page.getByLabel('Título', { exact: true }).fill(entry.title); await page.getByLabel('Autorizo reutilizar o meu perfil neste dossier privado.').check();
|
||||
await page.getByRole('button', { name: 'Criar e guardar dossier', exact: true }).click(); await page.waitForURL(/\/investimentos\/[0-9a-f-]{36}$/);
|
||||
entry.id = new URL(page.url()).pathname.split('/').pop(); entry.created_via_interface_at = new Date().toISOString(); save();
|
||||
} else { const card = page.getByRole('listitem').filter({ has: page.getByRole('link', { name: entry.title, exact: true }) });
|
||||
await card.getByRole('button', { name: 'Continuar neste dossier com o artigo', exact: true }).click(); }
|
||||
await dossier(page, entry); await shot(page, 'dossier-' + href.slice(1), false);
|
||||
await page.getByRole('region', { name: 'Fontes da ideia' }).locator('a').first().click(); await page.waitForURL(url => url.pathname === href && url.searchParams.get('context') === entry.id);
|
||||
await page.getByRole('link', { name: 'Continuar: ' + entry.title, exact: true }).click(); await dossier(page, entry);
|
||||
entry.source_return_to_same_dossier = true; save();
|
||||
}
|
||||
phase('visible logout, new MFA browser session, recovery and refresh');
|
||||
await logout(page); await ctx.close();
|
||||
const freshCtx = await context(), fresh = await freshCtx.newPage(); const first = Object.values(proof.dossiers)[0];
|
||||
await open(fresh, '/investimentos/' + first.id); await fresh.waitForURL(BASE + '/login'); await login(fresh, 'promoter');
|
||||
for (const entry of Object.values(proof.dossiers)) {
|
||||
await open(fresh, '/investimentos/' + entry.id); await dossier(fresh, entry); await fresh.reload({ waitUntil: 'networkidle' }); await dossier(fresh, entry);
|
||||
await open(fresh, '/investimentos/a-partir-de' + entry.href); const card = fresh.getByRole('listitem').filter({ has: fresh.getByRole('link', { name: entry.title, exact: true }) });
|
||||
await card.getByRole('button', { name: 'Continuar neste dossier com o artigo', exact: true }).click(); await dossier(fresh, entry);
|
||||
entry.new_session_recovered_and_refreshed = true; entry.single_reference_after_repetition = true; save();
|
||||
}
|
||||
phase('other profile denies dossiers, public context remains private');
|
||||
const otherCtx = await context(), other = await otherCtx.newPage(); await open(other, '/login'); await login(other, 'outsider');
|
||||
const publicCtx = await context(), publicPage = await publicCtx.newPage();
|
||||
for (const entry of Object.values(proof.dossiers)) {
|
||||
assert.equal((await open(other, '/investimentos/' + entry.id)).status(), 403);
|
||||
for (const reader of [other, publicPage]) { await open(reader, entry.href + '?context=' + entry.id); await reader.getByRole('region', { name: 'Artigo educativo' }).waitFor();
|
||||
assert.equal(await reader.getByText(entry.title, { exact: false }).count(), 0); }
|
||||
entry.other_actor_id = actors.outsider.id; entry.denied_status = 403; entry.private_context_hidden = true;
|
||||
}
|
||||
await logout(fresh); await freshCtx.close(); await otherCtx.close(); await publicCtx.close();
|
||||
proof.result = 'passed'; proof.completed_at = new Date().toISOString(); save();
|
||||
console.log(JSON.stringify({ result: proof.result, dossiers: Object.keys(proof.dossiers).length, screenshots: proof.screenshots.length, interface_observed: true, active_writes: 0 }));
|
||||
})().catch(async error => { proof.result = 'failed'; proof.error_type = error.name;
|
||||
let message = error.message;
|
||||
for (const actor of Object.values(actors)) for (const key of ['password', 'totp_secret', 'secret']) if (actor[key]) message = message.replaceAll(actor[key], '[REDACTED]');
|
||||
proof.error = message.replace(/\b\d{6}\b/g, '[MFA OMITIDO]').slice(0, 1600);
|
||||
proof.page_states = browser ? await Promise.all(browser.contexts().flatMap(ctx => ctx.pages()).map(async page => ({ path: new URL(page.url()).pathname, headings: await page.locator('h1').allTextContents().catch(() => []), alerts: await page.getByRole('alert').count().catch(() => 0) }))) : [];
|
||||
save(); console.error(JSON.stringify({ result: 'failed', phase: proof.phase, type: error.name })); process.exitCode = 1;
|
||||
}).finally(async () => { if (browser) await browser.close(); });
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Separate authenticated QA disclosure, real originals, and sector dossier continuity."""
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from portal_profiles_http_session import BASE, EXPECTED, PRIVATE, ROOT, Session, actors, events
|
||||
|
||||
proof = {'started_at': datetime.now(timezone.utc).isoformat(), 'target': EXPECTED, 'base': BASE,
|
||||
'interface_observed': False, 'active_writes': 0, 'phase': 'initialization'}
|
||||
proof_path = PRIVATE / 'legacy-sectors-http-qa.json'
|
||||
previous = json.loads(proof_path.read_text(encoding='utf-8')) if proof_path.exists() else {}
|
||||
if previous:
|
||||
archive = PRIVATE / ('legacy-sectors-http-attempt-' + previous['started_at'].replace(':', '-') + '.json')
|
||||
if not archive.exists():
|
||||
archive.write_text(json.dumps(previous, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
def save():
|
||||
proof_path.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
def run():
|
||||
receipt = json.loads((PRIVATE / 'legacy-sectors-verify-qa.json').read_text(encoding='utf-8'))
|
||||
manifest = json.loads((ROOT / 'storage/app/private/document-import-staging/legacy-setores-20261010/manifest.json').read_text(encoding='utf-8'))
|
||||
assert receipt['target'] == {'database': 'desk_restore_20261009_174726_test', 'port': 55441}
|
||||
assert len(receipt['resources']) == 22 and all(c['clean_scan_for_hash'] and c['stored_original_equal'] for c in receipt['checks'])
|
||||
actors['library_manager'] = json.loads((PRIVATE / 'resource-http-actors-private.json').read_text(encoding='utf-8'))['manager']
|
||||
anonymous = Session('outsider')
|
||||
manager = Session('library_manager').login()
|
||||
rows = receipt['resources']
|
||||
proof['phase'] = 'separate_authenticated_disclosure'
|
||||
proof['publication_actions'] = []
|
||||
for row in rows:
|
||||
record = next(r for r in manifest['documents'] if r['external_id'] == row['external_id'])
|
||||
assert hashlib.sha256(manager.call('GET', f"/management/documents/{row['document_id']}/download", binary=True)).hexdigest() == row['sha256'].strip()
|
||||
if record['external_id'].startswith('legacy-tempo-post-171994-'):
|
||||
assert not row['published']
|
||||
anonymous.call('GET', f"/documentos/{row['id']}/view", status=404, binary=True)
|
||||
proof['empty_finance_private'] = {'resource_id': row['id'], 'external_id': row['external_id'], 'published': False}
|
||||
continue
|
||||
if not row['published']:
|
||||
anonymous.call('GET', f"/documentos/{row['id']}/view", status=404, binary=True)
|
||||
data = {k: record[k] for k in ['title', 'issuer', 'category', 'topic', 'document_type', 'source_reference', 'source_url', 'language', 'version_label', 'description']}
|
||||
data.update({'document_id': row['document_id'], 'document_date': record.get('document_date'), 'metadata_version': row['metadata_version'],
|
||||
'territorial_scope': 'unknown', 'source_confirmation': 'identified', 'legal_status': 'not_applicable',
|
||||
'rights_status': 'authorized', 'data_classification': 'public', 'independent_review': False,
|
||||
'legislation': False, 'essential': False,
|
||||
'source_reference': record['source_reference'] + '; utilização e divulgação do legado exclusivamente em QA autorizadas pelo responsável em 10/10/2026; sem habilitação ativa.'})
|
||||
if record['external_id'].startswith('legacy-tempo-post-'):
|
||||
source = json.loads((ROOT / 'storage/app/private/document-import-staging' / record['relative_path']).read_text(encoding='utf-8'))
|
||||
image = next((r for r in rows if r['sha256'].strip() == source.get('image_sha256')), None)
|
||||
if image:
|
||||
data['associations'] = [{'target_type': 'document', 'target_key': str(image['id']), 'relationship': 'related_to',
|
||||
'applicability': 'support', 'basis_reference': 'Thumbnail identificada no exemplar original do post: ' + image['source_url'], 'review_status': 'confirmed'}]
|
||||
manager.call('PUT', f"/management/portal-services/resources/{row['id']}", data, status=302, binary=True)
|
||||
manager.call('POST', f"/management/portal-services/resources/{row['id']}", {'published': True}, binary=True)
|
||||
proof['publication_actions'].append({'resource_id': row['id'], 'actor_id': actors['library_manager']['id'], 'authenticated': True,
|
||||
'mfa_challenge_observed': any(e['actor_id'] == actors['library_manager']['id'] and e['path'] == '/two-factor-challenge' and e['status'] == 204 for e in events)})
|
||||
save()
|
||||
assert hashlib.sha256(anonymous.call('GET', f"/documentos/{row['id']}/download", binary=True)).hexdigest() == row['sha256'].strip()
|
||||
|
||||
proof['phase'] = 'public_consumers'
|
||||
articles = {}
|
||||
for group, expected in [('energia-mineracao', 3), ('turismo', 12), ('infraestruturas', 1)]:
|
||||
landing = anonymous.call('GET', '/explorar/' + group, inertia=True)['props']
|
||||
assert len(landing['articles']) == expected
|
||||
for brief in landing['articles']:
|
||||
article = anonymous.call('GET', brief['href'], inertia=True)['props']['article']
|
||||
assert article['html'].strip() and article['image'] and not article['calculation_use']
|
||||
assert article['territorial_references'] == []
|
||||
assert hashlib.sha256(anonymous.call('GET', article['image']['href'], binary=True)).hexdigest() == article['image']['sha256']
|
||||
anonymous.call('GET', article['library_href'], inertia=True)
|
||||
anonymous.call('GET', article['start_href'], status=401, binary=True)
|
||||
articles[brief['href']] = article
|
||||
missing = anonymous.call('GET', '/explorar/' + group + '?q=zzzz-no-content-qa', inertia=True)['props']
|
||||
assert not missing['match'] and missing['articles'] == []
|
||||
hit = anonymous.call('GET', '/explorar/energia-mineracao?q=Energia', inertia=True)['props']
|
||||
assert hit['match'] and any(a['href'] == '/energia' for a in hit['articles'])
|
||||
assert articles['/energia']['id'] != articles['/mineracao']['id']
|
||||
assert 'href="/energia"' in articles['/energia-mineracao']['html'] and 'href="/mineracao"' in articles['/energia-mineracao']['html']
|
||||
assert '<h2' in articles['/infraestruturas']['html']
|
||||
anonymous.call('GET', '/mercado-financeiro', status=404, binary=True)
|
||||
anonymous.call('GET', '/explorar/mercado-financeiro', status=404, binary=True)
|
||||
proof['public'] = {'new_nonempty_articles': len(articles), 'distinct_energy_mining': True,
|
||||
'tourism_editorial_only': True, 'finance_empty_page_unavailable': True,
|
||||
'image_and_original_hashes': True, 'search_hit_and_miss': True, 'infra_original_heading_preserved': True,
|
||||
'urls': list(articles)}
|
||||
save()
|
||||
|
||||
proof['phase'] = 'dossier_continuity'
|
||||
owner = Session('promoter').login()
|
||||
other = Session('outsider').login()
|
||||
proof['dossiers'] = previous.get('dossiers', {})
|
||||
for href in ['/energia', '/mineracao', '/turismo', '/infraestruturas']:
|
||||
article = articles[href]
|
||||
choice = owner.call('GET', article['start_href'], inertia=True)['props']
|
||||
assert choice['contentSource']['id'] == article['id']
|
||||
entry = proof['dossiers'].setdefault(href, {})
|
||||
if not entry.get('id'):
|
||||
entry['id'] = owner.call('POST', '/investimentos', {'title': 'Synthetic QA — ideia ' + article['title'],
|
||||
'client_key': str(uuid.uuid4()), 'reuse_consent': True, 'content_resource_id': article['id']}, status=201)['id']
|
||||
save()
|
||||
case = entry['id']
|
||||
first = owner.call('GET', '/investimentos/' + case)
|
||||
assert len(first['phases']) == 5 and len(first['steps']) == 19
|
||||
assert len(first['content_sources']) == 1 and first['content_sources'][0]['resource_id'] == article['id']
|
||||
owner.call('POST', f'/investimentos/{case}/conteudos', {'version': first['case']['version'], 'resource_id': article['id']})
|
||||
repeated = owner.call('GET', '/investimentos/' + case)
|
||||
assert repeated['case']['version'] == first['case']['version'] and repeated['content_sources'] == first['content_sources']
|
||||
other.call('GET', '/investimentos/' + case, status=403, binary=True)
|
||||
other.call('POST', f'/investimentos/{case}/conteudos', {'version': first['case']['version'], 'resource_id': article['id']}, status=403, binary=True)
|
||||
assert other.call('GET', href + '?context=' + case, inertia=True)['props']['contentContext'] is None
|
||||
assert anonymous.call('GET', href + '?context=' + case, inertia=True)['props']['contentContext'] is None
|
||||
entry.update(resource_id=article['id'], return_href=href, other_account_denied=True, reference_replay_idempotent=True)
|
||||
save()
|
||||
owner.call('POST', '/logout', {}, status=204)
|
||||
recovered = Session('promoter').login()
|
||||
for href, entry in proof['dossiers'].items():
|
||||
view = recovered.call('GET', '/investimentos/' + entry['id'])
|
||||
assert len(view['content_sources']) == 1 and view['content_sources'][0]['return_href'] == href
|
||||
assert recovered.call('GET', href + '?context=' + entry['id'], inertia=True)['props']['contentContext']['id'] == entry['id']
|
||||
entry['new_mfa_session_recovered'] = True
|
||||
proof.update(result='passed', completed_at=datetime.now(timezone.utc).isoformat(), events=events, actor_id=21)
|
||||
save()
|
||||
print(json.dumps({'result': 'passed', 'new_articles': len(articles), 'publication_actions': len(proof['publication_actions']),
|
||||
'dossiers': len(proof['dossiers']), 'events': len(events), 'finance_private': True, 'active_writes': 0}))
|
||||
|
||||
try:
|
||||
run()
|
||||
except Exception as error:
|
||||
proof.update(result='failed', error_type=type(error).__name__, events=events)
|
||||
save()
|
||||
print(json.dumps({'result': 'failed', 'phase': proof['phase'], 'error_type': type(error).__name__, 'details': 'private sanitized receipt'}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
// Existing private-library importer, isolated QA only; disclosure is a separate HTTP action.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
require __DIR__.'/portal-qa-environment.php';
|
||||
$target = portalQaEnvironment();
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$identity = portalQaAssertTarget($target);
|
||||
config(['portal.managers' => ['opportunity-manager@example.invalid', 'geo-manager-qa@example.invalid']]);
|
||||
$directory = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
$stamp = gmdate('Ymd_His');
|
||||
set_exception_handler(function (Throwable $error) use ($directory, $stamp): void {
|
||||
file_put_contents($directory.'/legacy-sector-import-error-private-'.$stamp.'.json', json_encode(['type' => get_class($error), 'message' => $error->getMessage()], JSON_THROW_ON_ERROR));
|
||||
fwrite(STDERR, "QA sector operation failed; inspect private diagnostic.\n");
|
||||
exit(1);
|
||||
});
|
||||
$mode = $argv[1] ?? 'preview';
|
||||
throw_unless(in_array($mode, ['preview', 'apply', 'verify', 'replay'], true), RuntimeException::class, 'Unknown operation');
|
||||
$manifestName = 'legacy-setores-20261010/manifest.json';
|
||||
$staging = storage_path('app/private/document-import-staging');
|
||||
$manifest = json_decode(file_get_contents($staging.'/'.$manifestName), true, 512, JSON_THROW_ON_ERROR);
|
||||
throw_unless(count($manifest['documents']) === 22, RuntimeException::class, 'Expected original 22-item sector lot');
|
||||
$actorFile = json_decode(file_get_contents($directory.'/resource-http-actors-private.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$actor = App\Models\User::findOrFail($actorFile['manager']['id']);
|
||||
throw_unless($actor->can('manage-portal'), RuntimeException::class, 'Authorized existing library manager required');
|
||||
function sectorCounts(): array {
|
||||
$result = [];
|
||||
foreach (['documents', 'portal_resources', 'audit_events', 'document_malware_scans', 'opportunities', 'desk_cases', 'users', 'jobs'] as $table) {
|
||||
if (Illuminate\Support\Facades\Schema::hasTable($table)) { $result[$table] = Illuminate\Support\Facades\DB::table($table)->count(); }
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
$before = sectorCounts();
|
||||
$exit = null;
|
||||
if (in_array($mode, ['preview', 'apply', 'replay'], true)) {
|
||||
$args = ['manifest' => $manifestName];
|
||||
if ($mode !== 'preview') {
|
||||
$health = app(App\Services\Malware\MalwareScanner::class)->health();
|
||||
throw_unless($health['available'] ?? false, RuntimeException::class, 'Real ClamD not healthy; import refused');
|
||||
// A fresh read-only preview is mandatory before each application, including repetition.
|
||||
$previewExit = Illuminate\Support\Facades\Artisan::call('documents:import-batch', $args);
|
||||
$preview = Illuminate\Support\Facades\Artisan::output();
|
||||
file_put_contents($directory.'/legacy-sectors-pre-apply-'.$stamp.'.txt', $preview);
|
||||
throw_unless($previewExit === 0 && ! str_contains($preview, 'review required') && ! str_contains($preview, 'not imported'), RuntimeException::class, 'Preview has unresolved conflicts');
|
||||
$args += ['--apply' => true, '--actor' => (string) $actor->id];
|
||||
config(['queue.default' => 'sync']); // Actual configured ClamD scanner; no fake scan result.
|
||||
}
|
||||
$exit = Illuminate\Support\Facades\Artisan::call('documents:import-batch', $args);
|
||||
$output = Illuminate\Support\Facades\Artisan::output();
|
||||
file_put_contents($directory.'/legacy-sectors-import-'.$mode.'-'.$stamp.'.txt', $output);
|
||||
throw_unless($exit === 0 && ! str_contains($output, 'review required') && ! str_contains($output, 'not imported'), RuntimeException::class, 'Import/preview failed');
|
||||
}
|
||||
$rows = Illuminate\Support\Facades\DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->whereIn('d.external_id', array_column($manifest['documents'], 'external_id'))
|
||||
->orderBy('r.id')->get(['r.id', 'r.document_id', 'r.title', 'r.source_url', 'r.metadata_version', 'r.published', 'd.external_id', 'd.sha256',
|
||||
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.status', 'd.file_name', 'd.storage_key', 'd.storage_encryption', 'd.data_classification']);
|
||||
$checks = [];
|
||||
foreach ($rows as $row) {
|
||||
$record = collect($manifest['documents'])->firstWhere('external_id', $row->external_id);
|
||||
$path = $staging.'/'.$record['relative_path'];
|
||||
$bytes = Illuminate\Support\Facades\Storage::disk('local')->get($row->storage_key);
|
||||
if ($row->storage_encryption === 'laravel') { $bytes = Illuminate\Support\Facades\Crypt::decryptString($bytes); }
|
||||
$checks[] = ['resource_id' => $row->id, 'source_hash_equal' => hash_equals(trim($row->sha256), hash_file('sha256', $path)),
|
||||
'stored_original_equal' => hash_equals(trim($row->sha256), hash('sha256', $bytes)), 'clean_scan_for_hash' => $row->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($row->sha256), (string) $row->malware_scanned_sha256)];
|
||||
unset($bytes);
|
||||
}
|
||||
$after = sectorCounts();
|
||||
if ($mode === 'preview') { throw_unless($before === $after, RuntimeException::class, 'Preview unexpectedly changed database'); }
|
||||
if ($mode !== 'preview') {
|
||||
throw_unless($rows->count() === 22 && collect($checks)->every(fn ($c) => $c['source_hash_equal'] && $c['stored_original_equal'] && $c['clean_scan_for_hash']), RuntimeException::class, 'Original/hash/scan checks incomplete');
|
||||
if ($mode === 'apply') { throw_unless($rows->every(fn ($r) => ! $r->published && $r->data_classification === 'internal'), RuntimeException::class, 'Importer must not publish'); }
|
||||
if ($mode === 'replay') { throw_unless($before === $after, RuntimeException::class, 'Repeat import is not idempotent'); }
|
||||
}
|
||||
$proof = ['mode' => $mode, 'captured_at' => gmdate('c'), 'target' => $identity, 'manifest_sha256' => hash_file('sha256', $staging.'/'.$manifestName),
|
||||
'expected_items' => 22, 'resources' => $rows, 'checks' => $checks, 'before' => $before, 'after' => $after, 'command_exit' => $exit,
|
||||
'scanner_class' => get_class(app(App\Services\Malware\MalwareScanner::class)), 'active_writes' => 0, 'publication_is_separate' => true];
|
||||
$path = $directory.'/legacy-sectors-'.$mode.'-'.$stamp.'.json';
|
||||
file_put_contents($path, json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
file_put_contents($directory.'/legacy-sectors-'.$mode.'-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['mode' => $mode, 'expected_items' => 22, 'resources_found' => $rows->count(), 'before' => $before, 'after' => $after, 'command_exit' => $exit,
|
||||
'target' => $identity, 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
// Independent read-only persistence proof after real HTTP and Chromium journeys.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
require __DIR__.'/portal-qa-environment.php';
|
||||
$target = portalQaEnvironment();
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$identity = portalQaAssertTarget($target);
|
||||
$dir = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
set_exception_handler(function (Throwable $error) use ($dir): void {
|
||||
file_put_contents($dir.'/legacy-sector-persistence-error-private.json', json_encode(['type' => get_class($error), 'message' => $error->getMessage()]));
|
||||
fwrite(STDERR, "QA persistence check failed; inspect private diagnostic.\n"); exit(1);
|
||||
});
|
||||
$http = json_decode(file_get_contents($dir.'/legacy-sectors-http-qa.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$browser = json_decode(file_get_contents($dir.'/legacy-sectors-browser-qa.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
throw_unless(($http['result'] ?? null) === 'passed' && ($browser['result'] ?? null) === 'passed'
|
||||
&& $http['target'] === $target['database'].':55441' && $browser['target'] === $http['target'], RuntimeException::class, 'Passed same-target evidence required');
|
||||
Illuminate\Support\Facades\DB::statement('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
try {
|
||||
$cases = [];
|
||||
foreach (['HTTP' => $http, 'Chromium' => $browser] as $consumer => $receipt) {
|
||||
foreach ($receipt['dossiers'] as $href => $entry) {
|
||||
$case = Illuminate\Support\Facades\DB::table('desk_cases')->where('id', $entry['id'])->firstOrFail();
|
||||
$payload = app(App\Services\VirtualDesk::class)->decode($case->payload);
|
||||
$refs = $payload['investor_path']['content_sources'] ?? [];
|
||||
$events = Illuminate\Support\Facades\DB::table('desk_events')->where('case_id', $case->id)->where('action', 'legacy_content_referenced')->count();
|
||||
throw_unless((int) $case->user_id === 21 && count($refs) === 1 && $refs[0]['return_href'] === $href && $events === 1,
|
||||
RuntimeException::class, 'Dossier/source isolation or idempotence failed');
|
||||
$cases[] = ['consumer' => $consumer, 'id' => $case->id, 'resource_id' => $refs[0]['resource_id'], 'return_href' => $href,
|
||||
'reference_count' => 1, 'reference_event_count' => 1, 'version' => $case->version];
|
||||
}
|
||||
}
|
||||
$manifest = json_decode(file_get_contents(storage_path('app/private/document-import-staging/legacy-setores-20261010/manifest.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
$resources = Illuminate\Support\Facades\DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->whereIn('d.external_id', array_column($manifest['documents'], 'external_id'))->get(['r.id', 'r.published', 'r.metadata_version', 'd.external_id', 'd.sha256', 'd.malware_scan_status', 'd.malware_scanned_sha256']);
|
||||
$originals = [];
|
||||
foreach ($resources as $row) {
|
||||
$count = Illuminate\Support\Facades\DB::table('documents')->where('sha256', $row->sha256)->whereNull('deleted_at')->count();
|
||||
throw_unless($count === 1 && $row->malware_scan_status === 'clean' && hash_equals(trim($row->sha256), trim($row->malware_scanned_sha256)), RuntimeException::class, 'Original duplication or missing scan');
|
||||
$originals[] = ['resource_id' => $row->id, 'documents_for_hash' => $count, 'metadata_version' => $row->metadata_version, 'published' => $row->published];
|
||||
}
|
||||
$finance = $resources->first(fn ($row) => str_starts_with($row->external_id, 'legacy-tempo-post-171994-'));
|
||||
$territorialLinks = Illuminate\Support\Facades\DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->where('target_type', 'territory')->count();
|
||||
throw_unless($resources->count() === 22 && $resources->where('published', true)->count() === 21 && $finance && ! $finance->published && $territorialLinks === 0,
|
||||
RuntimeException::class, 'Publication or territorial scope differs');
|
||||
$scans = Illuminate\Support\Facades\DB::table('document_malware_scans')->whereIn('document_id', Illuminate\Support\Facades\DB::table('documents')->whereIn('external_id', array_column($manifest['documents'], 'external_id'))->pluck('id'))
|
||||
->get(['engine', 'engine_version', 'result', 'file_sha256']);
|
||||
throw_unless($scans->count() === 22 && $scans->every(fn ($s) => $s->engine === 'ClamAV' && $s->engine_version !== 'fixture' && $s->result === 'clean'), RuntimeException::class, 'Real ClamD evidence required');
|
||||
$proof = ['verified_at' => gmdate('c'), 'target' => $identity, 'transaction_read_only' => true, 'cases' => $cases, 'originals' => $originals,
|
||||
'real_scans' => $scans, 'automatic_territory_links' => $territorialLinks, 'finance_private' => true, 'database_writes' => 0, 'active_writes' => 0];
|
||||
file_put_contents($dir.'/legacy-sectors-persistence-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['dossiers_verified' => count($cases), 'originals' => count($originals), 'real_scans' => $scans->count(), 'published' => 21,
|
||||
'finance_private' => true, 'automatic_territory_links' => 0, 'database_writes' => 0, 'active_writes' => 0]).PHP_EOL;
|
||||
} finally { Illuminate\Support\Facades\DB::statement('ROLLBACK'); }
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Accepted modular QA contracts on the actual isolated server; no auth material in receipts."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from portal_profiles_http_session import Session, PRIVATE, BASE, EXPECTED, REASON, events
|
||||
|
||||
receipt_path = PRIVATE / 'opportunity-modular-receipts-qa.json'
|
||||
receipts = json.loads(receipt_path.read_text(encoding='utf-8-sig')) if receipt_path.exists() else {}
|
||||
proof = {'url': BASE, 'target': EXPECTED, 'started_at': datetime.now(timezone.utc).isoformat(),
|
||||
'interface_observed': False, 'active_writes': 0, 'cases': []}
|
||||
|
||||
def page(session, ident):
|
||||
return session.call('GET', f'/minhas-oportunidades/{ident}/view', inertia=True)['props']
|
||||
|
||||
def save(session, ident, payload):
|
||||
return session.call('PUT', f'/minhas-oportunidades/{ident}/draft', {'etag': page(session, ident)['etag'], 'canonical': payload, 'reason': REASON})
|
||||
|
||||
def action(session, ident, name, status=200, **extra):
|
||||
current = page(session, ident)
|
||||
if name == 'publish':
|
||||
extra.update({'confirm_publication': True, 'preview_hash': current['profileContext']['approval']['hash']})
|
||||
return session.call('POST', f'/minhas-oportunidades/{ident}/workflow', {'etag': current['etag'], 'action': name, 'reason': REASON, **extra}, status=status)
|
||||
|
||||
def prepare(session, category, mode):
|
||||
source = page(session, 33)
|
||||
original = source['values']['canonical']
|
||||
exclude = ['images', 'review_due_date', 'module_profile_version'] + ([] if category == 'business_sale' else ['business_transaction'])
|
||||
payload = {k: copy.deepcopy(v) for k, v in original.items() if k not in exclude}
|
||||
payload.update({'opportunity_category': category, 'visibility': 'internal'})
|
||||
key = category + ':' + mode
|
||||
if key not in receipts:
|
||||
created = session.call('POST', '/minhas-oportunidades/drafts', {'creation_key': str(uuid.uuid4()), 'organization_id': source['profileOrganization'],
|
||||
'opportunity_category': category, 'canonical': {'opportunity_category': category}, 'reason': REASON}, status=201)
|
||||
receipts[key] = created['id']
|
||||
receipt_path.write_text(json.dumps(receipts, indent=2), encoding='utf-8')
|
||||
ident = receipts[key]
|
||||
current = page(session, ident)
|
||||
mapping = {}
|
||||
for kind in ['locations', 'documents']:
|
||||
for source_row in source['canonicalOptions']['resources'][kind]:
|
||||
if source_row['id'] not in original[kind]:
|
||||
continue
|
||||
title = 'Synthetic QA modular copy ' + source_row['id']
|
||||
row = next((r for r in current['canonicalOptions']['resources'][kind] if r['payload'].get('title') == title), None)
|
||||
if row is None:
|
||||
meta = {**source_row['payload'], 'title': title}
|
||||
body = {'etag': page(session, ident)['etag'], 'creation_key': str(uuid.uuid4()), 'reason': REASON, 'payload': meta}
|
||||
url = f'/minhas-oportunidades/{ident}/resources/{kind}'
|
||||
if kind == 'documents':
|
||||
raw = session.call('GET', f'/minhas-oportunidades/33/resources/documents/{source_row["id"]}/download', binary=True)
|
||||
body['payload'] = json.dumps(meta)
|
||||
session.call('POST', url, body, multipart=('qa-modular-original.png', 'image/png', raw), status=201)
|
||||
else:
|
||||
session.call('POST', url, body, status=201)
|
||||
current = page(session, ident)
|
||||
row = next(r for r in current['canonicalOptions']['resources'][kind] if r['payload'].get('title') == title)
|
||||
mapping[source_row['id']] = row['id']
|
||||
def replace(value):
|
||||
if isinstance(value, dict): return {k: replace(v) for k, v in value.items()}
|
||||
if isinstance(value, list): return [replace(v) for v in value]
|
||||
return mapping.get(value, value) if isinstance(value, str) else value
|
||||
payload = replace(payload)
|
||||
document = payload['documents'][0]
|
||||
payload['official_title'] = 'Synthetic QA modular ' + str(ident)
|
||||
if category == 'business_sale':
|
||||
payload['module_profile_version'] = current['canonicalOptions']['historyVersion']
|
||||
payload['business_transaction'].update({'history_status': 'no_completed_years', 'available_financial_years': 0, 'financial_periods': [],
|
||||
'history_declaration': 'Entidade sintética QA recém-criada sem exercícios completos.', 'history_declaration_document_id': document,
|
||||
'projections': {'scenario': 'Projeção sintética QA', 'source': 'Plano de teste QA', 'date': '2026-10-10', 'document_id': document}})
|
||||
return ident, payload, document
|
||||
payload['module_profile_version'] = current['canonicalOptions']['modularProfiles'][category]
|
||||
m = {'mode': mode, 'object': 'Objeto sintético QA, sem compromisso real', 'object_document_id': document, 'representation_document_id': document}
|
||||
if category == 'concession':
|
||||
m.update({'granting_entity': 'Entidade sintética QA, não autoridade real', 'procedure_regime': 'Procedimento de teste QA',
|
||||
'scope': 'Distrito de localização QA', 'term_value': 12, 'term_unit': 'months', 'remuneration_obligations': 'Obrigações sintéticas QA',
|
||||
'participation_conditions': 'Condições sintéticas QA', 'land_required': False, 'construction_required': False,
|
||||
'disclosure_authorization_document_id': document})
|
||||
if mode == 'awarded_right': m['external_decision_document_id'] = document
|
||||
if category in ['financing', 'supply']:
|
||||
m.update({'conditions_status': 'proposed', 'terms': 'Condições indicativas QA, sem compromisso', 'scenario': 'Cenário QA', 'source': 'Fonte sintética QA', 'terms_date': '2026-10-10'})
|
||||
if category == 'financing':
|
||||
m.update({'instrument': 'debt', 'term_value': 12, 'term_unit': 'months', 'repayment_periodicity': 'mensal', 'disbursement_calendar': 'Calendário indicativo QA'})
|
||||
if mode == 'seeking_funding':
|
||||
payload.update({'total_investment': 1000, 'investment_breakdown': [{'label': 'QA', 'amount': 1000, 'currency': 'MZN'}], 'secured_financing': [], 'funding_sought': 500})
|
||||
m.update({'use_of_funds': 'Preparação sintética QA', 'financial_plan_document_id': document})
|
||||
else:
|
||||
m.update({'offered_capacity': 100000, 'eligibility_criteria': 'Critérios indicativos QA', 'capacity_document_id': document})
|
||||
if category == 'supply':
|
||||
m.update({'quantity': 10, 'unit': 'unidade', 'destination': 'Distrito QA', 'start_date': '2026-10-11', 'end_date': '2026-11-11',
|
||||
'acceptance_criteria': 'Critérios sintéticos QA', 'unit_price': 10, 'total_price': 100, 'advance_or_guarantee': False})
|
||||
if mode == 'supply_offer': m['capacity_document_id'] = document
|
||||
if category == 'other':
|
||||
m.update({'subtype': 'qa-test-subtype', 'purpose': 'Teste QA', 'recipients': 'Destinatários sintéticos QA', 'expected_output': 'Resultado QA', 'risks': 'Riscos declarados QA'})
|
||||
payload['module_details'] = m
|
||||
return ident, payload, document
|
||||
|
||||
try:
|
||||
owner = Session('promoter').login()
|
||||
reviewer = Session('validator').login()
|
||||
publisher = Session('publisher').login()
|
||||
outsider = Session('outsider').login()
|
||||
cases = [('concession', 'intention_call'), ('concession', 'awarded_right'), ('financing', 'seeking_funding'), ('financing', 'funding_offer'),
|
||||
('supply', 'purchase_request'), ('supply', 'supply_offer'), ('other', 'classified_other')]
|
||||
if '--history-only' in sys.argv:
|
||||
cases = [('business_sale', 'no_completed_years')]
|
||||
for category, mode in cases:
|
||||
# Every case uses normal fresh MFA; long suites must not outlive the 15-minute security window.
|
||||
owner = Session('promoter').login()
|
||||
reviewer = Session('validator').login()
|
||||
publisher = Session('publisher').login()
|
||||
ident, payload, document = prepare(owner, category, mode)
|
||||
current = page(owner, ident)
|
||||
initial_state = current['workflowState']
|
||||
if current['workflowState'] == 'draft':
|
||||
invalid = copy.deepcopy(payload)
|
||||
field = None
|
||||
if mode == 'seeking_funding': invalid['funding_sought'] = 2000; field = 'canonical.funding_sought'
|
||||
if mode == 'funding_offer': invalid['total_investment'] = 100; field = 'canonical.total_investment'
|
||||
if mode == 'awarded_right': invalid['module_details']['external_decision_document_id'] = None; field = 'canonical.module_details.external_decision_document_id'
|
||||
if mode == 'supply_offer': invalid['module_details']['total_price'] = 99; field = 'canonical.module_details.total_price'
|
||||
if category == 'other': invalid['module_details']['subtype'] = 'unclassified-idea'; field = 'canonical.module_details.subtype'
|
||||
if field:
|
||||
save(owner, ident, invalid)
|
||||
assert page(Session('promoter').login(), ident)['values']['canonical']['module_details'] == invalid['module_details']
|
||||
failure = action(owner, ident, 'submit', status=422)
|
||||
assert field in failure['errors']
|
||||
# Clear fields left by a failed offer test explicitly; the merge never silently erases values.
|
||||
if mode == 'funding_offer': payload['total_investment'] = None
|
||||
save(owner, ident, payload)
|
||||
action(owner, ident, 'submit')
|
||||
current = page(owner, ident)
|
||||
if current['workflowState'] == 'submitted' and not current['profileContext'].get('correction_cycles'):
|
||||
action(reviewer, ident, 'request_correction', correction_items=['canonical.executive_summary'])
|
||||
current = page(owner, ident)
|
||||
if current['workflowState'] == 'needs_information':
|
||||
payload = current['values']['canonical']
|
||||
payload['executive_summary'] += ' Correção QA preservada.'
|
||||
save(owner, ident, payload)
|
||||
action(owner, ident, 'submit')
|
||||
current = page(owner, ident)
|
||||
if current['workflowState'] in ['submitted', 'in_review']:
|
||||
for gate in current['canonicalOptions']['gates']:
|
||||
action(reviewer, ident, 'review_gate', gate=gate, gate_decision='approved', evidence_document_ids=[document])
|
||||
action(owner, ident, 'approve', status=403)
|
||||
action(reviewer, ident, 'approve')
|
||||
if page(owner, ident)['workflowState'] == 'approved': action(publisher, ident, 'publish')
|
||||
recovered = Session('promoter').login().call('GET', f'/minhas-oportunidades/{ident}/response')
|
||||
if category == 'business_sale':
|
||||
assert recovered['data']['business_transaction']['history_status'] == mode
|
||||
assert recovered['data']['business_transaction']['financial_periods'] == []
|
||||
else:
|
||||
assert recovered['data']['module_details']['mode'] == mode
|
||||
assert recovered['data']['status'] == 'published' and recovered['publication']['scope'] == 'restricted'
|
||||
outsider.call('GET', f'/minhas-oportunidades/{ident}/response', status=403)
|
||||
Session('outsider').call('GET', f'/minhas-oportunidades/{ident}/response', status=401)
|
||||
raw = owner.call('GET', f'/minhas-oportunidades/{ident}/resources/documents/{document}/download', binary=True)
|
||||
resource = next(d for d in recovered['data']['documents'] if d['id'] == document)
|
||||
assert resource['scan_recorded_status'] == 'clean' and resource['sha256'].strip() == hashlib.sha256(raw).hexdigest()
|
||||
proof['cases'].append({'id': ident, 'category': category, 'mode': mode, 'profile_version': recovered['data']['module_profile_version'],
|
||||
'workflow': 'draft → submit → correction → resubmit → independent gates → approval → restricted release',
|
||||
'new_mfa_recovery': True, 'same_opportunity_verified_original': True, 'other_organization': 403, 'anonymous': 401,
|
||||
'public_catalog': False, 'initial_state_in_this_attempt': initial_state,
|
||||
'subtype_scope': 'synthetic QA acceptance only' if category == 'other' else None})
|
||||
print(json.dumps({'case': ident, 'category': category, 'mode': mode, 'result': 'passed', 'interface_observed': False}), flush=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()
|
||||
dest = PRIVATE / ('opportunity-history-http-qa.json' if '--history-only' in sys.argv else 'opportunity-modular-http-qa.json')
|
||||
if dest.exists():
|
||||
previous = json.loads(dest.read_text(encoding='utf-8-sig'))
|
||||
archive = PRIVATE / ('opportunity-modular-http-qa-' + previous['completed_at'].replace(':', '').replace('+', '_') + '.json')
|
||||
if not archive.exists(): archive.write_bytes(dest.read_bytes())
|
||||
dest.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'result': proof['result'], 'cases': len(proof['cases']), 'events': len(events), 'interface_observed': False, 'active_writes': 0}))
|
||||
@@ -1,13 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsurePortalProfiles;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityResourceInstallation;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\PortalSuperusers;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require_once $root.'/tools/portal-qa-environment.php';
|
||||
@@ -23,19 +31,28 @@ $app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
config(['investor-path.enabled' => true]);
|
||||
config(['geometry-import.enabled' => true]);
|
||||
config(['portal.interface_language' => 'pt-MZ']);
|
||||
$identity = portalQaAssertTarget($target);
|
||||
if (PHP_SAPI !== 'cli' && is_file($dir.'/portal-audit/qa-recovery-maintenance.json')) {
|
||||
http_response_code(503);
|
||||
header('Retry-After: 60');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
|
||||
echo 'QA temporariamente em recuperação técnica. Nenhuma alteração no ambiente ativo.';
|
||||
return;
|
||||
}
|
||||
config(['portal.qa_synthetic_accounts_only' => true]);
|
||||
require_once $root.'/tools/portal-qa-account-selection.php';
|
||||
// Only expose the non-secret identity; passwords, TOTP secrets and recovery codes remain private.
|
||||
\Laravel\Fortify\Fortify::loginView(fn (Request $request) => \Inertia\Inertia::render('auth/login', [
|
||||
'canResetPassword' => \Laravel\Fortify\Features::enabled(\Laravel\Fortify\Features::resetPasswords()),
|
||||
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
|
||||
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
||||
'status' => $request->session()->get('status'),
|
||||
'qaAccount' => ['id' => 18, 'email' => User::findOrFail(18)->email],
|
||||
]));
|
||||
\Laravel\Fortify\Fortify::twoFactorChallengeView(function (Request $request) {
|
||||
Fortify::twoFactorChallengeView(function (Request $request) {
|
||||
$actor = User::findOrFail($request->session()->get('login.id'));
|
||||
|
||||
return \Inertia\Inertia::render('auth/two-factor-challenge', ['qaAccount' => ['id' => $actor->id, 'email' => $actor->email]]);
|
||||
return Inertia::render('auth/two-factor-challenge', ['qaAccount' => ['id' => $actor->id, 'email' => $actor->email]]);
|
||||
});
|
||||
config(['portal.managers' => ['opportunity-manager@example.invalid', 'geo-manager-qa@example.invalid'], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
$sessions = $dir.'/qa-opportunity-resources-sessions';
|
||||
@@ -45,33 +62,52 @@ if (! is_dir($sessions)) {
|
||||
config(['session.files' => $sessions]);
|
||||
config(['portal.opportunity_dashboard_access_enabled' => true, 'portal.qa_build_directory' => 'build-qa-opportunities']);
|
||||
config(['portal.opportunity_specialized_drafts_enabled' => true]);
|
||||
config(['portal.opportunity_modular_profiles_qa_enabled' => true]);
|
||||
// Explicit synthetic acceptance subtype, never an approval/classification of a real opportunity.
|
||||
config(['portal.opportunity_other_qa_subtypes.qa-test-subtype' => ['approved' => true, 'version' => 'synthetic-acceptance-v1',
|
||||
'decision_reference' => 'Synthetic QA acceptance fixture only; real subtypes require functional approval',
|
||||
'gates' => ['technical_review', 'legal_territorial_review']]]);
|
||||
config(['portal.profile_access_enabled' => true]);
|
||||
config(['portal.legacy_content_qa_enabled' => true]);
|
||||
// QA-only bootstrap ceremony: fixed synthetic identities, normal authenticated MFA.
|
||||
Illuminate\Support\Facades\Route::middleware(['web', 'auth', 'verified', \App\Http\Middleware\EnsurePortalProfiles::class])->post('/qa-auditoria/perfis/inicializar', function (Request $request) use ($dir) {
|
||||
Route::middleware(['web', 'auth', 'verified', EnsurePortalProfiles::class])->post('/qa-auditoria/perfis/inicializar', function (Request $request) use ($dir) {
|
||||
$actors = json_decode(file_get_contents($dir.'/portal-audit/profiles-v1-actors-private.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless($request->user()->id === $actors['bootstrap']['id'], 403);
|
||||
config(['portal_profiles.bootstrap_authorized' => true]);
|
||||
try {
|
||||
app(\App\Services\PortalSuperusers::class)->initialize(User::findOrFail($actors['super_first']['id']), User::findOrFail($actors['super_second']['id']), $request->user());
|
||||
app(PortalSuperusers::class)->initialize(User::findOrFail($actors['super_first']['id']), User::findOrFail($actors['super_second']['id']), $request->user());
|
||||
} finally {
|
||||
config(['portal_profiles.bootstrap_authorized' => false]);
|
||||
}
|
||||
|
||||
return response()->json(['initialized' => true, 'holders' => [$actors['super_first']['id'], $actors['super_second']['id']]]);
|
||||
});
|
||||
// Read-only report derived from the sole audit matrix, accessible only on QA.
|
||||
function authorizeQaAuditReader(Request $request): void
|
||||
{
|
||||
$access = app(\App\Services\PortalProfileAccess::class);
|
||||
abort_unless($request->user()->can('manage-portal') || $access->has($request->user(), 'auditor') || ($access->superuser($request->user()) && $access->mfa($request->user())), 403);
|
||||
$access = app(PortalProfileAccess::class);
|
||||
$allowed = $request->user()->can('manage-portal') || $access->has($request->user(), 'auditor') || ($access->superuser($request->user()) && $access->mfa($request->user()));
|
||||
if (! $allowed && ! $request->expectsJson()) {
|
||||
if ($request->headers->has('X-Inertia')) {
|
||||
// Render the refused destination as a full page instead of trapping login in an error modal.
|
||||
abort(Inertia::location($request->url()));
|
||||
}
|
||||
// Refuse the administrative report without discarding an authenticated investor session.
|
||||
// Keep a usable destination when Fortify returns to a previously requested report.
|
||||
abort(response('<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Acesso reservado — InvestMoz QA</title></head><body style="font:16px/1.6 system-ui;max-width:44rem;margin:auto;padding:24px"><main><h1>Relatório reservado à gestão e auditoria</h1><p>A sua sessão está autenticada. Este perfil não tem autorização para consultar o relatório administrativo.</p><p><a href="/dashboard">Abrir o painel</a></p><p><a href="/investimentos">Continuar os meus investimentos</a></p></main></body></html>', 403)->header('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
abort_unless($allowed, 403);
|
||||
}
|
||||
Illuminate\Support\Facades\Route::middleware(['web', 'auth', 'verified'])->get('/qa-auditoria/pedidos-abertos', function (Request $request) use ($dir) {
|
||||
Route::middleware(['web', 'auth', 'verified'])->get('/qa-auditoria/pedidos-abertos', function (Request $request) use ($dir) {
|
||||
authorizeQaAuditReader($request);
|
||||
|
||||
return response()->file($dir.'/portal-audit/pedidos-abertos.html', ['Cache-Control' => 'private, no-store']);
|
||||
});
|
||||
foreach (['relatorio-executivo', 'backlog', 'perfis-v1-reconciliacao', 'roteiro-manual-perfis-v1'] as $reportName) {
|
||||
Illuminate\Support\Facades\Route::middleware(['web', 'auth', 'verified'])->get('/qa-auditoria/'.$reportName, function (Request $request) use ($root, $reportName) {
|
||||
foreach (['relatorio-executivo', 'backlog', 'perfis-v1-reconciliacao', 'roteiro-manual-perfis-v1', 'legado-piloto-qa'] as $reportName) {
|
||||
Route::middleware(['web', 'auth', 'verified'])->get('/qa-auditoria/'.$reportName, function (Request $request) use ($root, $reportName) {
|
||||
authorizeQaAuditReader($request);
|
||||
$content = file_get_contents($root.'/docs/investmoz/v2/auditoria-portal/'.$reportName.'.md');
|
||||
|
||||
return response('<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>InvestMoz — '.$reportName.'</title><style>body{font:16px/1.55 system-ui;max-width:1100px;margin:auto;padding:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}</style><pre>'.htmlspecialchars($content, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</pre></html>')->header('Cache-Control', 'private, no-store');
|
||||
});
|
||||
}
|
||||
@@ -145,10 +181,10 @@ header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
|
||||
header('X-Investmoz-QA-Key-Fingerprint: '.hash('sha256', app('encrypter')->getKey()));
|
||||
header('X-Investmoz-QA-Config-Cache: bypassed');
|
||||
require_once $root.'/tools/portal-qa-auth-trace.php';
|
||||
$httpKernel = $app->make(\Illuminate\Contracts\Http\Kernel::class);
|
||||
$httpKernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
|
||||
$httpKernel->prependMiddleware(PortalQaAuthTrace::class);
|
||||
$httpKernel->appendMiddlewareToGroup('web', PortalQaSessionTrace::class);
|
||||
$httpKernel->addToMiddlewarePriorityAfter(\Illuminate\Session\Middleware\StartSession::class, PortalQaSessionTrace::class);
|
||||
$httpKernel->addToMiddlewarePriorityAfter(StartSession::class, PortalQaSessionTrace::class);
|
||||
$httpKernel->appendMiddlewareToGroup('web', PortalQaAccountSelection::class);
|
||||
$httpKernel->addToMiddlewarePriorityAfter(PortalQaSessionTrace::class, PortalQaAccountSelection::class);
|
||||
$app->handleRequest(Request::capture());
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Read adapter and §27 semantics on QA18097. No credentials, cookies or MFA output."""
|
||||
import copy
|
||||
import json
|
||||
import uuid
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from portal_profiles_http_session import Session, PRIVATE, EXPECTED, REASON, events
|
||||
|
||||
receipt_path = PRIVATE / 'opportunity-response-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, 'responses': [], 'semantics': []}
|
||||
resume_semantics = '--semantics-only' in sys.argv
|
||||
if resume_semantics:
|
||||
previous = json.loads((PRIVATE / 'opportunity-response-http-qa.json').read_text(encoding='utf-8-sig'))
|
||||
assert len(previous['responses']) == 10 and previous['active_writes'] == 0
|
||||
proof['responses'] = previous['responses']
|
||||
proof['response_read_scope_from_attempt_at'] = previous['completed_at']
|
||||
proof['response_scope_note'] = 'Ten read/recovery criteria passed in the preserved preceding attempt; its subsequent harness correction failed. New semantic receipts below are separate.'
|
||||
|
||||
def save_receipts():
|
||||
receipt_path.write_text(json.dumps(receipts, indent=2), encoding='utf-8')
|
||||
|
||||
def page(session, ident):
|
||||
return session.call('GET', f'/minhas-oportunidades/{ident}/view', inertia=True)['props']
|
||||
|
||||
def persist(session, ident, payload):
|
||||
current = page(session, ident)
|
||||
return session.call('PUT', f'/minhas-oportunidades/{ident}/draft', {'etag': current['etag'], 'canonical': payload, 'reason': REASON})
|
||||
|
||||
def submit(session, ident, status=200):
|
||||
current = page(session, ident)
|
||||
return session.call('POST', f'/minhas-oportunidades/{ident}/workflow', {'etag': current['etag'], 'action': 'submit', 'reason': REASON}, status=status)
|
||||
|
||||
def clone(session, source_id, key):
|
||||
source = page(session, source_id)
|
||||
payload = copy.deepcopy(source['values']['canonical'])
|
||||
if key not in receipts:
|
||||
row = session.call('POST', '/minhas-oportunidades/drafts', {'creation_key': str(uuid.uuid4()), 'organization_id': source['profileOrganization'],
|
||||
'opportunity_category': payload['opportunity_category'], 'reason': REASON}, status=201)
|
||||
receipts[key] = row['id']; save_receipts()
|
||||
ident = receipts[key]
|
||||
existing = page(session, ident)
|
||||
mapping = {}
|
||||
for kind in ['locations', 'documents']:
|
||||
for source_row in source['canonicalOptions']['resources'][kind]:
|
||||
if source_row['id'] not in payload[kind]:
|
||||
continue
|
||||
row = next((r for r in existing['canonicalOptions']['resources'][kind] if r['payload'].get('title') == 'QA response copy ' + source_row['id']), None)
|
||||
if row is None:
|
||||
resource_payload = {**source_row['payload'], 'title': 'QA response copy ' + source_row['id']}
|
||||
body = {'etag': page(session, ident)['etag'], 'creation_key': str(uuid.uuid4()), 'reason': REASON, 'payload': resource_payload}
|
||||
url = f'/minhas-oportunidades/{ident}/resources/{kind}'
|
||||
if kind == 'documents':
|
||||
raw = session.call('GET', f'/minhas-oportunidades/{source_id}/resources/documents/{source_row["id"]}/download', binary=True)
|
||||
body['payload'] = json.dumps(resource_payload)
|
||||
session.call('POST', url, body, multipart=('qa-response-original.png', 'image/png', raw), status=201)
|
||||
else:
|
||||
session.call('POST', url, body, status=201)
|
||||
existing = page(session, ident)
|
||||
row = next(r for r in existing['canonicalOptions']['resources'][kind] if r['payload'].get('title') == resource_payload['title'])
|
||||
mapping[source_row['id']] = row['id']
|
||||
def replace(value):
|
||||
if isinstance(value, dict):
|
||||
return {k: replace(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [replace(v) for v in value]
|
||||
return mapping.get(value, value) if isinstance(value, str) else value
|
||||
payload = replace(payload)
|
||||
payload['official_title'] = 'Synthetic QA response semantics ' + str(ident)
|
||||
if 'land_rights' in payload:
|
||||
payload['land_rights']['duat_number'] = 'QA-RESPONSE-' + str(ident)
|
||||
return ident, payload
|
||||
|
||||
try:
|
||||
owner = Session('promoter').login()
|
||||
reviewer = Session('validator').login()
|
||||
outsider = Session('outsider').login()
|
||||
fresh = Session('promoter').login()
|
||||
for ident in ([] if resume_semantics else range(30, 40)):
|
||||
current = page(owner, ident)
|
||||
original = current['values']['canonical']
|
||||
response = owner.call('GET', f'/minhas-oportunidades/{ident}/response')
|
||||
assert response == current['canonicalResponse']
|
||||
assert response['data']['id'] == ident
|
||||
assert not response['compatibility']['normative_response_complete']
|
||||
assert not response['validation_result']['revalidated_on_read']
|
||||
assert 'completion_percentage' not in response['data']
|
||||
assert response['resource_references'] == {k: original.get(k, []) for k in ['documents', 'images', 'locations']}
|
||||
assert response == fresh.call('GET', f'/minhas-oportunidades/{ident}/response')
|
||||
for kind in ['documents', 'images', 'locations']:
|
||||
assert [r['id'] for r in response['data'][kind]] == original.get(kind, [])
|
||||
proof['responses'].append({'id': ident, 'adapter_version': response['adapter_version'], 'ids_preserved': True, 'new_mfa_recovery': True,
|
||||
'inertia_consumer_matches_endpoint': True, 'validation_not_invented': True, 'normative_response_complete': False})
|
||||
reviewer.call('GET', '/minhas-oportunidades/33/response')
|
||||
outsider.call('GET', '/minhas-oportunidades/33/response', status=403)
|
||||
Session('outsider').call('GET', '/minhas-oportunidades/33/response', status=401)
|
||||
|
||||
land_id, land = clone(owner, 32, 'land_duplicate')
|
||||
current = page(owner, land_id)
|
||||
if current['workflowState'] == 'draft':
|
||||
land['land_rights']['parcels'] = [copy.deepcopy(land['land_rights']['parcels'][0]) for _ in range(2)]
|
||||
for parcel in land['land_rights']['parcels']:
|
||||
parcel['cadastral_number'] = 'QA-DUPLICATE-' + str(land_id)
|
||||
persist(owner, land_id, land)
|
||||
failure = submit(owner, land_id, status=422)
|
||||
assert 'canonical.land_rights.parcels.1.cadastral_number' in failure['errors']
|
||||
recovered = page(Session('promoter').login(), land_id)
|
||||
assert recovered['values']['canonical']['land_rights']['parcels'] == land['land_rights']['parcels']
|
||||
land['land_rights']['parcels'] = copy.deepcopy(land['land_rights']['parcels'])
|
||||
land['land_rights']['parcels'][1]['cadastral_number'] += '-SECOND'
|
||||
persist(owner, land_id, land)
|
||||
submit(owner, land_id)
|
||||
proof['semantics'].append({'id': land_id, 'rule': '§27 cadastral number unique within opportunity', 'invalid_submission': 422,
|
||||
'field': 'canonical.land_rights.parcels.1.cadastral_number', 'invalid_draft_recovered': True, 'corrected_submission': 'submitted'})
|
||||
|
||||
business_id, business = clone(owner, 33, 'succession')
|
||||
if page(owner, business_id)['workflowState'] == 'draft':
|
||||
business['visibility'] = 'internal'
|
||||
business['images'] = []
|
||||
business['business_transaction']['transaction_type'] = 'succession'
|
||||
persist(owner, business_id, business)
|
||||
submit(owner, business_id)
|
||||
response = Session('promoter').login().call('GET', f'/minhas-oportunidades/{business_id}/response')
|
||||
assert response['data']['business_transaction']['transaction_type'] == 'succession'
|
||||
assert response['data']['status'] == 'submitted'
|
||||
proof['semantics'].append({'id': business_id, 'rule': 'original business_sale succession modality', 'saved': True, 'submitted': True, 'new_mfa_recovery': 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()
|
||||
dest = PRIVATE / 'opportunity-response-http-qa.json'
|
||||
if dest.exists():
|
||||
dest.rename(PRIVATE / ('opportunity-response-http-qa-' + datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + '.json'))
|
||||
dest.write_text(json.dumps(proof, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'result': proof['result'], 'responses': len(proof['responses']), 'semantics': len(proof['semantics']), 'events': len(events), 'active_writes': 0}))
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Run only the affected guarded dossier/Balcao regression; write sanitized test-reporter output."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
out = root / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
result = subprocess.run(['C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe', 'vendor/bin/phpunit',
|
||||
'tests/Feature/InvestorPathTest.php', '--no-progress'], cwd=root, capture_output=True)
|
||||
receipt = json.loads(result.stdout.decode('utf-8-sig'))
|
||||
(out / 'balcao-prefill-after-fix-qa.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'exit': result.returncode, 'tests': receipt['tests'], 'assertions': receipt['assertions'],
|
||||
'failures': receipt.get('failures', [])}, ensure_ascii=True))
|
||||
sys.exit(result.returncode)
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
// Guarded read-only trace of the existing, explicitly synthetic A/B dossiers. No credentials or ciphertexts in evidence.
|
||||
use App\Models\SimulationFormDraft;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
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);
|
||||
DB::beginTransaction();
|
||||
DB::statement('SET TRANSACTION READ ONLY');
|
||||
$dir = storage_path('app/private/investmoz-v2/portal-audit');
|
||||
$prior = json_decode(file_get_contents($dir.'/investor-ab-chromium-20261010.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$proof = ['captured_at' => gmdate('c'), 'target' => $target, 'read_only' => true, 'active_writes' => 0, 'scenarios' => []];
|
||||
foreach ($prior['scenarios'] as $name => $entry) {
|
||||
$case = DB::table('desk_cases')->where('id', $entry['id'])->firstOrFail();
|
||||
abort_unless((int) $case->user_id === 18 && $case->simulation_id === $entry['draft_id'], 403);
|
||||
$desk = app(VirtualDesk::class);
|
||||
$payload = $desk->decode($case->payload);
|
||||
$path = $payload['investor_path'];
|
||||
$result = collect($path['results'])->firstWhere('id', $entry['result_id']);
|
||||
abort_unless($result && $result['draft_id'] === $case->simulation_id, 422);
|
||||
$draft = SimulationFormDraft::findOrFail($entry['draft_id']);
|
||||
abort_unless((int) $draft->owner_user_id === 18, 403);
|
||||
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
$request = DB::table('desk_requests')->where('id', $entry['request_id'])->where('case_id', $case->id)->firstOrFail();
|
||||
$requestPayload = $desk->decode($request->payload);
|
||||
$task = DB::table('tasks')->where('id', $entry['task_id'])->where('desk_case_id', $case->id)->firstOrFail();
|
||||
abort_unless($task->guidance_key === 'path:S02' && $task->status === 'done', 422);
|
||||
$steps = DB::table('journey_steps')->where('journey_id', $case->journey_id)->whereIn('step_code', ['S02', 'S10'])->get();
|
||||
$stepProof = [];
|
||||
foreach ($steps as $step) {
|
||||
$detail = $desk->decode($step->path_detail);
|
||||
$stepProof[$step->step_code] = ['state' => $detail['state'], 'recorded_at' => $detail['recorded_at'] ?? null, 'scope' => $detail['scope'] ?? null];
|
||||
}
|
||||
$timeline = [];
|
||||
foreach (DB::table('desk_events')->where('case_id', $case->id)->orderBy('event_order')->get() as $event) {
|
||||
$data = $desk->decode($event->payload);
|
||||
if (($event->action === 'path_stage_recorded' && in_array($data['code'] ?? '', ['S02', 'S10'], true))
|
||||
|| (in_array($event->action, ['path_task_accepted', 'guidance_task_updated'], true) && ($data['task_id'] ?? '') === $task->id)) {
|
||||
$timeline[] = ['order' => $event->event_order, 'action' => $event->action, 'actor_id' => $event->actor_id,
|
||||
'created_at' => $event->created_at, 'code' => $data['code'] ?? null, 'state' => $data['state'] ?? null,
|
||||
'task_status' => $data['status'] ?? null, 'task_id' => $data['task_id'] ?? null];
|
||||
}
|
||||
}
|
||||
if ($name === 'B') {
|
||||
$wait = collect($timeline)->first(fn ($e) => $e['code'] === 'S10' && $e['state'] === 'waiting_external');
|
||||
$done = collect($timeline)->first(fn ($e) => $e['task_status'] === 'done');
|
||||
abort_unless($wait && $done && $wait['order'] < $done['order'] && $stepProof['S10']['state'] === 'waiting_external', 422);
|
||||
}
|
||||
$proof['scenarios'][$name] = ['case_id' => $case->id, 'case_version' => $case->version, 'owner_id' => $case->user_id,
|
||||
'facts' => $path['facts'], 'steps' => $stepProof, 'payload_sha256' => hash('sha256', $case->payload),
|
||||
'draft_id' => $draft->id, 'draft_version' => $draft->version_no, 'draft_snapshot_sha256' => $draft->snapshot_sha256,
|
||||
'project_title' => $economic['project_title'] ?? null, 'result_id' => $result['id'], 'result_draft_id' => $result['draft_id'],
|
||||
'tool_summary_length' => strlen($economic['project_summary'] ?? ''), 'request_description_length' => strlen($requestPayload['description'] ?? ''),
|
||||
'request_payload_fields' => array_keys($requestPayload), 'request_created_at' => $request->created_at,
|
||||
'result_draft_version' => $result['draft_version'], 'result_source_key' => $result['source_key'],
|
||||
'request_id' => $request->id, 'request_status' => $request->status, 'request_version' => $request->version,
|
||||
'request_country' => $requestPayload['country'] ?? null, 'request_origin' => $requestPayload['declared_origin_country_code'] ?? null,
|
||||
'task_id' => $task->id, 'task_status' => $task->status, 'task_guidance_key' => $task->guidance_key,
|
||||
'task_version' => $task->version_no, 'timeline' => $timeline];
|
||||
}
|
||||
DB::rollBack();
|
||||
$proof['result'] = 'passed_read_only_existing_path';
|
||||
$file = $dir.'/investor-ab-remaining-persistence-qa.json';
|
||||
if (is_file($file)) copy($file, $dir.'/investor-ab-remaining-persistence-qa-'.gmdate('Ymd_His').'.json');
|
||||
file_put_contents($file, json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['result' => $proof['result'], 'target' => $target, 'read_only' => true, 'scenarios' => array_keys($proof['scenarios'])]).PHP_EOL;
|
||||
@@ -0,0 +1,175 @@
|
||||
// Remaining A/B acceptance only: reuse the demonstrated dossiers, do not repeat C37 or create fixtures.
|
||||
const {start, BASE, DIR, fs, path, assert, sanitized} = require('./portal-visual-qa-lib.cjs');
|
||||
(async () => {
|
||||
const q = await start('investor-ab-remaining-chromium-20261010');
|
||||
const previous = JSON.parse(fs.readFileSync(path.join(DIR, 'investor-ab-chromium-20261010.json'), 'utf8'));
|
||||
assert.equal(previous.result, 'passed');
|
||||
q.proof.previous_full_path_receipt = 'investor-ab-chromium-20261010.json';
|
||||
q.proof.scope = 'Remaining visual A/B criteria, existing dossiers. Authentication is a prerequisite; C37 acceptance is not repeated.';
|
||||
q.proof.scenarios = {};
|
||||
q.proof.accessibility = [];
|
||||
let page, ctx;
|
||||
async function regionShot(locator, name) {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
const file = path.join(DIR, 'investor-ab-remaining-' + q.stamp + '-' + name + '.png');
|
||||
await locator.screenshot({path:file});
|
||||
q.proof.screenshots.push(path.relative(path.resolve(__dirname, '..'), file).replaceAll('\\', '/'));
|
||||
q.save();
|
||||
}
|
||||
async function audit(route, width) {
|
||||
await page.addScriptTag({path:path.join(DIR, 'qa-a11y-toolkit/package/axe.min.js')});
|
||||
const result = await page.evaluate(async () => {
|
||||
const r = await axe.run(document, {runOnly:{type:'tag', values:['wcag2a','wcag2aa','wcag21a','wcag21aa','best-practice']}});
|
||||
return {violations:r.violations.map(v => ({id:v.id, impact:v.impact, targets:v.nodes.map(n => n.target)})),
|
||||
incomplete:r.incomplete.map(v => v.id), viewport:innerWidth, scrollWidth:document.documentElement.scrollWidth};
|
||||
});
|
||||
q.proof.accessibility.push({route, width, ...result});
|
||||
q.save();
|
||||
assert(result.scrollWidth <= width + 1, 'Horizontal overflow on the demonstrated A/B page');
|
||||
assert.equal(result.violations.length, 0, 'Accessibility findings need separate correction');
|
||||
}
|
||||
try {
|
||||
q.phase('Remaining A/B criteria in fresh Chromium session, 390px');
|
||||
({page, ctx} = await q.context('investor18-remaining-ab', 390));
|
||||
await q.login(page, 'investor18');
|
||||
for (const name of ['A', 'B']) {
|
||||
const entry = previous.scenarios[name];
|
||||
q.phase('Scenario ' + name + ': applicability, correct financial result, existing Balcao preparation');
|
||||
await q.open(page, '/investimentos/' + entry.id);
|
||||
await page.getByRole('heading', {name:entry.title, exact:true}).waitFor();
|
||||
const data = (await q.json(page, '/investimentos/' + entry.id)).data;
|
||||
assert.equal(data.case.id, entry.id);
|
||||
assert.equal(await page.locator('article[id^="S"]').count(), 19);
|
||||
assert.equal(await page.getByRole('heading', {name:/^Fase \d+ ·/}).count(), 5);
|
||||
const decisions = page.locator('form').filter({has:page.getByRole('heading', {name:'Decisões do promotor e aplicabilidade', exact:true})});
|
||||
const expected = name === 'A' ? 'no' : 'yes';
|
||||
assert.equal(await decisions.getByLabel(/^Precisa de terra/).inputValue(), expected);
|
||||
assert.equal(await decisions.getByLabel(/^Precisa de financiamento externo/).inputValue(), expected);
|
||||
const land = data.steps.find(s => s.code === 'S10');
|
||||
assert.equal(land.state, name === 'A' ? 'dispensed' : 'waiting_external');
|
||||
assert.equal(land.preparation_allowed, name === 'B');
|
||||
await page.locator('#S10').getByText(name === 'A' ? /Dispensa apenas de preparação deste ramo/ : /Aguarda informação do terreno QA/).waitFor();
|
||||
await regionShot(decisions, name + '-applicability-390');
|
||||
await regionShot(page.locator('#S10'), name + '-land-390');
|
||||
const financial = page.locator('section').filter({has:page.getByRole('heading', {name:'Resultados devolvidos ao dossier', exact:true})});
|
||||
const result = data.results.find(r => r.id === entry.result_id);
|
||||
assert(result && result.draft_id === entry.draft_id);
|
||||
const other = previous.scenarios[name === 'A' ? 'B' : 'A'];
|
||||
assert(!data.results.some(r => r.id === other.result_id || r.draft_id === other.draft_id), 'No financial result from the other dossier');
|
||||
// Preserve older result versions; assert the exact previously demonstrated evidence, not an arbitrary first row.
|
||||
const financialEntry = financial.locator('li').filter({hasText:entry.result_id});
|
||||
assert.equal(await financialEntry.count(), 1);
|
||||
await financialEntry.getByText('Ponto de equilíbrio: 20 unidades', {exact:true}).waitFor();
|
||||
await financialEntry.getByText(new RegExp('^Necessidade de financiamento: ' + entry.expected_funding_gap + '(?:\\.00)?$')).waitFor();
|
||||
await regionShot(financial, name + '-financial-390');
|
||||
const tasks = page.locator('section').filter({has:page.getByRole('heading', {name:'Acompanhamento interno', exact:true})});
|
||||
const savedTask = data.tasks.find(t => t.id === entry.task_id);
|
||||
assert.equal(savedTask.status, 'done');
|
||||
const item = tasks.locator('li').filter({hasText:savedTask.title});
|
||||
await item.getByText(/done/).waitFor();
|
||||
if (name === 'B') {
|
||||
const independent = data.steps.find(s => s.code === 'S02');
|
||||
assert.equal(independent.saved_state, 'preparing');
|
||||
assert.equal(independent.preparation_allowed, true);
|
||||
const stage = page.locator('#S02');
|
||||
await stage.locator('summary').click();
|
||||
assert.equal(await stage.getByRole('button', {name:'Guardar registo', exact:true}).isEnabled(), true);
|
||||
assert.equal(await tasks.getByLabel(/^Etapa/).locator('option[value="S02"]').count(), 1);
|
||||
await regionShot(stage, 'B-independent-stage-enabled-390');
|
||||
}
|
||||
await regionShot(item, name + '-internal-completion-390');
|
||||
await audit('/investimentos/' + entry.id, 390);
|
||||
const support = page.locator('section').filter({has:page.getByRole('heading', {name:'Apoio no Balcão', exact:true})});
|
||||
const request = data.requests.find(r => r.id === entry.request_id);
|
||||
assert.equal(request.status, 'draft');
|
||||
assert.equal(request.protocol, null);
|
||||
await support.locator('a[href="/balcao-virtual/pedidos/' + entry.request_id + '"]').click();
|
||||
await page.waitForURL(BASE + '/balcao-virtual/pedidos/' + entry.request_id);
|
||||
await page.getByRole('heading', {name:'Rascunho — ainda não submetido', exact:true}).waitFor();
|
||||
assert.equal(await page.locator('#request-country').inputValue(), 'PT');
|
||||
assert.equal(await page.locator('#declared-origin-country').inputValue(), 'ZA');
|
||||
await page.getByRole('link', {name:'Voltar ao dossier ' + entry.title, exact:true}).waitFor();
|
||||
const prepared = (await q.json(page, '/balcao-virtual/pedidos/' + entry.request_id)).data;
|
||||
if (!prepared.request.payload.description && name === 'A') {
|
||||
const source = prepared.toolPrefill;
|
||||
assert(source && source.draft_id === entry.draft_id);
|
||||
const reuse = page.getByRole('region', {name:'Descrição guardada no projeto'});
|
||||
await reuse.getByText(source.description, {exact:true}).waitFor();
|
||||
await regionShot(reuse, 'A-C88-explicit-reuse-preview');
|
||||
const saved = page.waitForResponse(r => r.request().method() === 'PUT' && new URL(r.url()).pathname === '/balcao-virtual/pedidos/' + entry.request_id);
|
||||
await reuse.getByRole('button', {name:'Reutilizar descrição guardada e guardar rascunho', exact:true}).click();
|
||||
assert([200,302,303].includes((await saved).status()));
|
||||
await page.reload({waitUntil:'domcontentloaded'});
|
||||
const recovered = (await q.json(page, '/balcao-virtual/pedidos/' + entry.request_id)).data;
|
||||
assert.equal(recovered.request.payload.description, source.description);
|
||||
assert.equal(recovered.request.version, prepared.request.version + 1);
|
||||
assert.equal(recovered.request.status, 'draft');
|
||||
assert.equal(recovered.request.payload.country, 'PT');
|
||||
assert.equal(recovered.request.payload.declared_origin_country_code, 'ZA');
|
||||
assert(recovered.revisions.some(r => Number(r.version) === Number(prepared.request.version) && r.snapshot.payload.description === ''));
|
||||
assert(recovered.events.some(e => e.action === 'request_prefill_reused' && e.payload.draft_id === entry.draft_id));
|
||||
q.proof.C88 = {request_id:entry.request_id, draft_id:source.draft_id, draft_version:source.draft_version,
|
||||
request_version_before:prepared.request.version, request_version_after:recovered.request.version,
|
||||
old_empty_description_preserved_in_revision:true, explicit_preview_and_ui_save:true, source_recorded_in_audit:true};
|
||||
q.proof.business_data_writes = 1;
|
||||
q.save();
|
||||
} else if (name === 'A') {
|
||||
const reused = prepared.events.find(e => e.action === 'request_prefill_reused' && e.payload.draft_id === entry.draft_id);
|
||||
assert(reused, 'Recovered reuse must have provenance in the existing audit trail');
|
||||
assert(prepared.revisions.some(r => r.snapshot.payload.description === ''), 'Empty original preserved in a previous revision');
|
||||
q.proof.C88 = {request_id:entry.request_id, draft_id:reused.payload.draft_id, draft_version:reused.payload.draft_version,
|
||||
recovered_request_version:prepared.request.version, old_empty_description_preserved_in_revision:true,
|
||||
source_recorded_in_audit:true, explicit_preview_and_ui_save_receipt:'investor-ab-remaining-chromium-20261010-attempt-' + q.stamp + '.json',
|
||||
mutation_attempt_started_at:q.prior.started_at, mutation_attempt_result:q.prior.result,
|
||||
mutation_attempt_harness_limit:'revision version is serialized as text; corrected comparison uses Number and does not repeat the save'};
|
||||
q.save();
|
||||
}
|
||||
const populated = await page.locator('textarea, input:not([type="hidden"]):not([type="checkbox"])').evaluateAll(nodes => nodes.filter(n => n.value).map(n => ({label:n.labels?.[0]?.textContent?.trim(), value:n.value})).filter(n => /nome.*proje|título|resumo|descrição/i.test(n.label || '')));
|
||||
q.proof.prefill_probe = {scenario:name, fields:populated}; q.save();
|
||||
assert(populated.some(n => n.value === entry.title), 'Balcao title must remain the correct dossier title');
|
||||
assert(populated.some(n => n.value === 'Descrição sintética ' + name + ' QA para recuperar no Balcão.'), 'Balcao summary must come from the contextual saved tool');
|
||||
await q.shot(page, name + '-balcao-prepared-390');
|
||||
await audit('/balcao-virtual/pedidos/' + entry.request_id, 390);
|
||||
q.proof.scenarios[name] = {case_id:entry.id, draft_id:entry.draft_id, result_id:entry.result_id,
|
||||
facts:data.facts, land:{state:land.state, saved_state:land.saved_state, preparation_allowed:land.preparation_allowed},
|
||||
break_even_units:20, funding_gap:entry.expected_funding_gap, currency:result.currency,
|
||||
result_only_in_correct_dossier:true, preserved_result_history_count:data.results.length, request_id:entry.request_id, request_status:request.status,
|
||||
request_country:'PT', request_origin:'ZA', prefilled_fields:populated,
|
||||
task_id:savedTask.id, task_status:savedTask.status, task_title:savedTask.title,
|
||||
independent_stage_enabled:name === 'B', case_version_before:data.case.version};
|
||||
q.save();
|
||||
}
|
||||
q.phase('320px A/B dossier reflow, same data and no repeated mutation');
|
||||
await page.setViewportSize({width:320, height:844});
|
||||
for (const name of ['A', 'B']) {
|
||||
const entry = previous.scenarios[name];
|
||||
await q.open(page, '/investimentos/' + entry.id);
|
||||
await page.getByRole('heading', {name:entry.title, exact:true}).waitFor();
|
||||
await audit('/investimentos/' + entry.id, 320);
|
||||
const data = (await q.json(page, '/investimentos/' + entry.id)).data;
|
||||
assert.equal(data.case.version, q.proof.scenarios[name].case_version_before);
|
||||
q.proof.scenarios[name].case_version_after = data.case.version;
|
||||
await regionShot(page.locator('section').filter({has:page.getByRole('heading', {name:'Resultados devolvidos ao dossier', exact:true})}), name + '-financial-320');
|
||||
}
|
||||
q.proof.checks = {applicability_A_no_land_no_funding:true, applicability_B_land_and_funding:true,
|
||||
financial_results_in_correct_dossier:true, prepared_balcao_drafts_recovered:true,
|
||||
independent_task_completed_while_land_remains_waiting:true, independent_preparation_enabled:true,
|
||||
mobile_390_320_no_overflow_or_axe_violations:true, preserved_existing_dossiers_and_versions:true};
|
||||
q.proof.business_data_writes ||= 0;
|
||||
q.proof.scope_limit = 'Internal A/B preparation and follow-up only; no Balcao submission or external land/finance decision. Real devices and screen-reader acceptance remain separate.';
|
||||
q.proof.result = 'passed';
|
||||
} catch (error) {
|
||||
q.proof.result = 'failed';
|
||||
q.proof.error = sanitized(error);
|
||||
if (page && !['/login','/two-factor-challenge'].includes(new URL(page.url()).pathname)) {
|
||||
q.proof.failure_state = {path:new URL(page.url()).pathname, labels:await page.locator('label').allTextContents(), alerts:await page.getByRole('alert').allTextContents()};
|
||||
await q.shot(page, 'failure').catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
q.proof.finished_at = new Date().toISOString();
|
||||
q.save();
|
||||
await q.browser.close();
|
||||
console.log(JSON.stringify({result:q.proof.result, phase:q.proof.phase, checks:q.proof.checks, receipt:q.receipt}));
|
||||
if (q.proof.result !== 'passed') process.exitCode = 1;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,35 @@
|
||||
const {start,BASE,assert,sanitized}=require('./portal-visual-qa-lib.cjs');
|
||||
(async()=>{const q=await start('investor-ab-chromium-20261010');q.proof.scenarios=q.prior.scenarios||{};q.proof.checks={...(q.prior.checks||{})};q.proof.prior_attempt_started_at=q.prior.started_at||null;let page,ctx;
|
||||
async function view(id){const r=await q.json(page,'/investimentos/'+id);assert.equal(r.status,200);return r.data;}
|
||||
async function mutate(path,action){const response=page.waitForResponse(r=>new URL(r.url()).pathname===path&&r.request().method()==='POST');await action();const r=await response;assert([200,201,302,303].includes(r.status()),'Expected successful UI mutation');await page.waitForLoadState('domcontentloaded');}
|
||||
async function choice(label,text){const escaped=label.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');const control=page.getByRole('combobox',{name:new RegExp('^'+escaped+'(?: \\(obrigatório\\))?$')});await control.fill(text);await page.getByRole('option').filter({hasText:text}).first().click();}
|
||||
async function state(e,code,value,reason){const box=page.locator('#'+code);await box.locator('summary').click();await box.getByLabel(/^Estado/).selectOption(value);await box.getByLabel(/^Acção/).selectOption('prepare');await box.getByLabel('Fundamento',{exact:true}).fill(reason);await mutate('/investimentos/'+e.id+'/etapas/'+code,()=>box.getByRole('button',{name:'Guardar registo',exact:true}).click());await page.reload({waitUntil:'domcontentloaded'});assert.equal((await view(e.id)).steps.find(s=>s.code===code).saved_state,value);}
|
||||
try{
|
||||
if(!q.proof.checks.new_session_applicability_parallel){q.phase('A/B creation through interface');({page,ctx}=await q.context('investor18'));await q.login(page,'investor18');
|
||||
for(const name of ['A','B']){const e=q.proof.scenarios[name]||={title:'QA visual '+name+' — '+q.stamp};await q.open(page,'/investimentos');if(!e.id){await page.getByLabel('Título',{exact:true}).fill(e.title);await page.getByLabel('Autorizo reutilizar o meu perfil neste dossier privado.').check();await page.getByRole('button',{name:'Criar e guardar dossier',exact:true}).click();await page.waitForURL(/\/investimentos\/[0-9a-f-]{36}$/);e.id=new URL(page.url()).pathname.split('/').pop();e.created_via_ui=true;q.save();}await q.open(page,'/investimentos/'+e.id);await page.getByRole('heading',{name:e.title,exact:true}).waitFor();assert.equal(await page.locator('article[id^="S"]').count(),19);assert.equal(await page.getByRole('heading',{name:/^Fase \d+ ·/}).count(),5);}
|
||||
assert.notEqual(q.proof.scenarios.A.id,q.proof.scenarios.B.id);q.proof.checks.two_independent_dossiers_5_phases_19_steps=true;q.save();await q.logout(page);await ctx.close();
|
||||
q.phase('Fresh authenticated session, applicability and parallel preparation');({page,ctx}=await q.context('investor18-new-session'));await q.login(page,'investor18');
|
||||
for(const name of ['A','B']){const e=q.proof.scenarios[name];await q.open(page,'/investimentos');await page.getByRole('link',{name:e.title,exact:true}).click();await page.waitForURL(BASE+'/investimentos/'+e.id);e.recovered_new_session=true;
|
||||
const form=page.locator('form').filter({has:page.getByRole('heading',{name:'Decisões do promotor e aplicabilidade',exact:true})});await form.getByLabel(/^Precisa de terra/).selectOption(name==='A'?'no':'yes');await form.getByLabel(/^Precisa de financiamento externo/).selectOption(name==='A'?'no':'yes');await form.getByLabel(/^Decisão sobre viabilidade/).selectOption('proceed');await form.getByLabel('Fundamento',{exact:true}).fill('Declaração sintética '+name+' QA; sem decisão externa.');await mutate('/investimentos/'+e.id+'/aplicabilidade',()=>form.getByRole('button',{name:'Guardar decisões',exact:true}).click());await page.reload({waitUntil:'domcontentloaded'});
|
||||
const v=await view(e.id);assert.equal(v.facts.land_needed,name==='A'?'no':'yes');assert(name==='A'?v.steps.find(s=>s.code==='S10').state==='dispensed':['not_started','waiting_external'].includes(v.steps.find(s=>s.code==='S10').state));e.applicability_recovered=true;
|
||||
if(name==='B')await state(e,'S10','waiting_external','Aguarda informação do terreno QA; mercado independente.');await state(e,'S02','preparing','Preparação independente de mercado QA.');await q.shot(page,'dossier-'+name+'-applicability',false);q.save();}
|
||||
q.proof.checks.new_session_applicability_parallel=true;
|
||||
}else{q.phase('Resume demonstrated A/B, preserving prior evidence and persisted states');({page,ctx}=await q.context('investor18'));await q.login(page,'investor18');}
|
||||
q.phase('Contextual tool, saved financial result and return to dossier');
|
||||
for(const name of ['A','B']){const e=q.proof.scenarios[name];await q.open(page,'/investimentos/'+e.id);await page.getByRole('link',{name:'Plano e simulador',exact:true}).click();await page.waitForURL(u=>u.searchParams.get('context')===e.id);await page.getByText('Dossier privado recuperado da sua conta.',{exact:true}).waitFor();await choice('O que pretende calcular ou planear? Escolha o percurso','Avaliar o meu investimento');await choice('Nível de detalhe','Simples');await choice('O que pretende fazer?','Apenas simular');await choice('Idioma preferido','Português (Moçambique)');
|
||||
await page.getByRole('navigation',{name:'Etapas do formulário'}).getByRole('button',{name:/Projecto ou negócio/}).click();await page.getByLabel('Título do projecto',{exact:true}).fill('Plano financeiro '+name+' QA');await page.getByLabel('Resumo executivo',{exact:true}).fill('Descrição sintética '+name+' QA para recuperar no Balcão.');
|
||||
await page.getByRole('navigation',{name:'Etapas do formulário'}).getByRole('button',{name:/Estrutura financeira/}).click();
|
||||
// UI probes contain field names only, no entered credentials or OTP.
|
||||
q.proof.tool_probe={scenario:name,path:new URL(page.url()).pathname,labels:await page.locator('label').allTextContents(),alerts:await page.getByRole('alert').allTextContents()};q.save();
|
||||
const financial=page.getByRole('region',{name:'Ferramentas de planeamento financeiro'});await choice('Moeda','MZN');assert.equal(await financial.getByLabel('Moeda dos valores (código ISO)').inputValue(),'MZN');await financial.getByLabel(/^Custos fixos/).fill('1000');await financial.getByLabel('Preço por unidade',{exact:true}).fill('100');await financial.getByLabel('Custo variável por unidade',{exact:true}).fill('50');await financial.getByLabel('Investimento total',{exact:true}).fill('100000');await financial.getByLabel('Montante já mobilizado',{exact:true}).fill(name==='A'?'100000':'30000');await financial.getByLabel(/^Valor da receita/).first().fill('100');await financial.getByLabel(/^Valor da despesa/).first().fill('50');await financial.getByText(/Unidades: 20;/).waitFor();assert.equal(await financial.getByLabel('Moeda dos valores (código ISO)').inputValue(),'MZN');
|
||||
await page.getByRole('button',{name:'Guardar e continuar mais tarde',exact:true}).click();await page.getByText('Dossier guardado no espaço privado da sua conta. Pode continuar mais tarde ao iniciar sessão.',{exact:true}).waitFor();await page.getByRole('button',{name:'Guardar resultado financeiro e voltar ao dossier',exact:true}).click();await page.waitForURL(BASE+'/investimentos/'+e.id);const v=await view(e.id);assert(v.results.length>0);e.result_id=v.results.at(-1).id;e.draft_id=v.results.at(-1).draft_id;e.tool_returned_via_ui=true;await page.getByText('Ponto de equilíbrio: 20 unidades',{exact:true}).first().waitFor();await page.getByText(new RegExp('^Necessidade de financiamento: '+(name==='A'?'0':'70000')+'(?:\\.00)?$')).first().waitFor();e.expected_break_even=20;e.expected_funding_gap=name==='A'?0:70000;await q.shot(page,'dossier-'+name+'-result');q.save();
|
||||
const support=page.locator('section').filter({has:page.getByRole('heading',{name:'Apoio no Balcão',exact:true})});const requests=(await view(e.id)).requests;if(!requests.length){await support.getByLabel(/^Serviço/).selectOption({index:0});await support.getByRole('button',{name:'Preparar pedido com dados do dossier',exact:true}).click();await page.waitForURL(/\/balcao-virtual\/pedidos\/[0-9a-f-]{36}$/);e.request_id=new URL(page.url()).pathname.split('/').pop();}else{e.request_id=requests[0].id;await support.locator('a[href="/balcao-virtual/pedidos/'+e.request_id+'"]').click();await page.waitForURL(BASE+'/balcao-virtual/pedidos/'+e.request_id);}q.save();await page.locator('#request-country').selectOption('PT');await page.locator('#declared-origin-country').selectOption('ZA');const supportSave=page.waitForResponse(r=>r.request().method()==='PUT'&&new URL(r.url()).pathname==='/balcao-virtual/pedidos/'+e.request_id);await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();assert([200,302,303].includes((await supportSave).status()));await page.reload({waitUntil:'domcontentloaded'});assert.equal(await page.locator('#request-country').inputValue(),'PT');assert.equal(await page.locator('#declared-origin-country').inputValue(),'ZA');e.support_ui_saved=true;await q.shot(page,'balcao-'+name);
|
||||
await q.open(page,'/investimentos/'+e.id);const task=page.locator('section').filter({has:page.getByRole('heading',{name:'Acompanhamento interno',exact:true})});if(!(await view(e.id)).tasks.length){await task.getByLabel(/^Etapa/).selectOption('S02');await task.getByLabel('Tarefa',{exact:true}).fill('Mercado paralelo '+name+' QA');await task.getByLabel('Prazo',{exact:true}).fill('2026-10-20T12:00');await task.getByLabel('Assumo esta tarefa no acompanhamento interno.').check();await mutate('/investimentos/'+e.id+'/tarefas',()=>task.getByRole('button',{name:'Guardar tarefa',exact:true}).click());await page.reload({waitUntil:'domcontentloaded'});}e.task_id=(await view(e.id)).tasks[0].id;q.save();}
|
||||
q.proof.checks.contextual_tool_results_support_tasks=true;await q.logout(page);await ctx.close();
|
||||
q.phase('Management follow-up with private financial data protected');({page,ctx}=await q.context('manager'));await q.login(page,'manager');
|
||||
for(const name of ['A','B']){const e=q.proof.scenarios[name];await q.open(page,'/investimentos/'+e.id);await page.getByText('Nenhum resultado financeiro disponível para esta conta neste dossier.',{exact:true}).waitFor();assert.equal((await view(e.id)).results.length,0);if((await view(e.id)).tasks[0].status!=='done'){await page.getByLabel('Fundamento do resultado ou atribuição').fill('Acompanhamento interno sintético '+name+' QA.');for(const action of ['Assumir acompanhamento','Iniciar tarefa','Registar conclusão']){await mutate('/balcao-virtual/dossiers/'+e.id+'/tarefas/'+e.task_id,()=>page.getByRole('button',{name:action,exact:true}).click());await page.reload({waitUntil:'domcontentloaded'});await page.getByLabel('Fundamento do resultado ou atribuição').fill('Acompanhamento interno sintético '+name+' QA.');}}assert.equal((await view(e.id)).tasks[0].status,'done');e.management_ui_completed=true;await q.shot(page,'management-'+name);q.save();}await q.logout(page);await ctx.close();
|
||||
q.phase('Investor recovery, no project mixing, outsider denial');({page,ctx}=await q.context('investor18-recovery'));await q.login(page,'investor18');
|
||||
for(const name of ['A','B']){const e=q.proof.scenarios[name];await q.open(page,'/investimentos/'+e.id);const v=await view(e.id);assert(v.results.some(r=>r.id===e.result_id&&r.draft_id===e.draft_id));assert.equal(v.tasks[0].status,'done');assert.equal(v.requests[0].id,e.request_id);await page.getByRole('heading',{name:'Próxima acção',exact:true}).waitFor();assert.equal(v.steps.find(s=>s.code==='S10').state,name==='A'?'dispensed':'waiting_external');e.final_ui_recovery=true;await q.shot(page,'final-'+name);}
|
||||
for(const [name,e] of Object.entries(q.proof.scenarios)){await q.open(page,'/simulador-fiscal?context='+e.id);await page.getByText('Dossier privado recuperado da sua conta.',{exact:true}).waitFor();await page.getByRole('navigation',{name:'Etapas do formulário'}).getByRole('button',{name:/Projecto ou negócio/}).click();assert.equal(await page.getByLabel('Título do projecto',{exact:true}).inputValue(),'Plano financeiro '+name+' QA');await page.getByRole('navigation',{name:'Etapas do formulário'}).getByRole('button',{name:/Estrutura financeira/}).click();const tool=page.getByRole('region',{name:'Ferramentas de planeamento financeiro'});assert.equal(await tool.getByLabel(/^Custos fixos/).inputValue(),'1000');assert.equal(await tool.getByLabel('Moeda dos valores (código ISO)').inputValue(),'MZN');assert.equal(await tool.getByLabel('Montante já mobilizado',{exact:true}).inputValue(),name==='A'?'100000':'30000');e.tool_inputs_recovered_new_session=true;await q.shot(page,'tool-recovery-'+name);}q.proof.checks.tool_inputs_recovered_new_session=true;
|
||||
assert.notEqual(q.proof.scenarios.A.draft_id,q.proof.scenarios.B.draft_id);await q.logout(page);await ctx.close();({page,ctx}=await q.context('outsider'));await q.login(page,'outsider');for(const e of Object.values(q.proof.scenarios)){assert.equal((await q.open(page,'/investimentos/'+e.id)).status(),403);assert.equal((await q.json(page,'/api/v1/simulation-drafts/'+e.draft_id)).status,404);}await ctx.close();q.proof.checks.final_recovery_financial_privacy_unauthorized_denied=true;q.proof.result='passed';
|
||||
}catch(e){q.proof.result='failed';q.proof.error=sanitized(e);if(page&&!['/login','/two-factor-challenge'].includes(new URL(page.url()).pathname)){q.proof.failure_state={path:new URL(page.url()).pathname,labels:await page.locator('label').allTextContents(),alerts:await page.getByRole('alert').allTextContents()};await q.shot(page,'failure').catch(()=>{});}}finally{q.proof.finished_at=new Date().toISOString();q.save();await q.browser.close();console.log(JSON.stringify({result:q.proof.result,phase:q.proof.phase,checks:q.proof.checks,receipt:q.receipt}));if(q.proof.result!=='passed')process.exitCode=1;}})();
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Record the responsible user's scoped C37 acceptance, without repeating tests."""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
csv.field_size_limit(16000000)
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
OUT = PRIVATE / 'c37-manual-close-20261010'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc)
|
||||
references = ['c37-qa18-chromium-20261010.json', 'investor-ab-chromium-20261010.json',
|
||||
'portal-logout-chromium-20261010.json', 'logout-finalize-20261010/manual-qa18-logout.json']
|
||||
proofs = []
|
||||
for name in references:
|
||||
data = (PRIVATE / name).read_bytes()
|
||||
proof = json.loads(data)
|
||||
if name.endswith('manual-qa18-logout.json'):
|
||||
assert proof['button_and_logout_confirmed_by_user'] and not proof['session_authenticated_after_logout']
|
||||
else:
|
||||
assert proof['result'] == 'passed' and proof['active_writes'] == 0
|
||||
proofs.append({'file': name, 'sha256': hashlib.sha256(data).hexdigest()})
|
||||
evidence = {'recorded_at_utc': stamp.isoformat(), 'case': 'C37', 'state': 'closed_scoped_qa18_authentication',
|
||||
'manual_confirmation': 'Registar a confirmação manual e encerrar C37 no âmbito da autenticação QA18, ligando-a às provas anteriores de logout e recuperação. Não repetir os ensaios já concluídos.',
|
||||
'accepted_by': 'Responsável, mensagem desta conversa em 10/10/2026', 'actor_id': 18,
|
||||
'scope': 'Login, MFA, redirecionamento, sessão autenticada, atualização, logout e recuperação/nova sessão QA18',
|
||||
'references': proofs, 'tests_repeated': False, 'active_writes': 0,
|
||||
'does_not_close': ['A/B acceptance', 'entire audit', 'mobile and full accessibility', 'other profiles', 'active activation']}
|
||||
(OUT / 'acceptance.json').write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
tag = '[C37 fechado QA18 10/10/2026] '
|
||||
text = ('Confirmação manual expressa do responsável: C37 encerrado exclusivamente no âmbito da autenticação QA18. '
|
||||
'Liga login/MFA/redirecionamento/sessão/F5/nova sessão à prova Chromium QA18 anterior, '
|
||||
'ao logout manual18 com invalidação de sessão e à recuperação previamente demonstrada; sem repetir ensaios. '
|
||||
'Aceitação sanitizada e hashes: c37-manual-close-20261010/acceptance.json. C85 permanece corrigido QA. '
|
||||
'Não encerra A/B, auditoria integral, outros perfis ou ativação ativa.')
|
||||
ids = {'AUD-UI-01', 'AUD-GUIDE-01', 'AUD-DASH-01', 'AUD-PROFILE-24', 'AUD-PROFILE-25'}
|
||||
files = []
|
||||
for filename in ['matriz-requisitos.csv', 'backlog-priorizado.csv']:
|
||||
source = DOC / filename
|
||||
rows = list(csv.reader(source.open(encoding='utf-8-sig', newline='')))
|
||||
h = rows[0]
|
||||
patches = []
|
||||
for index, row in enumerate(rows[1:], 1):
|
||||
old = dict(zip(h, row))
|
||||
if old['ID'] not in ids:
|
||||
continue
|
||||
if filename.startswith('matriz'):
|
||||
values = {'Evidência funcional': old['Evidência funcional'] + ' | ' + tag + text,
|
||||
'Lacuna': tag + 'C37 não é mais pendência de autenticação QA18; permanecem os demais critérios do requisito. Histórico abaixo preservado e superado apenas neste âmbito. | ' + old['Lacuna'],
|
||||
'Interface': old['Interface'] + ' | ' + tag + 'Aceitação manual QA18 encerrada, ligada a logout e recuperação anteriores.',
|
||||
'Recuperação': old['Recuperação'] + ' | ' + tag + 'Recuperação/nova sessão aceite pelo responsável; provas anteriores preservadas, não repetidas.',
|
||||
'Consolidação': old['Consolidação'] + ' | ' + tag + 'Fecho por caso e âmbito; estado global parcial e referências/versões preservados.'}
|
||||
variants = json.loads(old['Critérios e versões por origem (JSON)'])
|
||||
for variant in variants:
|
||||
variant['aceitacao_c37_qa18_20261010'] = {'case': 'C37', 'estado': 'encerrado no âmbito da autenticação QA18',
|
||||
'fonte': 'Confirmação manual expressa do responsável', 'prova': 'c37-manual-close-20261010/acceptance.json',
|
||||
'registado_em': stamp.isoformat(), 'ensaios_repetidos': False, 'ativacao': False}
|
||||
values['Critérios e versões por origem (JSON)'] = json.dumps(variants, ensure_ascii=False, separators=(',', ':'))
|
||||
else:
|
||||
values = {'Pendência': tag + 'C37 autenticação QA18 encerrado; não agendar repetição. Demais pendências e histórico: ' + old['Pendência'],
|
||||
'Aceitação': old['Aceitação'] + ' | ' + tag + text}
|
||||
patches.append({'row': index, 'values': values})
|
||||
assert len(rows) == 3394 and len(patches) == len(ids)
|
||||
files.append({'filename': filename, 'input_sha256': hashlib.sha256(source.read_bytes()).hexdigest(), 'rows': rows,
|
||||
'patches': patches, 'allowed_columns': sorted({field for patch in patches for field in patch['values']}), 'append': []})
|
||||
(OUT / 'csv-edits.json').write_text(json.dumps({'files': files}, ensure_ascii=False), encoding='utf-8')
|
||||
section = f'''## C37 encerrado — autenticação QA18 — {stamp.astimezone(timezone(timedelta(hours=2))).isoformat()}
|
||||
|
||||
**Confirmação manual expressa do responsável registada. C37 está encerrado exclusivamente no âmbito da autenticação QA18.** Login → MFA → redirecionamento → sessão autenticada → atualização → logout → nova sessão/recuperação estão ligados às provas anteriores, sem repetir ensaios nem alterar conta, chave ou MFA. O logout manual de 20:17:15 foi confirmado pelo responsável e pelo rastreio: POST302→/, autenticação e estado persistido vazios.
|
||||
|
||||
Aceitação e hashes dos recibos: `c37-manual-close-20261010/acceptance.json`. Referências: `c37-qa18-chromium-20261010.json`, `logout-finalize-20261010/manual-qa18-logout.json`, `portal-logout-chromium-20261010.json` e recuperação do dossier em `investor-ab-chromium-20261010.json`. A prova QA21 do botão é complementar; a conta18 tem a sua própria confirmação. Horário local UTC+02; datas técnicas dos recibos conservadas em UTC.
|
||||
|
||||
O fecho **não encerra A/B, auditoria integral, móvel/acessibilidade integral, outros perfis ou ativação ativa**. Os cinco requisitos relacionados continuam parciais pelos seus outros critérios; contagens preservadas: 3393 requisitos/3393 linhas/6845 ocorrências. Menções históricas abaixo a C37/autenticação18 pendente são superadas somente por este fecho. C85 permanece corrigido em QA. Próximo trabalho independente: gravação/recuperação territorial e consistência país–hierarquia por consumidor, países/origem/estatísticas, documentos e workflow, preservando decisões próprias de perfis modulares/SLA.
|
||||
'''
|
||||
for name in ['relatorio-executivo.md', 'backlog.md', 'casos-reproducao.md', 'plano-ativacao.md']:
|
||||
file = DOC / name
|
||||
old = file.read_text(encoding='utf-8-sig')
|
||||
file.write_text('<!-- c37-manual-close-20261010:begin -->\n' + section + '\n<!-- c37-manual-close-20261010:end -->\n\n' + old, encoding='utf-8')
|
||||
print(json.dumps({'case': 'C37', 'state': 'closed_scoped_qa18_authentication', 'requirements_touched': len(ids), 'tests_repeated': False, 'active_writes': 0}))
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Reconcile browser evidence into the sole existing audit, preserving source references."""
|
||||
import collections
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
csv.field_size_limit(5_000_000)
|
||||
browser = json.loads((PRIVATE / 'legacy-content-browser-qa.json').read_text(encoding='utf-8'))
|
||||
persist = json.loads((PRIVATE / 'legacy-content-browser-persistence-qa.json').read_text(encoding='utf-8'))
|
||||
assert browser['result'] == 'passed' and browser['interface_observed'] and browser['checks']['qa_browser_title']
|
||||
assert persist['dossier_id'] == browser['dossier_id'] and persist['reference_count'] == persist['reference_event_count'] == 1
|
||||
assert browser['creation_evidence']['receipt'] and browser['checks']['created_from_ui']
|
||||
assert persist['transaction_read_only'] and not persist['active_access'] and browser['active_writes'] == 0
|
||||
matrix = DOC / 'matriz-requisitos.csv'
|
||||
rows = list(csv.DictReader(matrix.open(encoding='utf-8-sig', newline='')))
|
||||
headers = list(rows[0])
|
||||
protected = ['ID', 'Pedido/trecho', 'Fonte/ocorrências', 'Critério de aceitação', 'Versões divergentes',
|
||||
'IDs das linhas de origem', 'Quantidade de linhas de origem']
|
||||
before = {r['ID']: {k: r[k] for k in protected} for r in rows}
|
||||
tag = '[Interface legado QA 10/10/2026] '
|
||||
evidence = ('Chromium instalado, contextos isolados, desktop 1440 px / móvel 390 px: fotografia/fontes/paleta, pesquisa hit/miss, '
|
||||
'Enter/foco de 3 px, links carteira/zonas/biblioteca/oportunidade. Promotor QA21: login→MFA→dossier→fonte→retorno→'
|
||||
'logout→novo contexto/MFA→recuperação/F5→associação repetida. Conta23:403 e artigo público sem título privado; '
|
||||
'anónimo sem contexto privado. UI observada; referência/evento únicos e originais/hash/scan confirmados '
|
||||
'em leitura QA. legacy-content-browser-qa.json (creation_evidence aponta a criação inicial); legacy-content-browser-persistence-qa.json; legado-piloto-qa.md.')
|
||||
gaps = {
|
||||
'AUD-LEG-01': 'Cobertura histórica, paginação, destinos externos e restantes exemplares/versões não importados. C68 continua identificado.',
|
||||
'AUD-LEG-02': 'Piloto desktop/móvel e foco por teclado demonstrados. Falta cobertura sistemática dos restantes controlos/tecnologias assistivas e expansão das adaptações ao restante legado; não declarar conformidade integral de acessibilidade.',
|
||||
'AUD-LEG-03': 'Percurso do piloto demonstrado na interface. Relações cartográficas concretas dos seis corredores ainda não fundamentadas; link para mapas gerais não comprova delimitação/integração territorial do artigo. A/B globais permanecem independentes.',
|
||||
'AUD-LEG-04': 'Nenhum critério pendente na consulta e proteção do piloto QA demonstrado. C37/QA18 no navegador do utilizador e disponibilização ativa são requisitos/ações independentes, não fechados por este ensaio.',
|
||||
'AUD-LEG-05': 'Relações territoriais concretas e ampliação por etapa/setor ainda por fundamentar. Fonte única no piloto, sem criar oportunidades ou geometrias pelo artigo.',
|
||||
'AUD-LEG-06': 'Contexto/retorno do piloto observados; mapa de migração dos restantes URLs ainda pendente. C37/QA18 e cenários A/B completos não foram executados neste ensaio.',
|
||||
'AUD-LEG-07': 'Imagens/links/pesquisa/permissões/deduplicação do piloto demonstrados. Anexos/referências dos restantes conteúdos e imagem histórica404 por resolver; cobertura global permanece parcial.',
|
||||
}
|
||||
for row in rows:
|
||||
if row['ID'] not in gaps:
|
||||
continue
|
||||
# Old findings remain as dated evidence; source requests and acceptance wording are immutable.
|
||||
for key in ['Interface', 'Evidência funcional', 'Resultado apresentado', 'Recuperação']:
|
||||
if tag not in row[key]:
|
||||
value = evidence if key == 'Evidência funcional' else (
|
||||
'Render/cliques observados no Chromium QA, com recuperação noutra sessão/F5 e negação à conta23. '
|
||||
'Não é o navegador pessoal do utilizador nem o ensaio C37/QA18.')
|
||||
row[key] = tag + value + ' | Evidência anterior preservada: ' + row[key]
|
||||
row['Lacuna'] = gaps[row['ID']]
|
||||
row['Dependência'] = 'Biblioteca/documentos existentes; Balcão+09/190000 para dossier. Expansão cartográfica exige correspondências fundamentadas. C37/A-B/SLA/perfis e ativação mantêm âmbito próprio.'
|
||||
if row['ID'] == 'AUD-LEG-04':
|
||||
old_state = row['Estado']
|
||||
row['Estado'] = 'concluído e demonstrado'
|
||||
variants = json.loads(row['Critérios e versões por origem (JSON)'])
|
||||
for variant in variants:
|
||||
if variant.get('estado') != row['Estado']:
|
||||
variant.setdefault('historico_estado', []).append({'estado': variant.get('estado'), 'motivo': 'Sem prova visual no ensaio inicial'})
|
||||
variant['estado'] = row['Estado']
|
||||
variant['ambito_demonstrado'] = 'Piloto agronegócio exclusivamente QA; não ativação, C37 ou cobertura histórica'
|
||||
variant['evidencia'] = 'legacy-content-browser-qa.json; legacy-content-browser-persistence-qa.json'
|
||||
row['Critérios e versões por origem (JSON)'] = json.dumps(variants, ensure_ascii=False)
|
||||
if tag not in row['Consolidação']:
|
||||
row['Consolidação'] += ' | ' + tag + 'Estado anterior ' + old_state + '; critérios do piloto QA agora demonstrados; ativo indisponível e requisitos globais preservados.'
|
||||
else:
|
||||
if tag not in row['Consolidação']:
|
||||
row['Consolidação'] += ' | ' + tag + 'Critérios visuais do piloto comprovados sem encerrar as lacunas globais enumeradas.'
|
||||
# A later backup probe discovered a separate QA infrastructure fault. Keep the UI proof,
|
||||
# but do not allow a historical readiness receipt to imply a current verified backup.
|
||||
failure = json.loads((PRIVATE / 'profile-installation-backup-failure-qa.json').read_text(encoding='utf-8'))
|
||||
preservation = json.loads((PRIVATE / 'qa-readable-preservation.json').read_text(encoding='utf-8'))
|
||||
assert not failure['restore_verified'] and f"{failure['target']['database']}:{failure['target']['port']}" == browser['target']
|
||||
assert preservation['transaction_read_only'] and not preservation['restore_verified'] and preservation['database_writes'] == 0
|
||||
fault_tag = '[C71 QA 10/10/2026] '
|
||||
for row in rows:
|
||||
if row['ID'] not in ['AUD-OPS-01', 'AUD-PROFILE-31']:
|
||||
continue
|
||||
if fault_tag not in row['Evidência funcional']:
|
||||
row['Evidência funcional'] += ' | ' + fault_tag + 'Novo pg_dump falha: global/2964 (pg_db_role_setting) ausente no cluster QA55441. Prova anterior13:35:39UTC conservada, não é renovação. 295 tabelas/34902 linhas/31 sequências preservadas por leitura;26 ficheiros de catálogos partilhados ausentes. Preservação não é restauro verificado. qa-readable-preservation.json; profile-installation-backup-failure-qa.json.'
|
||||
row['Lacuna'] = fault_tag + 'Restauro atual bloqueado pela integridade do cluster QA; origem da remoção não identificada. Recuperar em cluster isolado preservando todos os dados, conferir schema/seq/ficheiros/MFA e repetir backup/restauro e percursos afetados. Readiness QA invalidada; candidata não fixada. | Lacunas anteriores preservadas: ' + row['Lacuna']
|
||||
row['Dependência'] = fault_tag + 'Restabelecer backup/restauro QA antes de novas escritas de ensaio ou candidata; nenhum catálogo copiado/recriado às cegas e nenhuma ação no PostgreSQL ativo5432. | ' + row['Dependência']
|
||||
if row['ID'] == 'AUD-OPS-01':
|
||||
row['Estado'] = 'com falha'
|
||||
row['Prioridade'] = 'P0'
|
||||
assert before == {r['ID']: {k: r[k] for k in protected} for r in rows}
|
||||
assert len({r['ID'] for r in rows}) == len(rows)
|
||||
with matrix.open('w', encoding='utf-8-sig', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=headers); writer.writeheader(); writer.writerows(rows)
|
||||
counts = {'requirements': len(rows), 'matrix_rows': len(rows), 'source_occurrences': sum(int(r['Quantidade de linhas de origem']) for r in rows),
|
||||
'states': dict(collections.Counter(r['Estado'] for r in rows)), 'legacy_states': {r['ID']: r['Estado'] for r in rows if r['ID'] in gaps}}
|
||||
(PRIVATE / 'legacy-browser-audit-update-qa.json').write_text(json.dumps(counts, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
marker = 'qa-legacy-browser-20261010'
|
||||
def prepend(filename, content):
|
||||
file = DOC / filename
|
||||
text = file.read_text(encoding='utf-8')
|
||||
text = re.sub(r'\n*<!-- ' + marker + r':begin -->.*?<!-- ' + marker + r':end -->\n*', '\n\n', text, flags=re.S)
|
||||
first, rest = text.split('\n', 1)
|
||||
file.write_text(first + '\n\n<!-- ' + marker + ':begin -->\n' + content + '\n<!-- ' + marker + ':end -->\n\n' + rest.lstrip(), encoding='utf-8')
|
||||
|
||||
summary = '''## Verificação do piloto na interface — 10/10/2026, exclusivamente QA
|
||||
|
||||
**O piloto existente foi demonstrado em Chromium instalado**, com contextos separados, sem usar o perfil pessoal do utilizador. O controlo nativo continua indisponível (`native pipe`, os error 2, duas sondagens); esse problema não impediu renderização, cliques e capturas por Playwright. C37/QA18 permanece pendente; este ensaio usou Promotor QA21 e outra conta sintética QA23.
|
||||
|
||||
Página pública→pesquisa com/sem resultado→artigo→carteira/mapas/biblioteca/oportunidade publicada→CTA→login/MFA da conta QA21→dossier guardado→fonte/retorno→logout→novo contexto/MFA→recuperação e F5→continuação/repetição passaram. Leitura anónima e QA23 não recebem o título/ID do contexto privado; dossier negado 403 à QA23. A criação na interface consta da tentativa inicial preservada; as repetições recuperam o mesmo dossier sem criar outro. Cinco fases / 19 etapas observadas. Persistência em transação READ ONLY: uma referência, um evento, um documento por hash; originais e scan preservados; projeto independente com zero referências.
|
||||
|
||||
Desktop 1440 px / móvel 390 px: fotografia 900×506, Noto Sans / Noto Serif carregadas, cores reais adaptadas, texto integral de 4.702 carateres, foco de 3 px por teclado e nenhum deslocamento horizontal. Sete capturas privadas. **C70 corrigido:** título de separador “Laravel” substituído por “InvesteMoz QA” apenas no build QA; bundle ativo preservado. Isto não certifica acessibilidade integral nem demonstra todas as geometrias/consumidores do portal.
|
||||
|
||||
**AUD-LEG-04 concluído e demonstrado em QA**, por consulta pública/proteção privada. AUD-LEG-01/02/03/05/06/07 continuam parciais pelas lacunas explicitadas na matriz: cobertura histórica, controlo assistivo sistemático, relações territoriais concretas, restante mapa de URLs e anexos. Nenhum I0–I3/A-B/workflow/C37 é encerrado por esta prova.
|
||||
|
||||
Recibos: `legacy-content-browser-qa.json`, `legacy-content-browser-persistence-qa.json`; testes `tools/legacy-content-browser-qa.cjs` e `tools/legacy-content-browser-persistence-qa.php`. Capturas e credenciais permanecem privadas; não foram registados Cookie, X-XSRF-TOKEN ou corpo com o código MFA. O ZIP anteriormente entregue é um snapshot anterior à verificação, não prova de execução remota.'''
|
||||
backup_note = """
|
||||
|
||||
**C71 / P0 — novo backup QA bloqueado:** `pg_dump` falhou às14:48–14:49UTC: `global/2964` ausente, catálogo `pg_db_role_setting`; diagnóstico identifica26 ficheiros de catálogos partilhados ausentes no cluster55441, situado em Temp. Não atribuir a remoção à limpeza automática sem evidência. O backup/restauro válido de13:35:39UTC (295 tabelas/81 ficheiros) permanece histórico. Preservação posterior READ ONLY:295 tabelas/34.902 linhas/31 sequências; não equivale a backup/restauro verificado. Readiness QA foi invalidada e o CLI passa a devolver exit1 controlado. Próxima ação: recuperação em ambiente isolado, preservação/conferência integral e nova prova de restauro antes de retomar escritas. Nenhum catálogo recriado às cegas nem acesso/escrita na base ativa. Ver `qa-readable-preservation.json`, `profile-installation-backup-failure-qa.json` e C71 em `casos-reproducao.md`."""
|
||||
summary += backup_note
|
||||
prepend('legado-piloto-qa.md', summary)
|
||||
prepend('relatorio-executivo.md', summary + f'''
|
||||
|
||||
**Contagens da mesma matriz:** {counts['requirements']} requisitos/{counts['matrix_rows']} linhas; {counts['source_occurrences']} ocorrências de origem. Estados: ''' + '; '.join(f"{v} {k}" for k, v in counts['states'].items()) + '.')
|
||||
prepend('backlog.md', '''## Próximo lote após a demonstração do piloto
|
||||
|
||||
| Requisito | Demonstrado | Falta / próxima ação |
|
||||
| --- | --- | --- |
|
||||
| AUD-OPS-01 / C71 — P0 | Diagnóstico e preservação READ ONLY; backup anterior intacto, readiness atual inválida. | Recuperar cluster QA e renovar backup/restauro antes de novas escritas; não fixar candidata. |
|
||||
| AUD-LEG-04 | Consulta pública e login apenas para dossier; MFA da conta QA21/nova sessão/F5; anónimo/QA23 sem contexto; QA23 negado 403. Concluído exclusivamente QA. | Ativação separada; não assumir C37/QA18 resolvido. |
|
||||
| AUD-LEG-02 | Desktop/móvel/fotografia/fontes/paleta, pesquisa, teclado; C70 título QA corrigido. | Verificação sistemática de controlos/tecnologias assistivas e adaptação dos outros setores. |
|
||||
| AUD-LEG-03/05 | Artigo→consumidores→dossier/fonte/retorno; referência/evento únicos; sem mistura. | Fundamentar correspondências dos seis corredores; mapas gerais não equivalem a geometrias relacionadas com o artigo. |
|
||||
| AUD-LEG-01/06/07 | Piloto visual e fontes preservadas. | Reconciliar os demais 50 exemplares observados, sitemap misto/paginação, outros URLs/anexos; C68 imagem 404 continua aberto. |
|
||||
| C37; A/B; dashboard QA21 | Ensaio QA21 do piloto não os substitui. | C37 no contexto problemático QA18; A/B completos com ferramentas/Balcão/acompanhamento; CRUD de oportunidades pelo dashboard. |
|
||||
|
||||
Continuar países/origem/estatísticas, 173 lacunas cartográficas (Mandie/Guro ambíguo), documentação/derivadas, Response/materialidade e workflow na mesma matriz. Nenhum responsável real ou compromisso SLA foi designado. Base ativa fora do âmbito; sem repetir instalações QA030/040/09-190. C71 bloqueia novas escritas QA até recuperar a integridade e o restauro, sem revogar as provas já obtidas.''')
|
||||
prepend('casos-reproducao.md', '''## C70 — identificação errada no título do navegador
|
||||
|
||||
**Requisito:** AUD-LEG-02; P2. **Reprodução:** abrir anónimamente `/explorar/agronegocio` na QA 18097 e consultar o título do separador. **Esperado:** identidade do portal e distinção QA. **Observado:** “Explorar agronegócio - Laravel”. **Causa:** `resources/js/app.tsx` usa o nome padrão da compilação; a QA herdava `VITE_APP_NAME` do ambiente local. **Correção:** `vite.config.ts` define “InvesteMoz QA” exclusivamente sob `INVESTMOZ_QA_BUILD=1`; não altera .env nem build ativo. **Evidência:** título verificado no Chromium, percurso renderizado após recompilação; manifesto ativo inalterado. Código preparado eQA demonstrada; não ativado.
|
||||
|
||||
As primeiras tentativas do ensaio de browser terminaram por falta de espera da navegação Inertia no próprio teste, antes de inspecionar o URL atualizado. O runner foi corrigido para aguardar o destino; recibos das tentativas preservados privadamente. Não classificar essas falhas de instrumentação como falhas do portal. C37 não foi repetido; C68 permanece aberto.
|
||||
|
||||
## C71 — falha do novo backup, cluster PostgreSQL QA
|
||||
|
||||
**Requisito:** AUD-OPS-01, dependência de AUD-PROFILE-31; P0, aberto. **Reprodução:** guarda identifica `desk_restore_20261009_174726_test:55441`; executar `tools/portal-profile-installation-backup-qa.php`. **Esperado:** dump atual, restauro noutra base QA e comparação integral. **Observado:** `pg_dump` exit1 ao consultar `pg_roles`, pois não consegue abrir `global/2964`; nenhum novo restauro criado. **Causa identificada:** ficheiro de `pg_db_role_setting` ausente; inspeção READ ONLY identifica26 ficheiros partilhados ausentes no clusterQA em Temp. O processo que os removeu não foi identificado. **Mitigação:** originais/backups anteriores preservados;295 tabelas/34.902 linhas/31 sequências extraídas privadamente por leitura; readiness atual invalidada e erro do CLI controlado. **Correção pendente:** recuperar clusterQA preservando schema, IDs, relações, sequências e chave estável, conferir todos os dados/ficheiros e repetir backup/restauro antes de ensaios com escrita. Não declarar preservação NDJSON ou backup13:35 como restauro renovado. Nenhum `key:generate`, catálogo inventado, reinício do PostgreSQL ativo ou escrita ativa.
|
||||
|
||||
Referência técnica: [PostgreSQL17 — pg_db_role_setting](https://www.postgresql.org/docs/17/catalog-pg-db-role-setting.html), catálogo partilhado por todas as bases de um cluster. Diagnósticos completos e extrações permanecem privados.''')
|
||||
prepend('plano-ativacao.md', '''## Atualização após a prova visual do piloto
|
||||
|
||||
O piloto legado foi demonstrado no Chromium QA com Promotor QA21; C37/QA18, CRUD de oportunidades no dashboard e A/B completos continuam pendentes. Não fixar uma revisão candidata global apenas por esta prova. A identificação “InvesteMoz QA” é compilada somente no build QA; não transferir esse rótulo automaticamente para produção.
|
||||
|
||||
Preparação preservada: corrigir critérios globais→fixar revisão/hashes/configuração→conferir base ativa por leitura e renovar backup/restauro correspondente→código/bundle compatíveis com flags desligadas→030000→040000→09/190000 apenas se ausentes→importações privadas por prévia/hash/ClamD→divulgação por recurso/camada→disponibilização do percurso. Fonte editorial pública usa biblioteca; guardar contexto requer Balcão/09-190. Cartografia continua lote distinto.
|
||||
|
||||
Contagens do legado inalteradas: um artigo + uma imagem, três associações fundamentadas, zero oportunidades/geometrias criadas pelo artigo e zero contas/dossiers QA transferidos. Reversão: ocultar piloto/flag e usar bundle compatível; retirar divulgação em ação auditada preservando originais, versões, relações e histórico. Ensaio/restauro QA não substitui backup da base ativa/revisão candidata; nenhuma ativação autorizada.
|
||||
|
||||
C71 é agora pré-condição técnica: recuperar o cluster QA e renovar backup/restauro, retomar as escritas e repetir os percursos afetados; depois fixar candidata e preparar a prova específica do alvo ativo. O restauroQA anterior13:35 permanece histórico; readiness invalidada. A falha nova não autoriza alterar ou reiniciar a base ativa.''')
|
||||
|
||||
pilot = DOC / 'legado-piloto-qa.md'
|
||||
text = pilot.read_text(encoding='utf-8')
|
||||
text = text.replace('**Aceitação visual pendente:** `cua.getState()`', '**Histórico do ensaio inicial, anterior ao Chromium:** `cua.getState()`')
|
||||
text = text.replace('Promotor QA21 visual, A/B, workflow normativo integral', 'CRUD do dashboard com Promotor QA21, A/B, workflow normativo integral')
|
||||
pilot.write_text(text, encoding='utf-8')
|
||||
# Preserve older versions as history, with explicit precedence for the latest evidence.
|
||||
for filename in ['legado-piloto-qa.md', 'relatorio-executivo.md', 'backlog.md', 'casos-reproducao.md', 'plano-ativacao.md']:
|
||||
file = DOC / filename
|
||||
content = file.read_text(encoding='utf-8')
|
||||
content = content.replace('## Frente atual — legado agronegócio, exclusivamente em QA', '## Histórico — legado agronegócio antes do ensaio Chromium')
|
||||
content = content.replace('## Próximo lote — expansão do legado mantendo I0–I3/C37', '## Histórico do lote planeado antes do ensaio Chromium')
|
||||
content = content.replace('## Complemento legado — preparaçãoQA, sem autorização ativa', '## Histórico — complemento legado anterior ao ensaio Chromium')
|
||||
content = content.replace('**Navegador integrado indisponível; aceitação visual pendente**', '**Estado histórico, antes do ensaio Chromium: navegador integrado indisponível; aceitação visual então pendente**')
|
||||
# The remainder of these old blocks retains its original findings and counts.
|
||||
for a, b in {'às14:48': 'às 14:48', 'identifica26': 'identifica 26', 'cluster55441': 'cluster 55441', 'de13:35': 'de 13:35', 'ONLY:295': 'ONLY: 295', 'exit1': 'exit 1', 'identifica26': 'identifica 26', 'clusterQA': 'cluster QA', 'privadamente por leitura;295': 'privadamente por leitura; 295', 'leitura;295': 'leitura; 295', 'preservados;295': 'preservados; 295', 'leitura;26': 'leitura; 26', 'de catálogos partilhados ausentes no cluster55441': 'de catálogos partilhados ausentes no cluster 55441'}.items():
|
||||
content = content.replace(a, b)
|
||||
file.write_text(content, encoding='utf-8')
|
||||
sources = DOC / 'inventario-fontes.csv'
|
||||
records = list(csv.DictReader(sources.open(encoding='utf-8-sig', newline='')))
|
||||
for filename in ['legado-piloto-qa.md']:
|
||||
file = DOC / filename; data = file.read_bytes()
|
||||
record = next(r for r in records if r['Exemplar/caminho'].replace('\\', '/').endswith('/' + filename))
|
||||
old = record['SHA256']; new = hashlib.sha256(data).hexdigest()
|
||||
if old != new: record['Edição/versão identificada'] += ' | exemplar anterior SHA256=' + old
|
||||
edition = record['Edição/versão identificada']
|
||||
if ' | interface QA10/10/2026' not in edition:
|
||||
edition += ' | interface QA10/10/2026'
|
||||
record.update({'SHA256': new, 'Bytes': str(len(data)), 'Nota de leitura': 'Prova real Chromium QA; limites históricos/cartográficos/C37/A-B preservados, sem ativação.',
|
||||
'Edição/versão identificada': edition})
|
||||
with sources.open('w', encoding='utf-8-sig', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(records[0])); writer.writeheader(); writer.writerows(records)
|
||||
print(json.dumps(counts, ensure_ascii=True))
|
||||
@@ -0,0 +1,114 @@
|
||||
// Edit only the reported manual criteria in the sole matrix; preserve every other cell.
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const DOC = path.join(ROOT, 'docs/investmoz/v2/auditoria-portal');
|
||||
const RUNTIME = path.join(ROOT, 'storage/app/private/investmoz-v2/portal-audit/legacy-sector-reconciliation-20261010');
|
||||
const requireRuntime = createRequire(path.join(RUNTIME, 'package.json'));
|
||||
const { Workbook } = await import(pathToFileURL(requireRuntime.resolve('@oai/artifact-tool')).href);
|
||||
const sourcesOnly = process.argv.includes('--sources-only');
|
||||
const baseline = JSON.parse(await fs.readFile(path.join(RUNTIME, sourcesOnly ? 'csv-source-baseline.json' : 'csv-baselines.json'), 'utf8'));
|
||||
const reconciliation = JSON.parse(await fs.readFile(path.join(RUNTIME, 'reconciliation.json'), 'utf8'));
|
||||
const csvUpdates = sourcesOnly ? { 'inventario-fontes.csv': JSON.parse(await fs.readFile(path.join(RUNTIME, 'source-inventory-updates.json'), 'utf8')) }
|
||||
: reconciliation.csv_updates;
|
||||
const manualTag = '[Confirmação manual recebida 10/10/2026] ';
|
||||
const scope = {
|
||||
'AUD-LEG-03': ['leitura_publica', 'autenticacao', 'criar_ou_escolher_dossier', 'guardar_referencia', 'nova_sessao', 'recuperar_dossier', 'abrir_artigo'],
|
||||
'AUD-LEG-04': ['leitura_publica', 'autenticacao'],
|
||||
'AUD-LEG-06': ['criar_ou_escolher_dossier', 'guardar_referencia', 'nova_sessao', 'recuperar_dossier', 'abrir_artigo'],
|
||||
'AUD-LEG-07': ['guardar_referencia', 'recuperar_dossier', 'abrir_artigo'],
|
||||
};
|
||||
const evidence = 'Responsável confirmou leitura pública → autenticação → criar/escolher dossier → guardar referência → nova sessão → recuperar dossier e abrir artigo. Confirmação declarada, sem conta/perfil/dispositivo/data de execução identificados. Não comprova MFA específico, ensaios móveis, acessibilidade, outros perfis, deduplicação, C37/QA18 ou A/B. Ver legado-piloto-qa.md, confirmação manual.';
|
||||
const allowedColumns = ['Evidência funcional', 'Interface', 'Gravação', 'Recuperação', 'Resultado apresentado', 'Consolidação', 'Critérios e versões por origem (JSON)'];
|
||||
const outputs = [];
|
||||
|
||||
function append(original, value) {
|
||||
return original.includes(manualTag) ? original : original + ' | ' + manualTag + value;
|
||||
}
|
||||
function encodeCSV(rows) {
|
||||
const quote = value => /[",\r\n]/.test(value) ? '"' + value.replaceAll('"', '""') + '"' : value;
|
||||
return '\ufeff' + rows.map(row => row.map(quote).join(',')).join('\r\n') + '\r\n';
|
||||
}
|
||||
function matchesSource(value, literal) {
|
||||
// Imported ISO dates are typed by this runtime. Compare their instant for validation,
|
||||
// but serialize untouched fields from the original CSV so its exact offset survives.
|
||||
if (value instanceof Date) return value.getTime() === Date.parse(literal);
|
||||
return (value == null ? '' : String(value)) === literal;
|
||||
}
|
||||
|
||||
for (const filename of sourcesOnly ? ['inventario-fontes.csv'] : ['matriz-requisitos.csv', 'legado-inventario.csv', 'legado-conteudos-reconciliados.csv']) {
|
||||
const input = await fs.readFile(path.join(DOC, filename), 'utf8');
|
||||
const sourceRows = baseline[filename];
|
||||
const workbook = await Workbook.fromCSV(input.replace(/^\ufeff/, ''), { sheetName: 'Registos' });
|
||||
const sheet = workbook.worksheets.getItem('Registos');
|
||||
const fullRange = sheet.getRangeByIndexes(0, 0, sourceRows.length, sourceRows[0].length);
|
||||
const rows = fullRange.values;
|
||||
assert(rows.every((row, i) => row.every((value, c) => matchesSource(value, sourceRows[i][c]))), 'Imported data must match source values');
|
||||
const strings = sourceRows.map(row => [...row]);
|
||||
const headers = strings[0];
|
||||
const changes = [];
|
||||
for (let i = 1; i < strings.length; i++) {
|
||||
const original = strings[i];
|
||||
const updated = [...original];
|
||||
const id = filename === 'matriz-requisitos.csv' ? original[headers.indexOf('ID')] : null;
|
||||
if (id && scope[id]) {
|
||||
for (const column of allowedColumns.filter(c => c !== 'Critérios e versões por origem (JSON)')) {
|
||||
const index = headers.indexOf(column);
|
||||
updated[index] = append(original[index], evidence);
|
||||
}
|
||||
const index = headers.indexOf('Critérios e versões por origem (JSON)');
|
||||
const variants = JSON.parse(original[index]);
|
||||
for (const variant of variants) {
|
||||
variant.confirmacao_manual = { recebida_em: '2026-10-10', fonte: 'Mensagem do responsável nesta conversa',
|
||||
conta: 'não indicada', perfil: 'não indicado', dispositivo: 'não indicado',
|
||||
criterios: scope[id].map(criterio => ({ criterio, estado: 'confirmado manualmente', ambito: 'piloto agronegócio QA' })),
|
||||
exclusoes: ['móvel', 'acessibilidade', 'outros perfis', 'C37 conta18', 'cenários A/B', 'cobertura integral'] };
|
||||
}
|
||||
updated[index] = JSON.stringify(variants);
|
||||
} else if (filename !== 'matriz-requisitos.csv') {
|
||||
const prepared = csvUpdates[filename][i - 1];
|
||||
assert(prepared);
|
||||
for (let c = 0; c < headers.length; c++) {
|
||||
if (prepared[headers[c]] !== original[c]) {
|
||||
const allowed = filename === 'legado-inventario.csv' ? ['Destino proposto', 'Situação']
|
||||
: filename === 'inventario-fontes.csv' ? ['SHA256', 'Bytes', 'Nota de leitura', 'Edição/versão identificada'] : ['Situação'];
|
||||
assert(allowed.includes(headers[c]), 'Source identity, authors, dates, hashes and URL aliases are immutable');
|
||||
updated[c] = prepared[headers[c]];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(original) !== JSON.stringify(updated)) {
|
||||
sheet.getRangeByIndexes(i, 0, 1, headers.length).values = [updated];
|
||||
changes.push({ row: i + 1, id, columns: headers.filter((_, c) => original[c] !== updated[c]) });
|
||||
strings[i] = updated;
|
||||
}
|
||||
}
|
||||
workbook.recalculate();
|
||||
const observed = sheet.getRangeByIndexes(0, 0, sourceRows.length, headers.length).values;
|
||||
assert(observed.every((row, i) => row.every((value, c) => matchesSource(value, strings[i][c]))), 'Authored fields must match verified values');
|
||||
const final = strings;
|
||||
for (let i = 0; i < sourceRows.length; i++) {
|
||||
for (let c = 0; c < headers.length; c++) {
|
||||
if (sourceRows[i][c] === final[i][c]) continue;
|
||||
const id = filename === 'matriz-requisitos.csv' ? sourceRows[i][0] : null;
|
||||
assert(changes.some(change => change.row === i + 1 && change.columns.includes(headers[c])));
|
||||
if (id) assert(scope[id] && allowedColumns.includes(headers[c]));
|
||||
}
|
||||
}
|
||||
// CSV has no workbook layout, formulas or styles; serialize the verified cell strings
|
||||
// in the repository's established BOM/CRLF CSV format rather than adding an XLSX matrix.
|
||||
const output = encodeCSV(final);
|
||||
const temp = path.join(RUNTIME, filename + '.verified');
|
||||
await fs.writeFile(temp, output, 'utf8');
|
||||
outputs.push({ filename, temp, changes });
|
||||
}
|
||||
// Apply only after ALL three imported tables have passed exact preservation checks.
|
||||
for (const output of outputs) await fs.copyFile(output.temp, path.join(DOC, output.filename));
|
||||
await fs.writeFile(path.join(RUNTIME, sourcesOnly ? 'csv-source-update-proof.json' : 'csv-update-proof.json'), JSON.stringify({ database_access: false, changed: outputs.map(({ filename, changes }) => ({ filename, changes })) }, null, 2));
|
||||
console.log(JSON.stringify(sourcesOnly ? { source_metadata_rows_updated: outputs[0].changes.length, database_writes: 0 }
|
||||
: { matrix_rows_changed: outputs[0].changes.map(c => c.id), statuses_changed: 0,
|
||||
inventory_rows_reconciled: outputs[1].changes.length, article_versions_reconciled: outputs[2].changes.length, database_writes: 0 }));
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Polish the new report block only; never rewrite historical sources or the matrix."""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
fixes = {'sem substituirI0': 'mantendo I0', 'mesmosIDs': 'mesmos IDs', 'os50': 'os 50', 'nãoimportados': 'não importados',
|
||||
'GOVERNADOR-in.gif404': 'GOVERNADOR-in.gif (404)', 'outroslinks': 'outros links', 'comfundamentoou': 'com fundamento ou',
|
||||
'módulosQA': 'módulos QA', 'nohistóricoQA49': 'ausência de histórico QA49', 'provaHTTP': 'prova HTTP',
|
||||
'decisões/external/publicação': 'decisões externas/aprovação/publicação', 'compromissosSLA': 'compromissos SLA',
|
||||
'Plano global030000': 'Plano global 030000', '1artigo+1imagem': '1 artigo +1 imagem', '3associações': '3 associações',
|
||||
'0oportunidades ou geometrias criadaspeloartigo': '0 oportunidades ou geometrias criadas pelo artigo',
|
||||
'0contas/dossiersQAtransferidos': '0 contas/dossiers QA transferidos', 'noopssehash/ID': 'reutilização se hash/ID',
|
||||
'Reversão:ocultar': 'Reversão: ocultar', 'BackupQA': 'Backup QA', 'biblioteca existente; associação': 'biblioteca existente; associação',
|
||||
'sóaplicaQA': 'só aplica QA', 'mesmo comflagtrue': 'mesmo com flag true', 'publicadas/recuperadas': 'publicadas/recuperadas'}
|
||||
for name in ['backlog.md', 'relatorio-executivo.md', 'plano-ativacao.md', 'casos-reproducao.md']:
|
||||
path = DOC / name
|
||||
text = path.read_text(encoding='utf-8')
|
||||
begin = text.index('<!-- qa-legacy-20261010:begin -->')
|
||||
end = text.index('<!-- qa-legacy-20261010:end -->')
|
||||
block = text[begin:end]
|
||||
for source, replacement in fixes.items(): block = block.replace(source, replacement)
|
||||
text = text[:begin] + block + text[end:]
|
||||
path.write_text(text, encoding='utf-8')
|
||||
path = DOC / 'legado-piloto-qa.md'
|
||||
text = path.read_text(encoding='utf-8')
|
||||
if '## Verificação final deste lote' not in text:
|
||||
text += '''
|
||||
## Verificação final deste lote
|
||||
|
||||
Leitura ativa renovada em10/10/2026, com transação READ ONLY: zero documentos do legado, piloto indisponível e bundle ativo inalterado. Ausentes exatamente as extensões `2026_10_09_030000_extend_opportunity_resources`, `2026_10_10_040000_create_portal_profile_authorization` e `2026_10_09_190000_prepare_investor_path`. Os190000 fiscal(03/10) eBalcão-base(07/10) instalados não substituem09/190000. Recibo `legacy-active-readonly.json`.
|
||||
|
||||
Persistência: um documento por hash; publicação e revisões documentais auditadas; ClamAV1.5.4 registado nos dois originais. Dossier criado e dossier continuado têm uma referência e um evento cada; projeto independente conserva zero referências/eventos. `legacy-persistence-qa.json`.
|
||||
|
||||
Restauro QA renovado em10/10/2026: **295 tabelas e81 ficheiros, zero ausentes**, base de restauro distinta `profile_restore_20261010_133520_test:55441`. Ensaio QA, sem substituir backup da futura base ativa/candidata. Recibo `profile-installation-backup-qa.json`. Build QA, TypeScript e análise estática passaram; oito testes específicos da biblioteca/percurso passaram, além dos ensaios HTTP por critério. Candidata não fixada nem ativação autorizada.
|
||||
'''
|
||||
path.write_text(text, encoding='utf-8')
|
||||
sources = DOC / 'inventario-fontes.csv'
|
||||
rows = list(csv.DictReader(sources.open(encoding='utf-8-sig')))
|
||||
for row in rows:
|
||||
if row['Exemplar/caminho'].replace('\\', '/').endswith('/legado-piloto-qa.md'):
|
||||
data = path.read_bytes(); sha = hashlib.sha256(data).hexdigest()
|
||||
if row['SHA256'] != sha:
|
||||
row['Edição/versão identificada'] += ' | exemplar anterior SHA256=' + row['SHA256']
|
||||
row['SHA256'] = sha; row['Bytes'] = str(len(data))
|
||||
with sources.open('w', encoding='utf-8-sig', newline='') as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(rows[0])); w.writeheader(); w.writerows(rows)
|
||||
print(json.dumps({'reports_updated': 5, 'matrix_preserved': True}))
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Document a narrowly scoped user confirmation and sector preparation in the existing audit."""
|
||||
import collections
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
OUT = ROOT / 'storage/app/private/investmoz-v2/portal-audit/legacy-sector-reconciliation-20261010'
|
||||
csv.field_size_limit(5_000_000)
|
||||
r = json.loads((OUT / 'reconciliation.json').read_text(encoding='utf-8'))
|
||||
preview = json.loads((OUT / 'import-preview.json').read_text(encoding='utf-8'))
|
||||
content = json.loads((OUT / 'content-check-after.json').read_text(encoding='utf-8'))
|
||||
assert preview['exit_code'] == 0 and preview['transaction_read_only'] and preview['preview_items'] == 22
|
||||
assert content['result'] == 'passed' and not content['database_access'] and not content['interface_accepted']
|
||||
|
||||
|
||||
def section(filename, marker, value):
|
||||
file = DOC / filename
|
||||
text = file.read_text(encoding='utf-8')
|
||||
text = re.sub(r'\n*<!-- ' + marker + r':begin -->.*?<!-- ' + marker + r':end -->\n*', '\n\n', text, flags=re.S)
|
||||
first, rest = text.split('\n', 1)
|
||||
file.write_text(first + '\n\n<!-- ' + marker + ':begin -->\n' + value.strip() + '\n<!-- ' + marker + ':end -->\n\n' + rest.lstrip(), encoding='utf-8')
|
||||
|
||||
|
||||
manual = '''## Confirmação manual recebida do responsável — 10/10/2026
|
||||
|
||||
Confirmado nesta conversa: **leitura pública → autenticação → criar/escolher dossier → guardar referência → nova sessão → recuperar dossier e abrir artigo**. Conta, perfil, dispositivo e hora de execução não foram indicados; não os presumir. “Criar/escolher” conserva o relato do responsável, sem declarar que ambas as alternativas foram ensaiadas separadamente.
|
||||
|
||||
| Critério confirmado manualmente | Correspondência na matriz existente |
|
||||
| --- | --- |
|
||||
| Leitura pública e autenticação ao entrar no percurso privado | AUD-LEG-03 e AUD-LEG-04 |
|
||||
| Criar/escolher dossier e guardar referência | AUD-LEG-03, AUD-LEG-06; gravação/consumidor em AUD-LEG-07 |
|
||||
| Nova sessão, recuperação do dossier e abertura do artigo | AUD-LEG-03, AUD-LEG-06 e AUD-LEG-07 |
|
||||
|
||||
Só estes subcritérios receberam a confirmação manual. Nenhum estado global foi alterado, nenhuma API/persistência recebeu prova técnica adicional por inferência e nenhuma linha foi acrescentada à matriz. O relato manual fica separado das provas Chromium/API/READ ONLY anteriores.
|
||||
|
||||
**Continuam pendentes:** ensaios manuais móveis, acessibilidade, outros perfis e cobertura integral do legado. As observações automáticas anteriores de viewport/teclado/QA23 ficam preservadas como provas limitadas; não constituem essas aceitações manuais. C37 da conta18 e os cenários A/B completos não foram encerrados. A demonstração não renova backup/restauro nem resolve C71.
|
||||
'''
|
||||
rows = []
|
||||
for s in r['sector_summaries']:
|
||||
desc = {
|
||||
'energia-mineracao': 'Índice com ligações explícitas a Energia172679 e Mineração172674; preservar as três identidades e os setores existentes2/8/7.',
|
||||
'turismo': 'Artigo setorial e 11 artigos territoriais explicitamente ligados; categoria Turismo preservada, sem inferir geometrias.',
|
||||
'infraestruturas': 'Artigo integral; C72 removia um título h1. Normalização h1→h2 preparada e conferida sobre o original.',
|
||||
'mercado-financeiro': 'Corpo vazio no exemplar atual. Arquivar metadados/original; não inventar texto nem substituir por Fintech.'
|
||||
}[s['slug']]
|
||||
rows.append(f"| [{s['title']}]({s['url']}) | {s['legacy_id']} / setor QA{s['master_sector']['id']} (`{s['master_sector']['slug']}`) | {desc} |")
|
||||
sectors = '''## Reconciliação dos quatro restantes setores — preparação, sem importação
|
||||
|
||||
As 92 entradas do inventário existente foram conferidas antes da seleção. Os quatro artigos de entrada foram novamente recolhidos por leitura anónima: HTTP200 e corpo/data de modificação iguais ao exemplar inicial. Os artigos descendentes e imagens usam os snapshots já inventariados, com hash conferido. Sitemap misto, paginação e restante acervo continuam abertos.
|
||||
|
||||
| Fonte legada | Identidade e cadastro existente | Conteúdo e lacuna concreta |
|
||||
| --- | --- | --- |
|
||||
''' + '\n'.join(rows) + '''
|
||||
|
||||
**Lote privado preparado: 17 versões de artigos + 5 imagens = 22 documentos.** Inclui quatro entradas setoriais, dois artigos de Energia/Mineração e 11 artigos territoriais de Turismo. A imagem de fundo genérica é um único exemplar por bytes, referenciado por vários artigos; não representa a fotografia de cada território. URLs com/sem barra são conservados como aliases do mesmo ID/versão/hash. Autoria, publicação, alteração, classificação original e HTML ficam preservados.
|
||||
|
||||
Os setores foram confrontados com a preservação READ ONLY da QA de10/10/2026 às14:54:19UTC, não com a base ativa. Correspondência2/3/4/6 para as entradas setoriais;8/7 para Energia/Mineração. Nenhum setor, identidade, artigo ou oportunidade foi criado na base. Alterações do corpo com a mesma identidade/versão deverão gerar conflito, não substituição silenciosa.
|
||||
|
||||
**Prévia real do importador existente:** 22 candidatos, zero duplicados por bytes e zero conflitos de identidade encontrados; transação READ ONLY no alvo `desk_restore_20261009_174726_test:55441`. CLI guardado, sem `--apply`, sem contas ou privilégios escolhidos. Não é aplicação/publicação autenticada. Nenhum scan novo foi executado ou declarado limpo; os scans do piloto não se estendem a este lote. C71 impede a aplicação enquanto faltar integridade/backup/restauro QA.
|
||||
|
||||
Reutilização concreta: biblioteca `documents`/`portal_resources`, importador `documents:import-batch`, verificador de binários, `portal_resource_links`, `LegacyContent`, controlador e componente `portal/legacy`, referências cifradas/eventos do dossier. Rotas de leitura/entrada, identidade do artigo e rótulos do componente ainda estão fixos em agronegócio: parametrizar esses pontos antes de disponibilizar outras páginas. As URLs legadas não são rotas QA disponíveis por estarem nesta tabela. Não criar um cadastro de conteúdo paralelo.
|
||||
|
||||
**C72 — código preparado:** o sanitizador removia o texto do h1 interno de Infraestruturas. A correção transforma-o em h2 e aplica a limpeza normal dos seus filhos/atributos. Os 17 originais conservaram hash; transformação e remoção de markup ativo verificadas sem bootstrap de aplicação ou acesso à base. Resultado não equivale a apresentação ou percurso dos novos setores demonstrado em QA.
|
||||
|
||||
Próximo incremento, após recuperar C71: prévia renovada no alvo e aplicação como rascunhos privados → ClamD por ficheiro → metadados/relações por citação e divulgação separada → parametrizar adaptador/componente e URLs preservadas → ensaios públicos e de dossier por setor. Mercado Financeiro mantém corpo vazio visível/identificado até existir conteúdo real; a consulta/cadastro do exemplar não exige uma relação a módulo/regra. Nenhuma importação ativa ou cálculo fiscal é habilitado.
|
||||
|
||||
Provas privadas: `legacy-sector-reconciliation-20261010/reconciliation.json`, `import-preview.json`, `content-check-before.json`, `content-check-after.json`; manifesto de staging `legacy-setores-20261010/manifest.json`. Inventários únicos atualizados: `legado-inventario.csv` e `legado-conteudos-reconciliados.csv`. Permanecem92 URLs/50 versões de artigos;22 entradas reconciliadas e17 versões preparadas neste incremento, sem omitir as restantes.
|
||||
'''
|
||||
section('legado-piloto-qa.md', 'legacy-manual-sectors-20261010', manual + '\n' + sectors)
|
||||
short = '''## Confirmação manual e reconciliação setorial — 10/10/2026
|
||||
|
||||
Responsável confirmou leitura pública → autenticação → criar/escolher dossier → guardar referência → nova sessão → recuperar dossier e abrir artigo. AUD-LEG-03/04/06/07 receberam apenas os subcritérios correspondentes; estados e contagens da matriz inalterados. Conta/perfil/dispositivo não indicados. Móvel, acessibilidade, outros perfis, cobertura integral, C37/QA18 e A/B continuam pendentes.
|
||||
|
||||
Quatro setores reconciliados: Energia & Mineração (índice + dois artigos), Turismo (artigo +11 referências territoriais), Infraestruturas (título corrigido em código) e Mercado Financeiro (corpo vazio preservado). **17 artigos +5 imagens** preparados; prévia CLI em transação READ ONLY:22 candidatos,0 duplicados,0 conflitos; nenhuma aplicação/scan/publicação nova. C72 corrigido na transformação dos originais, sem prova visual dos novos setores. C71 continua P0: recuperar integridade/backup/restauro antes de aplicação QA. Biblioteca/entidades/componentes existentes reutilizados; rotas dos restantes setores ainda por parametrizar. Ver `legado-piloto-qa.md` e inventários existentes.
|
||||
'''
|
||||
section('relatorio-executivo.md', 'legacy-manual-sectors-20261010', short)
|
||||
section('backlog.md', 'legacy-manual-sectors-20261010', short + '''
|
||||
|
||||
| Prioridade | Próxima ação | Aceitação exigida |
|
||||
| --- | --- | --- |
|
||||
| P0 / C71 | Recuperar cluster QA preservando dados e renovar backup/restauro. | Conteúdos/schema/sequências/ficheiros conferidos; guarda, chave e percursos recuperados. |
|
||||
| P1 / LEG01/05/06/07 | Renovar prévia dos22 exemplares, importar como rascunhos privados após C71, scan e parametrizar leitura/contexto por identidade. | Sem duplicações; fontes/URLs preservadas; publicação separada; origem/referência recuperadas por setor. |
|
||||
| P1 / LEG02/07 / C72 | Verificar o título preservado de Infraestruturas na página real após importação. | Conteúdo integral legível; original/hash imutáveis; markup ativo removido. Verificação de transformação já passada. |
|
||||
| P1 / Mercado Financeiro | Manter corpo vazio identificado e procurar no acervo um exemplar/conteúdo real. | Sem conteúdo inventado ou Fintech usado como substituto silencioso. |
|
||||
| P1 / aceitação pendente | Ensaios móveis, acessibilidade e outros perfis, C37/QA18, A/B e restante acervo. | Evidência própria por percurso/perfil; relato manual do piloto não os encerra. |
|
||||
''')
|
||||
section('casos-reproducao.md', 'legacy-sector-heading-20261010', '''## C72 — título original removido na projeção de Infraestruturas
|
||||
|
||||
**Requisitos:** AUD-LEG-02/07; P1 na expansão. **Reprodução:** aplicar `LegacyContent::sanitize()` ao HTML original do post171992, através de `tools/legacy-sector-content-check.php before`. **Esperado:** título “2. Sectores Prioritários de Investimento” preservado sem introduzir segundo h1 de página. **Observado:** título ausente; os restantes16 exemplares do lote não apresentavam essa perda. **Causa:** lista de apresentação não admitia h1 e removia o elemento com todos os filhos. **Correção preparada:** normalizar h1 interno para h2 e manter limpeza de atributos/filhos. **Prova:** verificação after sobre17 originais, hashes preservados, título presente e markup ativo removido; nenhum acesso à base. **Pendente:** importar o exemplar privado depois de C71, divulgar separadamente e demonstrar a página real. Não atualizar outros critérios da matriz com esta prova de transformação, nem declarar o setor publicado. C37/QA18 continua independente.
|
||||
''')
|
||||
section('plano-ativacao.md', 'legacy-manual-sectors-20261010', '''## Complemento após confirmação manual — preparação QA
|
||||
|
||||
O relato manual do piloto confirma somente a sequência registada em AUD-LEG-03/04/06/07. C37/QA18, A/B, outros perfis, móvel/acessibilidade e cobertura integral permanecem pendentes. C71 continua a impedir novas aplicações QA e a fixação de candidata.
|
||||
|
||||
O lote seguinte tem previsão de **17 artigos +5 imagens**, biblioteca existente e zero oportunidades/territórios/contas criados. A prévia READ ONLY encontrou22 candidatos novos; reconfirmar contagens/identidades no alvo após recuperação. Arquivo do exemplar vazio de Mercado Financeiro não significa artigo educativo completo. Relações do Turismo são textuais/editoriais; não importam geometrias.
|
||||
|
||||
Sequência global030000 →040000 →09/190000 preservada, sem repetir instalações. Após recuperar QA: aplicação privada/scan, parametrização dos consumidores e divulgação separada; depois ensaios específicos. Ativação futura requer revisão candidata, prova da base ativa por leitura e backup/restauro próprio, além da autorização separada. Reversão conserva originais, versões, referências e eventos; retirar divulgação/desabilitar disponibilização não elimina fontes dos dossiers. Nenhuma aplicação ativa nesta frente.
|
||||
''')
|
||||
|
||||
# Prepare source-inventory metadata; its CSV is authored by the same JS pipeline.
|
||||
sources_path = DOC / 'inventario-fontes.csv'
|
||||
sources = list(csv.DictReader(sources_path.open(encoding='utf-8-sig', newline='')))
|
||||
for name in ['legado-piloto-qa.md', 'legado-inventario.csv', 'legado-conteudos-reconciliados.csv']:
|
||||
file = DOC / name
|
||||
data = file.read_bytes()
|
||||
row = next(row for row in sources if row['Exemplar/caminho'].replace('\\', '/').endswith('/' + name))
|
||||
old, new = row['SHA256'], hashlib.sha256(data).hexdigest()
|
||||
if old != new:
|
||||
row['Edição/versão identificada'] += ' | exemplar anterior SHA256=' + old
|
||||
row.update({'SHA256': new, 'Bytes': str(len(data)), 'Nota de leitura': 'Confirmação manual limitada e reconciliação dos quatro setores;22 candidatos privados, sem importação/publicação; C71/C37/A-B/móvel/acessibilidade/outros perfis preservados.'})
|
||||
(OUT / 'source-inventory-updates.json').write_text(json.dumps(sources, ensure_ascii=False), encoding='utf-8')
|
||||
(OUT / 'csv-source-baseline.json').write_text(json.dumps({'inventario-fontes.csv': list(csv.reader(sources_path.open(encoding='utf-8-sig', newline='')))}, ensure_ascii=False), encoding='utf-8')
|
||||
print(json.dumps({'manual_confirmed_requirements': ['AUD-LEG-03', 'AUD-LEG-04', 'AUD-LEG-06', 'AUD-LEG-07'], 'statuses_changed': 0, 'database_writes': 0, 'sectors_reconciled': 4, 'new_imports': 0}))
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Extend the sole audit matrix; preserve every previous source/criterion/state."""
|
||||
import collections
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
csv.field_size_limit(5000000)
|
||||
matrix = DOC / 'matriz-requisitos.csv'
|
||||
rows = list(csv.DictReader(matrix.open(encoding='utf-8-sig')))
|
||||
headers = list(rows[0])
|
||||
protected = ['ID', 'Pedido/trecho', 'Fonte/ocorrências', 'Estado', 'Critério de aceitação', 'Versões divergentes', 'IDs das linhas de origem', 'Quantidade de linhas de origem', 'Critérios e versões por origem (JSON)']
|
||||
before = {r['ID']: {k: r[k] for k in protected} for r in rows if not r['ID'].startswith('AUD-LEG-')}
|
||||
tag = '[Legado QA 10/10/2026] '
|
||||
evidence = 'legado-piloto-qa.md; legacy-content-http-qa.json: recursos6/5 públicos, hashes verificados, pesquisa, login/MFA21, criação/continuação, logout/nova sessão, recuperação, replay único e isolamento. Interface não observada.'
|
||||
items = [
|
||||
('01', 'Reconciliar páginas, imagens, anexos, categorias e URLs do legado com biblioteca/importações e auditoria', '92 URLs recolhidos,50 versões de artigos e2.826 referências inventariados;30.170 entradas de sitemap de outro domínio preservadas. Dois recursos únicos importados/repetição sem duplicação.', 'Cobertura histórica/paginação/destinos externos e restantes versões por reconciliar. Imagem histórica404 identificada.', 'Cada conteúdo/versão tem identidade, origem, autoria/datas conhecidas, hash, duplicados/conflitos e destino; inventário cobre as fontes acessíveis, sem omissão das não importadas.'),
|
||||
('02', 'Adaptar cores, tipografia, imagens e padrões reais do legado aos componentes existentes, com acessibilidade e móvel', 'CSS/chunkwelcome e fotografia reais recuperados; Noto Sans/Serif locais, paleta vermelho/verde/fundo claro, cartões e foco/labels preparados no buildQA.', 'Render móvel/desktop, teclado e contraste efetivo no navegador pendentes.', 'Comparar com referências reais e demonstrar leitura/ações em desktop/móvel/teclado; sem imagens quebradas ou controlo inacessível.'),
|
||||
('03', 'Piloto página de agronegócio → artigo → localizações/oportunidades → iniciar/continuar ideia no dossier', 'Rotas públicas e fontes da biblioteca; uma oportunidade publicada no setor; links da carteira/mapas; criação e continuação com referência ao artigo no payload cifrado e evento existentes.', 'Percurso visual completo pendente; seis referências territoriais não delimitadas/associadas por inferência.', 'Percorrer as ações na interface, guardar, recuperar noutra sessão, continuar e voltar ao conteúdo; dados/contextos separados e próxima ação existente.'),
|
||||
('04', 'Consulta pública sem login; autenticação apenas para guardar/acompanhar dados pessoais', 'Artigo, imagem, biblioteca e pesquisa respondem sem login; início/associação ao dossier requer sessão verificada. Outra conta403, contexto privado ausente em leitura pública/alheia.', 'Verificação visual dos pontos de autenticação e sessão pendente; C37 permanece aberto.', 'Leitor anónimo consulta tudo o que foi autorizado; apenas gravação privada pede autenticação; nenhum dado do dossier vaza para anónimos/outras contas.'),
|
||||
('05', 'Fonte única associada a setores, territórios e etapas com fundamento; artigo não vira oportunidade concreta', 'Documento único6 e imagem5; três links com fundamento: tema, thumbnail e apoio editorialS01. Relações de oportunidades pelo setor já cadastrado; zero oportunidade ou geometria criada pelo artigo.', 'Correspondências territoriais concretas e ampliação por etapa/setor ainda pendentes; não inventar ligações.', 'Mesma fonte/versionamento reutilizados; relações têm citação/âmbito; cadastros reais e artigos distintos; ausência de relação é apresentada explicitamente.'),
|
||||
('06', 'Preservar contexto ao entrar/regressar ao percurso, URLs existentes e correspondências de migração', 'Mantido /agronegocio; /explorar/agronegocio novo. Referência comID/hash/versão/ator/data e retorno autorizado ao mesmo dossier; replay sem duplicação; cinco fases/19etapas conservadas.', 'Mapa final de migração de outros URLs e demonstração visual de retorno pendentes.', 'URL antigo recupera a mesma fonte; login/retorno preservam a escolha; nova sessão recupera; nenhuma mistura entre projetos nem conversão de roteiro legado.'),
|
||||
('07', 'Verificar imagens, anexos, links, pesquisa, permissões e duplicações, registando aceitação visual separadamente', 'Dois originais com ClamD real/hash; links dos consumidores200; pesquisahit/miss; permissões; repetição do importador sem novas linhas e associação idempotente.', 'Visual pendente; anexos/referências de restantes conteúdos e erro404 do legado por resolver individualmente.', 'Cada consumidor apresenta ficheiro/link/resultado íntegro; importação/pesquisa/recuperação/permissões verificadas e resultados visuais registados por ação.')
|
||||
]
|
||||
rows = [r for r in rows if not r['ID'].startswith('AUD-LEG-')]
|
||||
for suffix, request, implementation, gap, acceptance in items:
|
||||
ident = 'AUD-LEG-' + suffix
|
||||
origin = 'USR-LEG-20261010-' + suffix
|
||||
row = {k: '' for k in headers}
|
||||
row.update({'ID': ident, 'Pedido/trecho': request, 'Fonte/ocorrências': 'Pedido direto do responsável,10/10/2026, integração do legado emQA §' + str(int(suffix)) + '; https://investemoz.tempo.co.mz/; legado-piloto-qa.md',
|
||||
'Módulo': 'biblioteca / conteúdo público / percurso do investidor', 'Rotas': '/explorar/agronegocio; /agronegocio; /documentos/6; /investimentos/a-partir-de/agronegocio; /investimentos/{id}/conteudos',
|
||||
'Implementação encontrada': implementation, 'Disponibilidade ativa': 'Não disponibilizado; guarda QA/flagfalse. Nenhuma escrita ou divulgação ativa autorizada.',
|
||||
'Evidência funcional': evidence, 'Estado': 'parcial', 'Lacuna': gap, 'Dependência': 'Biblioteca/documentos existentes; Balcão+190000 para referência ao dossier; navegador para aceitação visual. Perfis/SLA são decisões independentes.',
|
||||
'Critério de aceitação': acceptance, 'Prioridade': 'P1', 'Versões divergentes': 'Legado post171985 modificado2026-04-20; categoria Oportunidades preservada como metadado, entidade guia. Metadados/sitemapInfopublica e área de investimento distintos; não remapear por nome.',
|
||||
'IDs das linhas de origem': origin, 'Quantidade de linhas de origem': '1',
|
||||
'Critérios e versões por origem (JSON)': json.dumps([{'id': origin, 'pedido': request, 'fontes': ['Pedido direto10/10/2026 §' + str(int(suffix))], 'aceitacao': acceptance, 'estado': 'parcial', 'variantes': 'IntegraçãoprogressivaQA, sem substituição de pendências anteriores', 'modulo': 'legado/biblioteca/percurso'}], ensure_ascii=False),
|
||||
'Interface': 'React preparado e buildQA; dados Inertia recuperados. Navegador indisponível; roteiro manual em legado-piloto-qa.md. Aceitação visual pendente.',
|
||||
'API': 'GET públicos e escritas autenticadas no servidor18097, alvo real55441 confirmado; publicação separada do importador.',
|
||||
'Gravação': 'Dois recursos na biblioteca existente e referências no payload cifrado do dossier; sem nova tabela/cadastro paralelo.',
|
||||
'Recuperação': 'Logout/nova sessão FortifyMFA21: fonte/versão/hashes recuperados; outra conta negada e projeto independente sem referência.',
|
||||
'Resultado apresentado': 'Dados retornam ao artigo/dossier/consumidores; render/cliques ainda não observados.',
|
||||
'Consolidação': 'Novo pedido verificável incorporado à matriz existente; não duplica nem encerra requisitos históricos.'})
|
||||
rows.append(row)
|
||||
for row in rows:
|
||||
if row['ID'] in ['AUD-LIB-01', 'AUD-LIB-02', 'AUD-DOC-01']:
|
||||
for key, value in {'Evidência funcional': evidence, 'Lacuna': 'Legado:50 versões observadas e sitemap30.170 entradas inventariados; cobertura histórica não encerrada. Visual/C37 pendentes.'}.items():
|
||||
if tag not in row[key]: row[key] += ' | ' + tag + value
|
||||
if row['ID'] in ['AUD-OP-04', 'AUD-PROFILE-28']:
|
||||
modular = '[Experiência modular QA 10/10/2026] '
|
||||
if modular not in row['Evidência funcional']:
|
||||
row['Evidência funcional'] += ' | ' + modular + 'QA42–48:sete modalidades dos quatro módulos percorrem rascunho→submissão→correção→revisão/gates→aprovação→divulgação restrita e nova sessãoMFA. QA49 entidade semexercícios com declaração/projeções identificadas. Recibos opportunity-modular-http-qa.json e opportunity-history-http-qa.json; C67 projeção de resumo corrigida. Extensões explicitamenteQA; legados36–39 preservados. Visual, materialidade, Response normativo e aprovação funcional/compromissosSLA pendentes; confirmação do legado não aprova esses pontos.'
|
||||
assert before == {r['ID']: {k: r[k] for k in protected} for r in rows if not r['ID'].startswith('AUD-LEG-')}
|
||||
assert len({r['ID'] for r in rows}) == len(rows)
|
||||
with matrix.open('w', encoding='utf-8-sig', newline='') as f:
|
||||
w = csv.DictWriter(f, fieldnames=headers); w.writeheader(); w.writerows(rows)
|
||||
counts = {'requirements': len(rows), 'matrix_rows': len(rows), 'source_occurrences': sum(int(r['Quantidade de linhas de origem']) for r in rows), 'states': dict(collections.Counter(r['Estado'] for r in rows))}
|
||||
(PRIVATE / 'legacy-audit-update-qa.json').write_text(json.dumps(counts, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
marker = 'qa-legacy-20261010'
|
||||
def prepend(name, content):
|
||||
import re
|
||||
path = DOC / name
|
||||
text = path.read_text(encoding='utf-8-sig')
|
||||
text = re.sub(r'<!-- ' + marker + r':begin -->.*?<!-- ' + marker + r':end -->\s*', '', text, flags=re.S)
|
||||
first, _, rest = text.partition('\n')
|
||||
replacements = {'exclusivamenteQA': 'exclusivamente em QA', 'um sitemap de outro domínio': 'um sitemap de outro domínio', 'Perfismodulares/SLA': 'Perfis modulares/SLA',
|
||||
'Legado:50': 'Legado: 50', 'ClamD real nos dois': 'ClamD real nos dois', 'imagemhistórica404': 'imagem histórica 404',
|
||||
'I0–I3/C37': 'I0–I3/C37', 'comPromotorQA21': 'com Promotor QA21', 'LEG07': 'LEG07', 'porperfil': 'por perfil',
|
||||
'links não recolhidos antesde': 'links não recolhidos antes de', 'imagemlegada': 'imagem legada',
|
||||
'original recuperado comfundamentoou': 'original recuperado com fundamento ou', 'artigosconsumidores': 'artigos consumidores',
|
||||
'visual/materialidade/Response/aprovaçãoformal': 'visual/materialidade/Response/aprovação formal',
|
||||
'50 exemplares': '50 exemplares', 'perfilmodular': 'perfil modular', 'folgaQA': 'folga QA',
|
||||
'173lacunasgeo': '173 lacunas geo', '(Mandie/Guroambíguo)': '(Mandie/Guro ambíguo)', '030000→040000→190000conservado': '030000 → 040000 → 190000 conservado',
|
||||
'recursoslegado/cartografia/divulgação': 'recursos do legado/cartografia/divulgação', 'revisãocandidata/config/bundle': 'revisão candidata/config/bundle',
|
||||
'backup/restauroda baseativa correspondente': 'backup/restauro da base ativa correspondente', 'rasculhosprivados': 'rascunhos privados',
|
||||
'hash/idjáexistirem': 'hash/ID já existirem', 'conflitosaguardamrevisão': 'conflitos aguardam revisão', 'Cartografiaéoutrolote, nãoautopublicado': 'Cartografia é outro lote, sem publicação automática',
|
||||
'ocultarflag/bundlecompatível': 'ocultar a flag/usar bundle compatível', 'originais, versões, fontes dosdossiers': 'originais, versões, fontes dos dossiers',
|
||||
'baseativa+candidata': 'base ativa + candidata', 'Decisõesperfis/SLA,C37,Promotor21visual,A/Bcontinuamabertas': 'Decisões de perfis/SLA, C37, Promotor21 visual e A/B continuam abertas',
|
||||
'ClamD real/hash': 'ClamD real/hash', 'doexemplar doartigoJSONno importadorexistente': 'do exemplar do artigo JSON no importador existente',
|
||||
'MIMEapplication/json': 'MIME application/json', 'uploadJSONguardadoQA': 'upload JSON guardado em QA',
|
||||
'originalpreservado': 'original preservado', 'hashverificado': 'hash verificado', 'publicaçãoautenticada': 'publicação autenticada',
|
||||
'Doisrecursos; repetição semduplicação': 'Dois recursos; repetição sem duplicação', 'imagem original; observado404': 'imagem original; observado 404',
|
||||
'Identificado noinventário': 'Identificado no inventário', 'PNGdoagronegócio': 'PNG do agronegócio', 'emartigosconsumidores dessaimagem': 'em artigos consumidores dessa imagem',
|
||||
'summary`de500carateres': 'summary` de 500 carateres', 'a499+reticência': 'a 499 + reticência',
|
||||
'depoisQA42–48divulgadas/recuperadas noutraMFA. Visualpendente': 'depois QA42–48 divulgadas/recuperadas noutra sessão MFA. Visual pendente',
|
||||
'conforme autorizaçãoexistente; gestorQA14executou': 'conforme autorização existente; gestor QA14 executou',
|
||||
'C37nãofoiretestadonemencerrado': 'C37 não foi retestado nem encerrado'}
|
||||
for source, replacement in replacements.items(): content = content.replace(source, replacement)
|
||||
path.write_text(first + '\n\n<!-- ' + marker + ':begin -->\n' + content + '\n<!-- ' + marker + ':end -->\n\n' + rest.lstrip(), encoding='utf-8')
|
||||
|
||||
summary = f'''## Frente atual — legado agronegócio, exclusivamenteQA
|
||||
|
||||
Piloto: **http://127.0.0.1:18097/explorar/agronegocio → /agronegocio → /investimentos/a-partir-de/agronegocio**. Artigo6 e imagem5 reutilizam biblioteca; leitura/pesquisa/imagem públicas, gravação privada autenticada. HTTP/persistência demonstrados: criação e continuação, logout/novaMFA21, recuperação, replay, isolamento e retorno com contexto. ClamD real nos dois originais. Uma oportunidade publicada no setor permanece independente; nenhuma oportunidade/geo inferida do artigo.
|
||||
|
||||
Inventário: **92URLs**, **50 versões de artigos**, **2.826 referências**, **30.170 entradas anunciadas num sitemap de outro domínio**, sem afirmar recolha integral. Dois recursos importados; repetição sem duplicação. Fonte/autoria/datas/versão/hash e diferenças preservadas. C68:imagemhistórica404. Cobertura documental histórica permanece aberta.
|
||||
|
||||
Mesma matriz: **{counts['requirements']} requisitos únicos = {counts['matrix_rows']} linhas lógicas; {counts['source_occurrences']} ocorrências de origem**. Estados: ''' + '; '.join(f'{n} {state}' for state, n in counts['states'].items()) + '''. Sete pedidos novosLEG01–07 incorporados, todos parciais. Nenhum critério anterior foi encerrado por esta frente.
|
||||
|
||||
Referências visuais reais: CSS/chunkwelcome, fotografia original, Noto Sans/Serif locais no buildQA, paleta/cartões adaptados. **Navegador integrado indisponível; aceitação visual pendente**, com roteiro executável. C37/QA18, Promotor21 visual e A/B continuam abertos. Perfismodulares/SLA têm decisões independentes; a confirmação do legado não os aprova.
|
||||
|
||||
Relatório e critérios: [legado-piloto-qa.md](legado-piloto-qa.md); inventários `legado-inventario.csv`, `legado-conteudos-reconciliados.csv`, `legado-referencias.csv`, `legado-sitemap-anunciado.csv`. Recibos privados sanitizados por percurso. Ativo não alterado; candidata ainda não fixada. Blocos anteriores são evidências históricas datadas, não conclusão global atual. Merece distinção o ensaio modularQA42–49 já demonstrado porHTTP, comvisual/materialidade/Response/aprovaçãoformal pendentes.'''
|
||||
prepend('relatorio-executivo.md', summary)
|
||||
prepend('backlog.md', '''## Próximo lote — expansão do legado sem substituirI0–I3/C37
|
||||
|
||||
| Prioridade / requisito | Próxima ação concreta | Critério de fecho |
|
||||
| --- | --- | --- |
|
||||
| P1 / AUD-LEG-02/03/04/06/07 | Roteiro visual do piloto comPromotorQA21: leitura anónima, pesquisa, fonte, criação/continuação, nova sessão, retorno e móvel/teclado. | Ações observadas por perfil/consumidor; mesmosIDs/dados, recuperação/negações e resultado visível. C37 continua independente. |
|
||||
| P1 / AUD-LEG-01/05; LIB01/02; DOC01 | Reconciliar os50 exemplares observados, paginação/URLs externos e sitemap misto; importar apenas próximo lote com hash/versão/scan e divulgação separada. | Inventário/deduplicação/citações e destinos explícitos; sem omitir nãoimportados nem publicar automaticamente. |
|
||||
| P1 / AUD-LEG-03/05; GEO | Conferir seis nomes/âmbitos de corredores com cadastros/publicações existentes. | Relação territorial inequívoca com citação/IDs; geometria disponível distinguida de uso visual. Nada de inferir parcelas ou coberturas pelo nome. |
|
||||
| P1 / AUD-LEG-07 / C68 | Rever imagemlegada GOVERNADOR-in.gif404 e outroslinks não recolhidos antesde expandir esses artigos. | Original recuperado comfundamentoou ausência identificada; não declarar ficheiro inexistente como válido. |
|
||||
| P1 / OP04 / PROFILE28 | Continuar Response integral/materialidade e cerimónia funcional; quatro módulosQA42–48 e nohistóricoQA49 têm provaHTTP, não fecho visual/formal. | Critérios especializados por categoria; revisões afetadas, decisões/external/publicação distintas. Confirmação legado não aprova perfis/SLA. |
|
||||
|
||||
Países/origem, estatísticas,173lacunasgeo (Mandie/Guroambíguo), documentos por consumidor, recuperação e acessos/workflow restantes permanecem no backlog anterior. Próximo trabalho executável não depende de designar pessoas reais ou aprovar compromissosSLA. Plano global030000→040000→190000conservado; recursoslegado/cartografia/divulgação separados. Ver `legado-piloto-qa.md`.''')
|
||||
prepend('plano-ativacao.md', '''## Complemento legado — preparaçãoQA, sem autorização ativa
|
||||
|
||||
Preservar **030000→040000→190000**, verificar ledger/schema/dependências no alvo antesde aplicar; não repetir instalações. Leitura editorial usa a biblioteca existente; associação ao dossier exige Balcão+190000. Perfismodulares não são pré-condição da leitura pública.
|
||||
|
||||
Ordem deste complemento: demonstração visualQA/correções→revisãocandidata/config/bundle→backup/restauroda baseativa correspondente→upload/importação autenticados como rasculhosprivados→hash/duplicados/ClamD/metadados→associações fundamentadas→divulgação explícita por recurso→disponibilização pública/percurso. O CLI importador sóaplicaQA; a guarda atual do piloto impede ativo, mesmo comflagtrue. Habilitação ativa requer revisão/autorização própria.
|
||||
|
||||
Contagem prevista: **1artigo+1imagem**, **3associações**, **0oportunidades ou geometrias criadaspeloartigo**, **0contas/dossiersQAtransferidos**; noopssehash/idjáexistirem, conflitosaguardamrevisão. Cartografiaéoutrolote, nãoautopublicado. Reversão:ocultarflag/bundlecompatível e retirar divulgação auditada; preservar originais, versões, fontes dosdossiers e histórico. BackupQA não substitui prova da baseativa+candidata. Decisõesperfis/SLA,C37,Promotor21visual,A/Bcontinuamabertas.''')
|
||||
prepend('casos-reproducao.md', '''## Casos adicionais preservados — fontes e consumidor novo
|
||||
|
||||
- **C67 / OP04:** publicar a chamada de concessãoQA42 após aprovação. Esperado: projeção restrita persistida e legível. Observado inicial:HTTP503; rótulo de modalidade adicionado à síntese excedia coluna `summary`de500carateres. Causa no novo adaptador, não no middleware. Correção limita projeção a499+reticência e preserva texto canónico completo. Tentativa falhada arquivada; depoisQA42–48divulgadas/recuperadas noutraMFA. Visualpendente.
|
||||
- **C68 / LEG07:** GET público legado `/images/ANO-2017/JUNHO/21/GOVERNADOR-in.gif`. Esperado: imagem original; observado404. Identificado noinventário; não afeta PNGdoagronegócio. Falha permanece emartigosconsumidores dessaimagem; não inventar substituição ou autenticidade.
|
||||
- **C69 / LIB02 / LEG01:** prévia doexemplar doartigoJSONno importadorexistente. Esperado: cadastro privado, scan e adaptação segura; observado MIMEapplication/json não suportado. Lista doimportador reconciliada e uploadJSONguardadoQA; originalpreservado, apresentação sanitizada, hashverificado, publicaçãoautenticada separada. Doisrecursos; repetição semduplicação. Não alargar divulgação ativa.
|
||||
|
||||
Superutilizador27 recebeu403 em gestão documental, conforme autorizaçãoexistente; gestorQA14executou divulgação. Esse ensaio negativo comprova segregação e não é uma falha a contornar. C37nãofoiretestadonemencerrado.''')
|
||||
|
||||
proposal = DOC / 'propostas-perfis-modulares-sla.md'
|
||||
text = proposal.read_text(encoding='utf-8')
|
||||
text = text.replace('a decisão QA seguinte tem precedência nos pontos identificados.', 'o ensaio técnico QA seguinte identifica as recomendações adotadas experimentalmente, sem decisão formal de ativação.')
|
||||
text = text.replace('## Recomendações aceites para execução exclusivamente em QA — 10/10/2026', '## Recomendações ensaiadas tecnicamente em QA — decisões próprias pendentes')
|
||||
text = text.replace('Esta decisão permite implementação e ensaios sintéticos; não declara os módulos completos nem aprova ativação.', 'A implementação e os ensaios sintéticos decorrem da autorização QA existente. As recomendações não são compromissos operacionais aprovados; a confirmação posterior sobre o legado refere-se somente ao legado e não decide perfis/SLA.')
|
||||
proposal.write_text(text, encoding='utf-8')
|
||||
sources = DOC / 'inventario-fontes.csv'
|
||||
source_rows = list(csv.DictReader(sources.open(encoding='utf-8-sig')))
|
||||
for path in [proposal, DOC / 'legado-piloto-qa.md', DOC / 'legado-inventario.csv', DOC / 'legado-conteudos-reconciliados.csv', DOC / 'legado-referencias.csv', DOC / 'legado-sitemap-anunciado.csv']:
|
||||
data = path.read_bytes(); sha = hashlib.sha256(data).hexdigest()
|
||||
record = next((r for r in source_rows if r['Exemplar/caminho'].replace('\\', '/').endswith('/' + path.name)), None)
|
||||
if record is None:
|
||||
record = {k: '' for k in source_rows[0]}; source_rows.append(record)
|
||||
record['Exemplar/caminho'] = str(path)
|
||||
elif record['SHA256'] and record['SHA256'] != sha:
|
||||
record['Edição/versão identificada'] += ' | exemplar anterior SHA256=' + record['SHA256']
|
||||
record.update({'Bytes': str(len(data)), 'SHA256': sha, 'Tipo': 'reconciliação auditoriaQA', 'Nota de leitura': 'Legado inventariado/pilotoQA; cobertura histórica e decisões separadas explicitamente preservadas.', 'Disponibilidade': 'Relatório/inventário para consulta; não habilita importação/publicação ativa', 'Uso em cálculo': 'Nenhum cálculo ativado', 'Situação jurídica/documental': 'Documento de programação/reconciliação, não fonte oficial', 'Edição/versão identificada': record['Edição/versão identificada'] + ' | atualizaçãoQA10/10/2026'})
|
||||
with sources.open('w', encoding='utf-8-sig', newline='') as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(source_rows[0])); w.writeheader(); w.writerows(source_rows)
|
||||
print(json.dumps(counts, ensure_ascii=True))
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Reconcile C85 and the confirmed QA18 logout into the existing audit only.
|
||||
|
||||
CSV cells are prepared as JSON and authored by the existing Artifact Tool writer.
|
||||
"""
|
||||
import collections
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
csv.field_size_limit(16000000)
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
OUT = PRIVATE / 'logout-finalize-20261010'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).isoformat()
|
||||
tag = '[C85/logout QA 10/10/2026] '
|
||||
proof = json.loads((PRIVATE / 'portal-logout-chromium-20261010.json').read_text())
|
||||
assert proof['result'] == 'passed' and all(proof['checks'].values())
|
||||
assert proof['active_writes'] == 0 and proof['checks']['no_qa18_mfa_consumed']
|
||||
assert len(proof['presentation']) == 6 and len(proof['logout']) == 2
|
||||
|
||||
# Retain only non-secret trace fields for the manually confirmed QA18 logout.
|
||||
traces = []
|
||||
for file in PRIVATE.glob('auth-session-trace-qa*.jsonl'):
|
||||
for attempt in range(10):
|
||||
try:
|
||||
lines = file.read_text(encoding='utf-8').splitlines()
|
||||
break
|
||||
except PermissionError:
|
||||
time.sleep(.1)
|
||||
for line in lines:
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if row.get('at_utc', '') < '2026-10-10T18:09:00' or row.get('path') not in ['/logout', '/login', '/two-factor-challenge', '/investimentos']:
|
||||
continue
|
||||
phases = {name: {key: (row.get(name) or {}).get(key) for key in ['authenticated_user', 'pending_mfa_user', 'stored_web_user']} for name in ['loaded', 'after', 'persisted']}
|
||||
if not any(18 in state.values() for state in phases.values()):
|
||||
continue
|
||||
traces.append({key: row.get(key) for key in ['at_utc', 'method', 'path', 'status', 'redirect']} | phases)
|
||||
traces.sort(key=lambda row: row['at_utc'])
|
||||
logout = next(row for row in reversed(traces) if row['method'] == 'POST' and row['path'] == '/logout' and row['loaded']['authenticated_user'] == 18)
|
||||
assert logout['status'] == 302 and logout['redirect']['path'] == '/'
|
||||
assert logout['after']['authenticated_user'] is None and logout['persisted']['stored_web_user'] is None
|
||||
post_logout_mfa = any(row['at_utc'] > logout['at_utc'] and row['path'] == '/two-factor-challenge' and row['method'] == 'POST' and row['after']['authenticated_user'] == 18 for row in traces)
|
||||
manual = {'recorded_at': stamp, 'source': 'Responsável: «já tem o botão e fiz o logout»; rastreio sanitizado QA18097',
|
||||
'target': proof['target'], 'actor_id': 18, 'button_and_logout_confirmed_by_user': True,
|
||||
'logout_at_utc': logout['at_utc'], 'session_authenticated_after_logout': False,
|
||||
'manual_post_logout_mfa_observed': post_logout_mfa, 'manual_f5_confirmed': False,
|
||||
'events': traces, 'active_writes': 0, 'secrets_recorded': False}
|
||||
(OUT / 'manual-qa18-logout.json').write_text(json.dumps(manual, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
readiness_path = ROOT / 'storage/app/private/investmoz-v2/portal-profile-installation/readiness.json'
|
||||
backup = json.loads(readiness_path.read_text())
|
||||
checks = json.loads((PRIVATE / 'profile-installation-checks-qa.json').read_text())
|
||||
assert backup['code_hash'] == checks['code_hash'] and backup['restore_verified'] and backup['files_restore_verified']
|
||||
alias = PRIVATE / 'profile-installation-backup-qa.json'
|
||||
if alias.exists():
|
||||
previous = OUT / 'previous-profile-installation-backup-qa.json'
|
||||
if not previous.exists():
|
||||
previous.write_bytes(alias.read_bytes())
|
||||
alias.write_bytes(readiness_path.read_bytes())
|
||||
|
||||
evidence = ('C85: ação ausente na navegação do percurso, apesar de existir no menu do dashboard. '
|
||||
'Navegação comum passa a mostrar conta e Terminar sessão, usando POST /logout do Fortify e limpeza do cache Inertia. '
|
||||
'Responsável confirmou botão e logout QA18; rastreio ' + logout['at_utc'] + ': auth18 → null, persistência null, 302 → /. '
|
||||
'Chromium QA21 demonstrou logout direto do dossier desktop/móvel, API privada401, página privada→login, novo contexto com MFA normal e dossier inalterado; seis verificações de apresentação/teclado/reflow 320/390/1440px sem violações automáticas no cabeçalho. '
|
||||
'Provas: portal-logout-chromium-20261010.json e logout-finalize-20261010/manual-qa18-logout.json. '
|
||||
'MFA, chaves e QA18 preservados; bundle ativo sem alteração. Não substitui A/B ou outros perfis.')
|
||||
gap = ('C37: logout manual QA18 demonstrado; F5 explícito e novo login/MFA QA18 após este logout ainda por confirmar. '
|
||||
'Nova sessão Chromium QA21 demonstrada separadamente. Restantes consumidores, perfis, critérios A/B, móvel/acessibilidade integral e ativação não encerrados por este ensaio.')
|
||||
ids = {'AUD-UI-01', 'AUD-GUIDE-01', 'AUD-DASH-01', 'AUD-PROFILE-24', 'AUD-PROFILE-25'}
|
||||
files = []
|
||||
for filename in ['matriz-requisitos.csv', 'backlog-priorizado.csv']:
|
||||
source = DOC / filename
|
||||
rows = list(csv.reader(source.open(encoding='utf-8-sig', newline='')))
|
||||
headers = rows[0]
|
||||
found = set()
|
||||
patches = []
|
||||
for index, row in enumerate(rows[1:], 1):
|
||||
old = dict(zip(headers, row))
|
||||
rid = old['ID']
|
||||
if rid not in ids:
|
||||
continue
|
||||
found.add(rid)
|
||||
if filename.startswith('matriz'):
|
||||
values = {'Evidência funcional': evidence, 'Lacuna': gap,
|
||||
'Interface': 'Terminar sessão visível no topo de investimentos/dossier/artigo/simulador; confirmação manual QA18 e Chromium QA21 desktop/móvel.',
|
||||
'API': 'POST logout302→/; API privada401 e página privada→login após saída. Novo login+MFA21 normal numa sessão distinta.',
|
||||
'Gravação': 'Sessão invalidada; documentos, dossier, resultados, etapas e tarefas guardados não modificados.',
|
||||
'Recuperação': 'Dossier QA21 recuperado noutra sessão com o mesmo hash do snapshot de case/results/steps/tasks; F5 manteve acesso.',
|
||||
'Resultado apresentado': 'Conta atual identificada, botão acessível por teclado; após logout consulta pública sem botão e acesso privado exige login.',
|
||||
'Consolidação': 'Subcritérios C85 e logout manual C37 atualizados; estado global, fontes, versões e decisões preservados.'}
|
||||
variants = json.loads(old['Critérios e versões por origem (JSON)'])
|
||||
for variant in variants:
|
||||
variant['incremento_logout_qa_20261010'] = {'atualizado_em': stamp, 'evidencia': evidence, 'lacuna_atual': gap, 'ativacao': False}
|
||||
values = {field: old[field] + ' | ' + tag + value for field, value in values.items()}
|
||||
values['Critérios e versões por origem (JSON)'] = json.dumps(variants, ensure_ascii=False, separators=(',', ':'))
|
||||
else:
|
||||
values = {'Pendência': gap, 'Correção/componentes': 'C85 corrigido no PortalGroupedMenu/UserMenuContent; Fortify e proteção de sessão reutilizados. Não contornar MFA nem permissões.',
|
||||
'Aceitação': 'Logout manual18 demonstrado. Confirmar F5 e novo login/MFA18 após saída; provas QA21 independentes preservadas.'}
|
||||
values = {field: old[field] + ' | ' + tag + value for field, value in values.items()}
|
||||
patches.append({'row': index, 'values': values})
|
||||
assert found == ids and len(rows) == 3394
|
||||
files.append({'filename': filename, 'input_sha256': hashlib.sha256(source.read_bytes()).hexdigest(), 'rows': rows,
|
||||
'patches': patches, 'allowed_columns': sorted({field for patch in patches for field in patch['values']}), 'append': []})
|
||||
(OUT / 'csv-edits.json').write_text(json.dumps({'files': files}, ensure_ascii=False), encoding='utf-8')
|
||||
|
||||
section = f'''## Logout disponível e confirmação manual — {stamp}
|
||||
|
||||
**C85 corrigido em QA; nenhuma alteração no ativo.** A ação existia no menu de utilizador do dashboard, mas não na navegação do investidor. O cabeçalho comum mostra agora a conta atual e **Terminar sessão**, sem obrigar a regressar ao dashboard. Reutiliza POST `/logout` do Fortify e limpa o cache Inertia; chaves, MFA, perfis e dados guardados foram preservados.
|
||||
|
||||
O responsável confirmou o botão e o logout. Rastreio QA18 às **{logout['at_utc']}** (20:17:15 em Moçambique): POST logout302→/, conta18→sem autenticação e estado persistido vazio. C37 recebe apenas esse critério; F5 explícito e novo login/MFA QA18 após a saída continuam pendentes. O GET/login anterior com conta autenticada não era um novo login. A declaração geral de testes efetuados não substitui evidências dos restantes critérios.
|
||||
|
||||
Chromium QA21, contextos isolados: ação visível em investimentos, dossier, artigo e simulador; logout direto do dossier em desktop e 320px; API privada401 e página privada→login; nova sessão com MFA normal recuperou case/results/steps/tasks com hash igual e F5 manteve acesso. Seis verificações de cabeçalho em 320/390/1440px sem violações automáticas ou overflow; foco por teclado demonstrado. Não consumiu códigos QA18. Não encerra A/B, leitor de ecrã ou acessibilidade integral.
|
||||
|
||||
Provas sanitizadas: `portal-logout-chromium-20261010.json` e `logout-finalize-20261010/manual-qa18-logout.json`. Cinco requisitos receberam subcritérios; permanecem **3393 requisitos/3393 linhas/6845 ocorrências**, com estados globais inalterados.
|
||||
|
||||
Regressão de instalador/publicação: 9 testes/93 asserções e TypeScript passaram. Backup/restauro QA renovado **{backup['verified_at']}**, 295 tabelas/103 ficheiros, em `{backup['restored_target']['database']}:55441`, associado ao novo fingerprint que inclui os dois menus. O backup ativo READ ONLY de 17:48:52 UTC permanece um retrato anterior; candidata global, compatibilidade/reversão no clone do ativo e ativação continuam pendentes. Preservar **030000 → 040000 → 09/190000**, sem repetir instalações QA nem copiar contas/dossiers de teste.
|
||||
|
||||
As afirmações históricas abaixo sobre logout manual ainda por testar são superadas somente por esta confirmação; nenhuma aceitação adicional inferida.
|
||||
'''
|
||||
|
||||
def prepend(filename, key, content):
|
||||
file = DOC / filename
|
||||
old = file.read_text(encoding='utf-8-sig')
|
||||
pattern = re.compile(r'<!-- ' + re.escape(key) + r':begin -->.*?<!-- ' + re.escape(key) + r':end -->\s*', re.S)
|
||||
old = pattern.sub('', old)
|
||||
file.write_text(f'<!-- {key}:begin -->\n{content}\n<!-- {key}:end -->\n\n' + old, encoding='utf-8')
|
||||
|
||||
prepend('relatorio-executivo.md', 'qa-logout-results-20261010', section)
|
||||
prepend('backlog.md', 'qa-logout-results-20261010', section)
|
||||
prepend('casos-reproducao.md', 'qa-logout-c85-20261010', '''## C85 — logout ausente no percurso do investidor
|
||||
|
||||
- **Reprodução:** autenticar QA18, abrir `/investimentos` ou um dossier; procurar logout no cabeçalho. Não havia botão; era preciso conhecer o menu do dashboard.
|
||||
- **Esperado:** terminar sessão diretamente no percurso, em desktop/móvel e por teclado; dados guardados preservados e acesso privado negado após saída.
|
||||
- **Causa:** PortalNavigation usa PortalGroupedMenu sem ação de sessão; o logout de UserMenuContent só era apresentado pelo AppLayout do dashboard.
|
||||
- **Correção QA:** conta e botão Terminar sessão no cabeçalho comum; POST Fortify/CSRF existentes, limpeza do cache Inertia, sem alteração de guard, MFA ou privilégios.
|
||||
- **Evidência:** responsável confirmou botão/logout QA18; trace18 POST/logout302→/ com auth e persistência vazias. Chromium21 desktop/móvel, API401, página privada→login, nova sessão MFA e snapshot do dossier iguais. Cabeçalho acessível por teclado, sem overflow ou violações automáticas nos seis ensaios.
|
||||
- **Estado:** causa corrigida e logout manual demonstrado. C37 continua limitado pelos critérios F5 explícito e novo login/MFA18 após saída; provas21 não substituem conta18. Provas sanitizadas e 0 escritas ativas no relatório atual.
|
||||
''')
|
||||
prepend('plano-ativacao.md', 'qa-logout-activation-20261010', f'''## Incremento C85 — revisão QA, sem ativação
|
||||
|
||||
Menus de sessão adicionados ao fingerprint; preparação anterior ligada ao hash antigo preservada. Checagens e backup/restauro QA renovados em {backup['verified_at']} para o código atual: 295 tabelas/103 ficheiros em `{backup['restored_target']['database']}:55441`; nenhum novo recurso, migração, titular ou grant necessário para o botão. Bundle ativo permaneceu com SHA256 `{proof['active_build_sha256']}`.
|
||||
|
||||
Correção futura: disponibilizar componente/build compatíveis, após revisão candidata e ensaios correspondentes; preservar a ordem 030000→040000→09/190000 e executar somente instalações ausentes. Não repetir instalações QA por causa do botão. Reversão do incremento UI: regressar ao bundle/componentes anteriores, preservando dados e MFA; não desfazer schema nem recuperar uma chave distinta. Backup ativo READ ONLY de 17:48:52 UTC não é aceitação da nova candidata; verificar compatibilidade e reversão no clone e renovar o retrato antes de qualquer corte autorizado. C37 manual parcialmente confirmado; sem autorização de ativação ativa.
|
||||
''')
|
||||
print(json.dumps({'prepared_tables': len(files), 'updated_requirements': len(ids), 'manual_qa18_logout': True, 'manual_post_logout_mfa_observed': post_logout_mfa, 'active_writes': 0}))
|
||||
@@ -17,8 +17,12 @@ rows = list(csv.DictReader(matrix.open(encoding='utf-8-sig', newline='')))
|
||||
opened = [r for r in rows if r['Estado'] != 'concluído e demonstrado']
|
||||
prepared = {'AUD-COUNTRY-01', 'AUD-COUNTRY-02', 'AUD-COUNTRY-03', 'AUD-COUNTRY-04', 'AUD-COUNTRY-06', 'AUD-COUNTRY-07', 'AUD-STATS-01',
|
||||
'AUD-GEO-01', 'AUD-OP-01', 'AUD-OP-02', 'AUD-OP-05', 'AUD-DESK-01', 'AUD-DESK-02', 'AUD-CRM-01', 'AUD-GUIDE-01', 'AUD-SIM-01', 'AUD-PART-01', 'AUD-LAND-01', 'AUD-LIB-02'}
|
||||
access_failure = {'AUD-DASH-01', 'AUD-UI-01'}
|
||||
# C37 QA18 is manually closed; QA21 dashboard access is separately demonstrated.
|
||||
# Keep functional gaps under their canonical state instead of relabelling them as access failures.
|
||||
access_failure = set()
|
||||
prepared.update({'AUD-DASH-01', 'AUD-UI-01', 'AUD-GEO-02', 'AUD-GEO-03'})
|
||||
prepared.update({'AUD-OP-03','AUD-OP-04','AUD-PROFILE-19'})
|
||||
prepared.update({'AUD-LEG-01', 'AUD-LEG-02', 'AUD-LEG-03', 'AUD-LEG-05', 'AUD-LEG-06', 'AUD-LEG-07'})
|
||||
prepared.update(f'AUD-PROFILE-{number:02d}' for number in [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,23,24,25,30,31,32])
|
||||
groups = ['Não implementados', 'Parciais', 'Preparados em QA', 'Instalados com falha de acesso']
|
||||
|
||||
@@ -34,16 +38,16 @@ def group(row):
|
||||
|
||||
|
||||
next_actions = {
|
||||
'AUD-DASH-01': 'Identificar URL/conta da sessão reportada; no servidorQA, gestor17: dashboard→Gestão de Oportunidades→Nova oportunidade→guardar→nova sessão→editar→arquivar→restaurar. Incorporar prova visualC43 antes de futura disponibilização dos atalhos.',
|
||||
'AUD-UI-01': 'ManterC37 pendente; executar separadamente login/reload/logout/nova sessão investidor18 e os cenáriosA/B completos, depois as demais rotas/consumidores. Sem novo pedido de autorizaçãoQA.',
|
||||
'AUD-OP-01': 'Executar roteiro visualC43 a partir do dashboard com gestorQA17/MFA; conferir mensagens, filtro archived,ID/histórico e público intacto.',
|
||||
'AUD-DASH-01': 'Atalhos, rascunho, gravação, recuperação, edição, pedido de arquivo e arquivo independente QA21/25 já demonstrados; não repetir. Completar política e ação de restauração pelo perfil competente, sem conceder arquivo direto ao Promotor por inferência. Restantes categorias/workflow exigem provas próprias.',
|
||||
'AUD-UI-01': 'C37 encerrado exclusivamente para autenticação QA18 por confirmação manual, com provas anteriores preservadas. Não repetir o ciclo concluído. Completar A/B móvel, critérios especializados e restantes consumidores/perfis; auditoria e ativação permanecem abertas.',
|
||||
'AUD-OP-01': 'Executar C43 a partir do dashboard com Promotor QA21/MFA; conferir gravação, recuperação, coedição autorizada, pedidos/decisões de arquivo e restauro pelos perfis competentes, sem confundir o dossier privado com o catálogo público.',
|
||||
'AUD-OP-02': 'Conferir operações visuais por tipo/perfil e scans próprios de derivadas; após aceitação/backuprestaurado, aplicar autenticadamente030000 apenas se ausente. Não repetir020000.',
|
||||
'AUD-OP-03': 'Executar ciclo visual com PromotorQA21, Validador24, Publicador25 e recurso29; API/persistência do ciclo já demonstradas. Completar pareceres/incidentes/coordenação editorial e política SLA/agenda sem inventar responsáveis ou prazo.',
|
||||
'AUD-OP-04': 'Completar editores/validações semânticas §25 e Response integral; seis contratos estruturais aceites em QA não encerram todos os gates. Quatro categorias conservam drafts, mas exigem perfil modular aprovado para submissão/publicação.',
|
||||
'AUD-OP-05': 'Selecionar/guardar/reabrir7/8 na interface com versão do catálogo explícita; conferir legado6sem converter significados e fases de revisão/publicação.',
|
||||
'AUD-GEO-01': 'Pesquisar cada província, guardar/reabrirID/nome e confirmar o limite no seletor partilhado para cada perfil.',
|
||||
'AUD-GEO-02': 'Resolver188nomes(31distritos/157postos) por pai+edição/correspondência inequívoca; aplicar privadamenteQA, replaysem duplicação; percorrer cada seletor e limpar filhos ao mudar pai.',
|
||||
'AUD-GEO-03': 'Verificar mapa por consumidor com393limites privados e permissões; distinguir referência/limite/perímetro; resolver31/157e disponibilidade de polígonos de localidades. Publicação separada.',
|
||||
'AUD-GEO-02': 'Resolver as 173 correspondências restantes (26 distritos/147 postos), por pai, edição e identidade inequívoca; Mandie/Guro mantém-se ambíguo. Preservar as 408 associações demonstradas, aplicar privadamente em QA com replay e testar cada seletor.',
|
||||
'AUD-GEO-03': 'Testar cada mapa/consumidor com os 408 limites privados e permissões; distinguir território, geometria e apresentação. Permanecem 26 distritos/147 postos e polígonos de localidades. Publicação das camadas separada.',
|
||||
'AUD-REL-01': 'Ligar as relações existentes a citações e fonte/edição específicas; não inferir pares oportunidade–projeto/cluster nem criar ligações sem evidência.',
|
||||
'AUD-REL-02': 'Pergunta concreta:quais são os pares identificados e documentados entre as seis oportunidades existentes e âncoras/clusters? Preservar relações atuais enquanto indeterminados.',
|
||||
'AUD-SIM-02': 'Completar correspondência diploma/artigo/âmbito/período/parâmetro nas fontes oficiais aceites; testar cenários rastreáveis. Taxa ausente permanece indeterminada; importação não ativa cálculo.',
|
||||
@@ -62,6 +66,12 @@ next_actions = {
|
||||
'AUD-EDU-02': 'Depois deI0/C43/contratos prioritários, permitir guardar procura/capacidade incompletas como rascunho privado nas entidades existentes, sem exigir os campos finais.',
|
||||
'AUD-EDU-03': 'Não iniciar agora; depois das prioridades, jornadas/painéis por ator reutilizando19etapas/5fases e contas existentes, sem renumerar roteiros.',
|
||||
'AUD-OPS-01': 'Concluir critérios visuais→fixar candidata→renovar backup/restauro→disponibilizar código/flags→instalar somente migrations ausentes→percurso/importação/publicação separadas, com reversão preservadora.',
|
||||
'AUD-LEG-01': 'C71 recuperado; 18/50 versões cadastradas (17 com texto, uma vazia privada). Reconciliar as restantes 32 versões, paginação/sitemap e anexos/C68; prévia, hash, scan real, replay e divulgação separada.',
|
||||
'AUD-LEG-02': 'Desktop dos setores demonstrado no Chromium; completar móvel/acessibilidade sistemática e demais perfis. Não inferir aceitação integral a partir de tipografia/imagens carregadas.',
|
||||
'AUD-LEG-03': 'Quatro dossiers setoriais QA21 demonstrados na interface, nova sessão e retorno; completar consumidores e A/B com ferramenta/Balcão/acompanhamento, sem usar esta prova para encerrar C37.',
|
||||
'AUD-LEG-05': 'Preservar Energia e Mineração distintas; Turismo editorial sem associação geográfica automática. Fundamentar relações territoriais/por etapa com citação específica, reutilizando a mesma fonte da biblioteca.',
|
||||
'AUD-LEG-06': 'URLs e retornos dos setores demonstrados; completar o restante mapa de migração/aliases e cobertura do acervo. C37/QA18 e A/B mantêm critérios independentes.',
|
||||
'AUD-LEG-07': '22 originais/hash/ClamD/replay demonstrados; 21 divulgados separadamente e Mercado Financeiro vazio privado. Completar as 32 versões restantes, C68, links/anexos, móvel/acessibilidade e visual do editor Assunto opcional C74.',
|
||||
'AUD-DOC-01': 'Reconciliar individualmente cláusulas/versões com prova datada; conservar os estados históricos e atualizar somente o critério demonstrado na matriz única.',
|
||||
'AUD-UX-01': 'Percorrer interface/teclado/móvel e remover somente informação sem utilidade demonstrada, preservandoIDs/URLs e rastreabilidade.',
|
||||
'AUD-HUMAN-01': 'Pergunta concreta:qual é a entidade operadora, contacto oficial e texto/versão dos termos a apresentar? Não atribuir a entidade/responsável inexistente.',
|
||||
@@ -83,6 +93,8 @@ def action(row):
|
||||
|
||||
escape = html.escape
|
||||
counts = collections.Counter(group(r) for r in opened)
|
||||
for label in groups:
|
||||
counts.setdefault(label, 0)
|
||||
cards = []
|
||||
for r in sorted(opened, key=lambda x: (groups.index(group(x)), x['Prioridade'], x['Módulo'], x['ID'])):
|
||||
body = ''.join('<dt>' + escape(label) + '</dt><dd>' + escape(r[key]) + '</dd>' for label, key in [('Pedido', 'Pedido/trecho'), ('O que falta', 'Lacuna'), ('Rotas', 'Rotas'), ('Componentes encontrados', 'Implementação encontrada')])
|
||||
@@ -92,6 +104,8 @@ page = '''<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewp
|
||||
page = page.replace('3.354 requisitos/linhas;5.830 ocorrências;5 concluídos de âmbito restrito;3.349 abertos.',
|
||||
f'{len(rows):,} requisitos/linhas;{sum(int(r["Quantidade de linhas de origem"]) for r in rows):,} ocorrências;{len(rows)-len(opened)} concluídos de âmbito restrito;{len(opened):,} abertos.')
|
||||
page = page.replace('3.349 requisitos apresentados', f'{len(opened):,} requisitos apresentados')
|
||||
page = page.replace('“Instalados com falha de acesso” reúneC43/C37 ainda abertos, com correções/backendQA e aceitação visual pendente; não confirma o perfil da sessão reportada.',
|
||||
'“Instalados com falha de acesso” contém apenas casos de acesso ainda demonstradamente abertos. C37 foi encerrado para autenticação QA18; o acesso ao dashboard QA21 tem prova própria. Os outros critérios funcionais continuam abertos e a disponibilidade ativa é separada.')
|
||||
decision_count = sum(r['Estado'] == 'dependente de decisão concreta' for r in opened)
|
||||
page = page.replace('As4decisões concretas', f'As{decision_count}decisões concretas')
|
||||
(private / 'pedidos-abertos.html').write_text(page, encoding='utf-8')
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Append a scoped response/§27 receipt to the existing matrix; no new inventory."""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DOCS = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
csv.field_size_limit(16 * 1024 * 1024)
|
||||
proof = json.loads((PRIVATE / 'opportunity-response-http-qa.json').read_text(encoding='utf-8-sig'))
|
||||
checks = json.loads((PRIVATE / 'profile-installation-checks-qa.json').read_text(encoding='utf-8-sig'))
|
||||
assert proof['result'] == checks['result'] == 'passed' and proof['active_writes'] == 0 and not proof['interface_observed']
|
||||
assert len(proof['responses']) == 10 and len(proof['semantics']) == 2
|
||||
schema_sha = hashlib.sha256((ROOT / 'resources/data/investmoz-opportunity-modular.schema.json').read_bytes()).hexdigest()
|
||||
assert schema_sha == '9ce0acfa5df1f81a3357dee9e5a9a1c4e77e4eefc8bce294497d18f4caa8e5c0'
|
||||
matrix = DOCS / 'matriz-requisitos.csv'
|
||||
with matrix.open(encoding='utf-8-sig', newline='') as source:
|
||||
rows = list(csv.DictReader(source))
|
||||
protected = ['ID', 'Pedido/trecho', 'Fonte/ocorrências', 'Critério de aceitação', 'Versões divergentes', 'IDs das linhas de origem', 'Quantidade de linhas de origem', 'Critérios e versões por origem (JSON)', 'Estado']
|
||||
before = [{k: r[k] for k in protected} for r in rows]
|
||||
tag = '[Response/§27 QA 10/10/2026] '
|
||||
values = {
|
||||
'Implementação encontrada': 'Resposta de leitura versionada com IDs bigint/referências legados intactos, recursos UUID hidratados e metadados efetivamente gravados. Integra o editor existente e GET /minhas-oportunidades/{id}/response. Succession aceite na venda de negócio conforme contrato original; §27 recusa cadastro de parcela repetido na mesma oportunidade antes de submeter.',
|
||||
'Evidência funcional': 'opportunity-response-http-qa.json: dez respostas recuperadas noutra sessão MFA e iguais ao consumidor Inertia; Validador permitido, organização alheia 403 e sessão anónima 401. QA40: rascunho duplicado recuperável, submissão 422 no campo cadastral, correção e submissão aceite. QA41: succession guardada, submetida e recuperada. Testes isolados específicos em OpportunityCanonicalWorkflowTest; nenhum ensaio visual.',
|
||||
'Lacuna': 'Response normativo integral ainda parcial: algoritmo de completude, identidade/metadados de itens inline, correspondência explícita de estados e autor de alteração/exclusão de recursos. §27 conservado como fonte, sem criar tabelas especializadas paralelas. Quatro perfis e SLA não aprovados; condições de unidades/urbanismo/entidade sem exercícios mantêm decisões próprias.',
|
||||
'API': 'GET privado /minhas-oportunidades/{id}/response; recurso autorizado antes de hidratar, Cache-Control private/no-store. Escritas existentes e validações 422 preservadas; nunca publica nem aprova por leitura.',
|
||||
'Gravação': 'QA40 e QA41 sintéticos, usando oportunidades e coleções existentes. Nenhuma alteração das dez oportunidades anteriores, titulares, prazos ou base ativa.',
|
||||
'Recuperação': 'Novas sessões Fortify/MFA recuperam IDs, referências, valores e metadados. Números cadastrais ausentes permanecem desconhecidos; sem renumerar UUIDs/bigints ou fabricar aprovação/completude.',
|
||||
'Interface': 'Resposta entregue ao editor Inertia existente; interface, C37, Promotor QA21 e A/B continuam pendentes. Não confundir recuperação das props com clique ou render observado.'
|
||||
}
|
||||
for row in rows:
|
||||
if row['ID'] == 'AUD-OP-04':
|
||||
for key, value in values.items():
|
||||
old = re.sub(r'\s*\|\s*' + re.escape(tag) + r'.*?(?=\s*\|\s*\[|$)', '', row[key], flags=re.S)
|
||||
row[key] = old + ' | ' + tag + value
|
||||
assert before == [{k: r[k] for k in protected} for r in rows]
|
||||
assert len(rows) == 3386 and sum(int(r['Quantidade de linhas de origem']) for r in rows) == 6838
|
||||
with matrix.open('w', encoding='utf-8-sig', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(rows[0])); writer.writeheader(); writer.writerows(rows)
|
||||
|
||||
marker = 'qa-response-20261010'
|
||||
def prepend(path, content):
|
||||
text = path.read_text(encoding='utf-8-sig')
|
||||
text = re.sub(r'<!-- ' + marker + r':begin -->.*?<!-- ' + marker + r':end -->\s*', '', text, flags=re.S)
|
||||
first, _, rest = text.partition('\n')
|
||||
path.write_text(first + '\n\n<!-- ' + marker + ':begin -->\n' + content.strip() + '\n<!-- ' + marker + ':end -->\n\n' + rest.lstrip(), encoding='utf-8')
|
||||
|
||||
summary = f'''## Incremento atual — Response/§27 e propostas para decisão
|
||||
|
||||
Somente QA **http://127.0.0.1:18097**, `{proof['target']}`. Mesmo inventário/matriz; nenhum fecho integral ou escrita ativa.
|
||||
|
||||
- **AUD-OP-04:** `/minhas-oportunidades/{{id}}/response` integra a leitura do editor existente. Preserva IDs, códigos, referências UUID e estados atuais. Dez casos #30–39 recuperados noutra sessão MFA; Validador permitido, organização alheia 403 e anónimo 401. A versão do adaptador declara expressamente que a resposta normativa completa continua parcial.
|
||||
- **C65 / QA40:** §27 exige unicidade de número cadastral por oportunidade. Rascunho incompleto/duplicado permanece recuperável; submissão devolve 422 junto a `canonical.land_rights.parcels.1.cadastral_number`. Após correção, submissão aceite. Números ausentes não são inventados.
|
||||
- **C66 / QA41:** `succession` constava no contrato original mas era recusada pela lista semântica de venda. Lista reconciliada; criação/edição, submissão e nova sessão demonstradas. Não alterar silenciosamente outras modalidades.
|
||||
- **Ainda parcial:** algoritmo de completude, metadados/identidade de itens inline, equivalência explícita dos estados e recuperação dos autores de alteração/exclusão dos recursos. §27 não cria tabelas paralelas. Condições sem regra inequívoca continuam como decisões D-S1/S2.
|
||||
|
||||
Provas: `opportunity-response-http-qa.json`, `OpportunityCanonicalWorkflowTest`, `profile-installation-checks-qa.json` e schema original preservado. O recibo identifica separadamente a tentativa anterior em que passaram os dez critérios de leitura e o ensaio semântico posterior; falhas do harness foram conservadas, não apresentadas como falhas do portal.
|
||||
|
||||
**Propostas modulares e SLA continuam não aprovados.** O ficheiro `propostas-perfis-modulares-sla.md` explicita compromissos internos de atendimento, janela operacional do Promotor, pausas por tarefa, espera externa separada e ausência de decisão automática. Nenhum `due_at`, titular ou perfil modular ativado.
|
||||
|
||||
**Ativação:** o restauro reportado é QA. Para uma futura ativação, conferir a identidade, schema, dados e configuração efetivos da base ativa para a revisão candidata, capturar hashes/manifesto dessa revisão e provar o seu backup/restauro. As provas QA ou `ready=true` não substituem essa ligação. Sequência 030000 → 040000 → 190000; C37, QA21 e A/B visual pendentes, candidata não fixada.
|
||||
|
||||
**Próximo trabalho independente:** completar metadados/Response sem novos cadastros; PDFs gerados/exportações/derivadas por consumidor; países/origem com filtros e estatísticas por formulário; correspondências inequívocas, mantendo Mandie/Guro ambíguo. Cartografia permanece 408 resolvidas e 173 lacunas neste incremento.
|
||||
'''
|
||||
for name in ['relatorio-executivo.md', 'backlog.md', 'workflow-oportunidades-qa.md']:
|
||||
prepend(DOCS / name, summary)
|
||||
prepend(DOCS / 'plano-ativacao.md', '**Preparação da futura ativação.**' + summary.split('**Ativação:**', 1)[1].split('**Próximo trabalho independente:**', 1)[0])
|
||||
case_block = '- **C65' + summary.split('- **C65', 1)[1].split('- **Ainda parcial:**', 1)[0]
|
||||
prepend(DOCS / 'casos-reproducao.md', case_block + '\nC65 foi identificado por confronto código/§27; C66 por divergência da lista do servidor com o contrato. A prova atual é pós-correção. Não atribuir a estes casos falhas antigas não reproduzidas.')
|
||||
prepend(ROOT / 'docs/investmoz/v2/gestao-oportunidades-dashboard.md', summary)
|
||||
(PRIVATE / 'response-audit-update-qa.json').write_text(json.dumps({'requirements': 3386, 'source_occurrences': 6838, 'protected_fields_preserved': True,
|
||||
'schema_sha256': schema_sha, 'states_unchanged': True, 'updated': ['AUD-OP-04'], 'active_writes': 0}), encoding='utf-8')
|
||||
print(json.dumps({'requirements': 3386, 'source_occurrences': 6838, 'updated': ['AUD-OP-04'], 'states_unchanged': True, 'active_writes': 0}))
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Prepare targeted edits and reconcile existing reports from completed QA receipts, no database access."""
|
||||
import collections
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
csv.field_size_limit(16000000)
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
OUT = PRIVATE / 'legacy-sectors-finalize-20261010'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
def read(name):
|
||||
return json.loads((PRIVATE / name).read_text(encoding='utf-8'))
|
||||
http = read('legacy-sectors-http-qa.json')
|
||||
browser = read('legacy-sectors-browser-qa.json')
|
||||
persistence = read('legacy-sectors-persistence-qa.json')
|
||||
imported = read('legacy-sectors-apply-qa.json')
|
||||
replay = read('legacy-sectors-replay-qa.json')
|
||||
backup = read('profile-installation-backup-qa.json')
|
||||
copy = json.loads((PRIVATE.parent / 'portal-qa-recovery/20261010_154445/damaged-copy-proof.json').read_text())
|
||||
origin = read('legacy-financial-origin-20261010/proof.json')
|
||||
assert http['result'] == browser['result'] == 'passed'
|
||||
assert backup['restore_verified'] and backup['files_restore_verified'] and backup['target'] == imported['target']
|
||||
assert copy['all_names_sizes_hashes_equal'] and len(persistence['cases']) == 8
|
||||
assert datetime.fromisoformat(backup['verified_at']) >= datetime.fromisoformat(browser['completed_at'].replace('Z', '+00:00'))
|
||||
assert origin['html_http_status'] == origin['inertia_http_status'] == 200 and origin['initial_origin_content_length'] == origin['inertia_origin_content_length'] == 0
|
||||
assert imported['before']['opportunities'] == imported['after']['opportunities'] == 49
|
||||
assert replay['before'] == replay['after'] and len(imported['resources']) == 22
|
||||
stamp = datetime.now(timezone.utc).isoformat()
|
||||
tag = '[Setores QA após C71 10/10/2026] '
|
||||
rows = list(csv.reader((DOC / 'matriz-requisitos.csv').open(encoding='utf-8-sig', newline='')))
|
||||
headers = rows[0]
|
||||
canonical = {r[0]: dict(zip(headers, r)) for r in rows[1:]}
|
||||
assert len(canonical) == len(rows) - 1 == 3393
|
||||
backup_tables = len(backup['tables'])
|
||||
backup_files = len(backup.get('files', []))
|
||||
assert backup_tables == 295 and backup_files == 103
|
||||
evidence = ('22 originais importados privadamente após prévia renovada, hashes recuperados,22 scans ClamD reais e replay sem novos documentos/recursos/auditoria/scans. '
|
||||
'Divulgação autenticada separada:16 artigos com texto+5 imagens públicos; Mercado Financeiro privado, vazio confirmado na origem HTML e Inertia200. '
|
||||
'Chromium desktop:16 artigos, pesquisa/imagens, Energia e Mineração distintas, C72 título preservado; QA21 criou4 dossiers, retorno, logout/novaMFA/F5/replay; QA23 negado403 e anónimo sem contexto. '
|
||||
'Persistência READ ONLY:8 dossiers HTTP/Chromium, uma referência/evento cada,22 hashes únicos,0 relações geográficas automáticas. '
|
||||
'legacy-sectors-{apply,replay,http,browser,persistence}-qa.json; legacy-financial-origin-20261010/proof.json. C37/QA18,A/B,móvel/acessibilidade/cobertura integral não encerrados.')
|
||||
ops_evidence = (f'C71 corrigido somenteQA: novo cluster íntegro em diretório estável, mesma porta55441/base;295 tabelas/34.902 linhas byte-a-byte e31sequências preservadas. '
|
||||
f'Schema/owners/ACL/triggers comparados semanticamente;2 constraints varchar/text e slots de coluna normalizados, sem afirmar DDL byte-a-byte. '
|
||||
f'Cluster danificado original e cópia retidos:{copy["files"]} ficheiros, todos nomes/bytes/hashes iguais. '
|
||||
f'Backup/restauro posterior ao lote:{backup["verified_at"]},295tabelas/103ficheiros,restauro em{backup["restored_target"]["database"]}:55441. '
|
||||
'APP_KEY/MFA/conta18 preservados. Históricos QA preservados fisicamente, não todos restaurados no novo runtime. Provas portal-qa-recovery/20261010_154445 e profile-installation-backup-qa.json. Candidata global não fixada.')
|
||||
updates = {}
|
||||
for requirement in ['AUD-OPS-01'] + [f'AUD-LEG-{i:02d}' for i in range(1, 8)]:
|
||||
old = canonical[requirement]
|
||||
update = {}
|
||||
update['Evidência funcional'] = old['Evidência funcional'] + ' | ' + tag + (ops_evidence if requirement == 'AUD-OPS-01' else evidence)
|
||||
update['Consolidação'] = old['Consolidação'] + ' | ' + tag + 'Mesmo ID/referências/versões. Subcritérios deste lote demonstrados emQA; decisões e aceitações independentes mantidas.'
|
||||
variants = json.loads(old['Critérios e versões por origem (JSON)'])
|
||||
for variant in variants:
|
||||
variant['incremento_setores_qa_20261010'] = {'atualizado_em': stamp, 'ambito': 'QA18097;base55441isolada',
|
||||
'evidencia': ops_evidence if requirement == 'AUD-OPS-01' else evidence,
|
||||
'estado_requisito_atual': 'parcial' if requirement == 'AUD-OPS-01' else old['Estado'],
|
||||
'exclusoes': ['ativação', 'C37/QA18', 'A/B', 'móvel', 'acessibilidade', 'cobertura integral']}
|
||||
update['Critérios e versões por origem (JSON)'] = json.dumps(variants, ensure_ascii=False, separators=(',', ':'))
|
||||
if requirement == 'AUD-OPS-01':
|
||||
update['Estado'] = 'parcial'
|
||||
update['Lacuna'] = ('C71 técnico resolvido emQA; falta aceitação global C37/QA18,A/B,demais consumidores e revisão candidata. '
|
||||
'Futura ativação exige snapshot/backup/restauro próprios do ativo e ações autenticadas;CLI--actor é apenas atribuiçãoQA. '
|
||||
'Não repetir030/040/09-190naQA. | Histórico da falha e lacunas anteriores: ' + old['Lacuna'])
|
||||
update['Critério de aceitação'] = ('Revisão/destino atual, backup/restauro integral correspondente, guarda/compatibilidade/reversão e ativação autenticada única; '
|
||||
'QA atual295tabelas/103ficheiros demonstrada. Não substitui prova ativa. | Critério de versão anterior preservado: ' + old['Critério de aceitação'])
|
||||
update['Gravação'] = old['Gravação'] + ' | ' + tag + 'Recuperação em destino vazio isolado; comparações e backup/restauro verificados,0escritasativas.'
|
||||
update['Recuperação'] = old['Recuperação'] + ' | ' + tag + ops_evidence
|
||||
update['API'] = old['API'] + ' | ' + tag + 'QA18097/login200 após retirada da manutenção; alvo real55441confirmado.'
|
||||
update['Resultado apresentado'] = old['Resultado apresentado'] + ' | ' + tag + 'Manutenção retirada; percursos setoriais recuperados. Aceitação QA18 permanece independente.'
|
||||
else:
|
||||
for field in ['Interface', 'API', 'Gravação', 'Recuperação', 'Resultado apresentado']:
|
||||
update[field] = old[field] + ' | ' + tag + evidence
|
||||
pending = {
|
||||
'AUD-LEG-01': '18/50 versões observadas cadastradas:17comtexto consultáveis e1vazia privada;32versões ainda não importadas, sitemap/paginação/cobertura histórica eC68pendentes.',
|
||||
'AUD-LEG-02': 'Identidade aplicada e desktop dos novos setores observado; acessibilidade sistemática/móvel e restantes conteúdos pendentes. Editor Assunto opcional C74 sem ensaio visual próprio.',
|
||||
'AUD-LEG-03': 'Quatro percursos setoriais com retorno e nova sessão demonstrados; relações territoriais concretas, ferramenta/Balcão/acompanhamento deA/B e demais perfis continuam independentes.',
|
||||
'AUD-LEG-04': 'Consulta pública/proteção privada dos setores demonstradas. Disponibilização ativa,C37/QA18,A/B e aceitações manuais adicionais permanecem separadas.',
|
||||
'AUD-LEG-05': 'Mesma biblioteca/versão/imagem reutilizada. Turismo editorial sem geometrias inferidas; relações territoriais e por etapa ainda exigem fundamento concreto.',
|
||||
'AUD-LEG-06': '17caminhos comtexto preservados incluindo agronegócio e4novos retornos; restante mapaURL/aliases/acervo ainda incompleto. C37/QA18 eA/B não encerrados.',
|
||||
'AUD-LEG-07': '22originais/5imagens/hashes/scans/replay e16novos artigos públicos testados;32versões restantes, anexos/links externos/C68,móvel/acessibilidade e perfis adicionais por testar.'}
|
||||
update['Lacuna'] = pending[requirement] + ' | Histórico anterior: ' + old['Lacuna']
|
||||
updates[requirement] = update
|
||||
canonical[requirement].update(update)
|
||||
|
||||
packet = {'captured_at': stamp, 'files': []}
|
||||
def table(name):
|
||||
raw = (DOC / name).read_bytes()
|
||||
return list(csv.reader(raw.decode('utf-8-sig').splitlines(keepends=True))), hashlib.sha256(raw).hexdigest()
|
||||
def add(name, data, digest, patches, allowed, appended=None, source_hash_updates=None):
|
||||
packet['files'].append({'filename': name, 'input_sha256': digest, 'rows': data, 'patches': patches,
|
||||
'allowed_columns': allowed, 'append': appended or [], 'source_hash_updates': source_hash_updates or []})
|
||||
data, digest = table('matriz-requisitos.csv')
|
||||
add('matriz-requisitos.csv', data, digest, [{'row': i, 'values': updates[r[0]]} for i, r in enumerate(data) if i and r[0] in updates],
|
||||
list(set(k for value in updates.values() for k in value)))
|
||||
states = collections.Counter(r['Estado'] for r in canonical.values())
|
||||
assert sum(states.values()) == 3393 and states['concluído e demonstrado'] == 7 and states['com falha'] == 0
|
||||
|
||||
data, digest = table('backlog-priorizado.csv')
|
||||
bh = data[0]; patches = []; known = set()
|
||||
for i, values in enumerate(data[1:], 1):
|
||||
old = dict(zip(bh, values)); item = canonical[old['ID']]; known.add(old['ID'])
|
||||
new = {'Prioridade': item['Prioridade'], 'Estado': item['Estado'], 'Pendência': item['Lacuna'],
|
||||
'Dependência/pergunta': item['Dependência'], 'Aceitação': item['Critério de aceitação']}
|
||||
if old['ID'] in updates:
|
||||
new['Correção/componentes'] = 'Biblioteca/importador/LegacyContent/portal/legacy/percurso existentes; QA cluster/backup quandoOPS01; ver relatório atual.'
|
||||
new['Ordem do próximo lote'] = 'C37/A-B e aceitações independentes; continuar expansão/documentos/países/cartografia sem reabrir fontes oficiais.'
|
||||
changed = {k: v for k, v in new.items() if old[k] != v}
|
||||
if changed: patches.append({'row': i, 'values': changed})
|
||||
appended = []
|
||||
for requirement, item in canonical.items():
|
||||
if requirement not in known:
|
||||
new = {'Prioridade': item['Prioridade'], 'ID': requirement, 'Estado': item['Estado'], 'Pendência': item['Lacuna'],
|
||||
'Correção/componentes': item['Implementação encontrada'], 'Dependência/pergunta': item['Dependência'],
|
||||
'Aceitação': item['Critério de aceitação'], 'Ordem do próximo lote': 'Critério individual; consultar backlog atual e matriz única'}
|
||||
appended.append([new[h] for h in bh])
|
||||
assert len(data) - 1 + len(appended) == 3393
|
||||
add('backlog-priorizado.csv', data, digest, patches, [h for h in bh if h != 'ID'], appended)
|
||||
manifest = json.loads((ROOT / 'storage/app/private/document-import-staging/legacy-setores-20261010/manifest.json').read_text(encoding='utf-8'))
|
||||
resources = {r['external_id']: r for r in imported['resources']}
|
||||
by_url = {r['source_url'].rstrip('/'): resources[r['external_id']] for r in manifest['documents']}
|
||||
for filename in ['legado-inventario.csv', 'legado-conteudos-reconciliados.csv']:
|
||||
data, digest = table(filename); h = data[0]; patches = []
|
||||
for i, values in enumerate(data[1:], 1):
|
||||
item = dict(zip(h, values)); row = None
|
||||
if filename == 'legado-inventario.csv': row = by_url.get(item['URL'].rstrip('/'))
|
||||
else:
|
||||
row = next((r for r in resources.values() if r['external_id'].startswith('legacy-tempo-post-' + item['ID legado'] + '-')), None)
|
||||
if row:
|
||||
empty = row['external_id'].startswith('legacy-tempo-post-171994-')
|
||||
status = (f'RecursoQA{row["id"]}; original/hash/ClamD/replay demonstrados. ' +
|
||||
('Corpo vazio na origemHTML/Inertia200;privado,sem página publicada.' if empty else 'DivulgaçãoQAseparada;consumidorHTTP/desktop observado;não é oportunidade nem geometria.'))
|
||||
changes = {'Situação': status}
|
||||
if filename == 'legado-inventario.csv': changes['Destino proposto'] = f'biblioteca existente recursoQA{row["id"]};URLs/versão originais preservados'
|
||||
else: changes['Recurso QA'] = str(row['id'])
|
||||
patches.append({'row': i, 'values': changes})
|
||||
add(filename, data, digest, patches, ['Situação', 'Destino proposto', 'Recurso QA'])
|
||||
|
||||
block = f'''<!-- qa-c71-sectors-result-20261010:begin -->
|
||||
## Resultado atual — {stamp}
|
||||
|
||||
**QA18097 recuperada; nenhuma alteração na base ativa.** C71 técnico corrigido: faltavam26 ficheiros de catálogos partilhados, incluindo `global/2964`, necessários ao `pg_dump`. O agente/processo que os removeu continua desconhecido. O cluster danificado foi preservado e substituído por cluster novo em diretório estável, apenas no alvoQA55441. As295tabelas/34.902linhas e31sequências iniciais conferiram; schema/owners/ACL/triggers equivalentes após normalização explícita de2constraints varchar/text e slots de colunas removidas. A cópia física retida confere em{copy['files']}ficheiros por nomes, tamanhos ehashes; isso não afirma restauro lógico de todos os bancos históricosQA.
|
||||
|
||||
**Backup/restauro posterior ao lote:** {backup['verified_at']},295tabelas e103ficheiros privados conferidos em `{backup['restored_target']['database']}:55441`. APP_KEY, MFA e contas preservados. Não é prova de backup da base ativa nem fixa candidata global. QA/login voltou aHTTP200; manutenção arquivada. 2FAS Auth continua compatível com TOTP; a rejeição manualQA18 registada foi de palavra-passe antes do MFA, sem justificar troca de aplicação.
|
||||
|
||||
| Requisito / caso | Evidência deste lote | Lacuna concreta |
|
||||
| --- | --- | --- |
|
||||
| AUD-OPS-01 / C71 | Cluster saudável, dados/sequências/permissões preservados, backup/restauro renovado,0escritasativas. Estado global passa de com falha para parcial. | C37/QA18,A/B e outros consumidores; candidata e backup ativo/autorização própria. |
|
||||
| AUD-LEG-01/05/07 |22ficheiros (17artigos+5imagens) importados privadamente, hashes recuperados,22scans ClamD reais; replay sem duplicações/eventos/scans novos. |32das50versões observadas continuam não importadas; sitemap misto/paginação/links externos/C68 e cobertura integral. |
|
||||
| AUD-LEG-02/03/06 / C72 |16artigos novos comtexto públicos, título deInfraestruturas preservado. Chromium desktop: imagens/pesquisa/artigos→MFA21→4dossiers→retorno→logout→nova sessão→F5/replay. HTTP e persistência separados,8dossiers no total. | Móvel/acessibilidade/mais perfis e percursosA/B completos não encerrados. |
|
||||
| AUD-LEG-04 |Leitura pública; guardar requer login. Anónimo/QA23 sem contexto privado; QA23recebe403 ao dossier alheio. Estado de consulta/proteçãoQA mantido concluído no âmbito demonstrado. | Disponibilização ativa e demais aceitações são independentes. |
|
||||
| Mercado Financeiro |OrigemHTML e respostaInertiaHTTP200 contêm corpo vazio; exemplar22 no lote fica privado na biblioteca (recurso23). `/mercado-financeiro` e respetiva exploração404,sem página vazia apresentada como entrega. | Obter conteúdo real/versão comtexto; não usar Fintech nem inventar corpo. |
|
||||
| C74 |Imagens semAssunto eram recusadas porPUT422. QA aceita tema vazio sem inferir setor; interface tornaAssunto opcional. Cinco metadados vazios guardados/recuperados; teste dedicado1/11. | Editor administrativo sem ensaio visual próprio; alteração protegida pela guardaQA. |
|
||||
|
||||
**Contagens canónicas:**3393requisitos/3393linhas,6845ocorrências de origem; {states['parcial']}parciais,145não implementados,5dependentes de decisão,1implementado mas não ativado,7concluídos e demonstrados,0com falha. Casos adicionais e aceitações abertas continuam visíveis dentro dos requisitos parciais;0com falha global não significa portal sem erros. BacklogCSV é projeção reconciliada dos mesmos3393IDs, incluindo39 anteriormente omitidos, não outra matriz.
|
||||
|
||||
**RotasQA:** `/explorar/energia-mineracao` → `/energia-mineracao`, `/energia`, `/mineracao`; `/explorar/turismo` → `/turismo` e11artigos territoriais; `/explorar/infraestruturas` → `/infraestruturas`. Agronegócio preservado. Energia/Mineração são fontes distintas; Turismo mantém texto editorial e0associações territoriais automáticas. Todos os dados de fonte/autoria/datas/hash/URLs/versões originais são conservados. Os artigos não criaram oportunidades (49antes/depois), geometrias ou perfis. Divulgação foi uma ação autenticada do gestorQA14 pelo mecanismo existente, separada da importaçãoCLIQA (atribuição `--actor`, não autenticação ativa); nenhum desafioMFA foi observado nessa conta. QA21/QA23 usaramMFA normalmente.
|
||||
|
||||
**Próximo trabalho executável:** restantes32versões/acervo eC68 por ficheiro; Response/§27/validações, PDFs/derivadas epaíses/origem/estatísticas por consumidor;173correspondências territoriais ainda abertas comMandie/Guroambíguo. Não acrescentar jornadas educativas para contornar essas pendências. Perfis modulares eSLA conservam decisões próprias. C37/QA18,A/B,móvel/acessibilidade permanecem abertos; este lote não demonstraCRUD/workflow de oportunidades nem os encerra.
|
||||
|
||||
**Ativação futura:** revisão/hashes/flags e aceitação global→leitura/backup/restauro do ativo correspondente→código/build compatíveis com flags desligadas→030000→040000→09/190000 somente se ausentes→importação privada de conteúdo/cartografia com prévia e scan→divulgação separada→disponibilização incremental. QA já tem as3instalações;não as repetir. Previsão desta extensão:22originais (reutilizar hashes existentes),21potencialmente divulgáveis e1exemplar vazio privado;0contas/dossiers/opportunities/geometrias transferidos. O CLIrecusa aplicação ativa: falta preparar/demonstrar uma ação autenticada emlote que preserve exatamente as identidades/versionamento existentes; upload unitário não foi provado equivalente. Cartografia:408candidatas/173pendentes no retratoQA anterior, não importadas por esta frente. `ready=true` continua preparação, não instalação/disponibilização.
|
||||
|
||||
**Reversão:** retirar divulgação em ações auditadas e ocultar consumidores/flag, preservando originais, versões, consentimentos/referências eeventos. Restauro técnico usa o novo dump íntegro e chave estável numa QA distinta,comparação antes de corte;não voltar ao cluster danificado. Nunca drop de tabelas utilizadas nem copiar fixturesQA para o ativo. Provas: `legacy-sectors-{{apply,replay,http,browser,persistence}}-qa.json`, `legacy-financial-origin-20261010/proof.json`, `portal-qa-recovery/20261010_154445/*-proof.json` e `profile-installation-backup-qa.json` (privadas,sem credenciais/cookies/códigos nas capturas).
|
||||
|
||||
**Versões históricas preservadas abaixo:** frases sobreC71aberto,22ficheiros apenas preparados ou rotas apenas deagronegócio descrevem os incrementos anteriores e são substituídas, para o estado atual, pelas provas desta secção.
|
||||
<!-- qa-c71-sectors-result-20261010:end -->
|
||||
'''
|
||||
def legible(value):
|
||||
value = re.sub(r'\b(as|As|de|e|os|em|por)(?=\d)', r'\1 ', value)
|
||||
value = re.sub(r'(\d+)(tabelas|linhas|ficheiros|sequências|constraints|instalações|artigos|imagens|itens|originais|versões|referências|eventos|scans|dossiers|requisitos|ocorrências|parciais|correspondências|nomes|exemplar|contas|pendentes|candidatas|não|dependentes|implementado|concluídos|com|das|novos|potencialmente)', r'\1 \2', value)
|
||||
for old, new in {'comtexto': 'com texto', 'semtexto': 'sem texto', 'sem página': 'sem página', 'original/hash/ClamD': 'original / hash / ClamD',
|
||||
'semcontorno': 'sem contorno', 'semcontas': 'sem contas', 'apósguarda': 'após guarda', 'porficheiro': 'por ficheiro',
|
||||
'preservedos': 'preservados', 'privadas,sem': 'privadas, sem', 'ativos,sem': 'ativos, sem',
|
||||
'21divulgados': '21 divulgados', '1artigo': '1 artigo', 'comQA21': 'com QA21',
|
||||
'QA23negado403': 'QA23 negado 403', 'das50': 'das 50', 'eC68imagem404': 'e C68 (imagem 404)',
|
||||
'19etapas': '19 etapas', '3393IDs': '3393 IDs', '39estavam': '39 estavam',
|
||||
':22': ': 22', ':3393': ': 3393', ',6845': ', 6845', ';3235': '; 3235', ',145': ', 145',
|
||||
',5 dependentes': ', 5 dependentes', ',1 implementado': ', 1 implementado', ',7 concluídos': ', 7 concluídos',
|
||||
',0 com falha': ', 0 com falha', 'CLI--actor': 'CLI `--actor`',
|
||||
'sequência030→040→09/190': 'sequência 030000 → 040000 → 09/190000'}.items():
|
||||
value = value.replace(old, new)
|
||||
return value
|
||||
block = legible(block)
|
||||
backlog_block = f'''<!-- qa-c71-sectors-result-20261010:begin -->
|
||||
## Prioridades atuais após C71 — {stamp}
|
||||
|
||||
**QA recuperada e lote setorial demonstrado; base ativa fora do âmbito.** C71 técnico corrigido, com backup/restauro final de295tabelas/103ficheiros. AUD-OPS-01 volta a parcial, porque aceitação global e ativação continuam pendentes. Biblioteca:22novos originais importados/scaneados;21divulgados por ação separada e1artigo vazio privado. Chromium desktop comQA21:4dossiers criados, retorno, logout/novaMFA/F5/replay; QA23negado403. Ver relatório executivo e provas individuais.
|
||||
|
||||
| Prioridade / requisito | Falta e próxima ação | Critério concreto |
|
||||
| --- | --- | --- |
|
||||
| P0 / AUD-UI-01,C37/QA18 | Incorporar tentativa manual sanitizada; primeira rejeição atual foi de palavra-passe, antes do MFA. 2FAS continua compatível; sem nova aplicação ou reset de segredo. | MFA→`/investimentos`→F5→logout→novo login na sessão problemática; não usar QA21 como substituto. |
|
||||
| P1 / AUD-LEG-01/06/07 |32das50versões ainda não importadas, sitemap/paginação/acervo eC68imagem404. Reconciliar próximo lote por identidade/hash/citação. | Prévia, armazenamento privado, scan real, replay/recuperação e divulgação separada; não inventar corpo de Mercado Financeiro. |
|
||||
| P1 / AUD-LEG-02/03/07 | Móvel/acessibilidade/mais perfis e editor administrativo C74 sem ensaio visual próprio; restantes ligações/consumidores. | Prova de cada ação visível e permissão; preservar confirmação manual limitada do piloto e diferenças entre versões. |
|
||||
| P1 / percurso,I0–I3,A/B | Cenários completos com ferramenta→resultado→Balcão→acompanhamento, e ações do dashboard/workflow continuam independentes. | Cinco fases/19etapas, nova sessão, preparação paralela, isolamento/permissões, retorno ao dossier. |
|
||||
| P1 / países e cartografia | Completar países/origem/filtros/estatísticas por formulário e173correspondências territoriais; Mandie/Guro permanece ambíguo. | UI→API→gravação→recuperação→resultado por consumidor; território≠geometria≠mapa. |
|
||||
| P1 / documentos,Response/§27 | Completar condições semânticas, PDFs/exportações/derivadas por consumidor. | Scan próprio, originais/hash preservados, permissões e resultados recuperáveis. |
|
||||
| P1/P2 / decisões e ativação | Perfis modulares/SLA, calendário/nomeações reais e candidata permanecem separados. Preparar ação autenticada de importação em lote preservando identidade:CLI--actor sóQA. | Decisão concreta por regra; backup/restauro da base ativa da candidata e sequência030→040→09/190 sem repetições. |
|
||||
|
||||
**Mesma matriz:**3393requisitos/linhas,6845ocorrências;3235parciais,145não implementados,5dependentes de decisão,1implementado mas não ativado,7concluídos e demonstrados,0com falha global. O backlogCSV agora projeta todos os3393IDs;39estavam omitidos. Casos/aceitações dentro dos parciais continuam abertos. As secções abaixo são histórico anterior à recuperação/importação, não o estado atual.
|
||||
<!-- qa-c71-sectors-result-20261010:end -->
|
||||
'''
|
||||
activation_block = f'''<!-- qa-c71-sectors-result-20261010:begin -->
|
||||
## Preparação atual após recuperação e lote setorial — {stamp}
|
||||
|
||||
C71 corrigido emQA: comparação integral e backup/restauro renovado {backup['verified_at']} ({backup_tables}tabelas/{backup_files}ficheiros), em `{backup['restored_target']['database']}:55441`. Esta prova não é backup do ativo nem fixa candidata. C37/QA18,A/B,móvel/acessibilidade/outros consumidores e decisões próprias permanecem pendentes.
|
||||
|
||||
1. Concluir as aceitações da revisão; fixar código/manifesto/configuração e hashes. A guarda atual do legado continua restrita aQA; código preparado não é habilitação ativa.
|
||||
2. Conferir o alvo ativo por leitura e provar backup/restauro próprio dessa revisão, incluindo ficheiros e chave estável. O último retrato ativo anterior continua histórico.
|
||||
3. Disponibilizar código/build compatíveis com flags desligadas; preservarURLs,IDs,roteiros/consentimentos. Não copiar contas ou dossiers sintéticos.
|
||||
4. Prévia e aplicação autenticadas030000→040000→**2026_10_09_190000_prepare_investor_path**, apenas se ausentes e coerentes. QA já tem as3: não repetir. O Balcão07/190 não substitui a extensão09/190; dependências/efeitos e bootstrap separado estão na tabela histórica abaixo.
|
||||
5. Conteúdo: prévia/hash/identidade/versão, armazenamento privado eClamD. **22candidatos do lote (17artigos+5imagens)**;17artigos comtexto no total incluindo piloto anterior,1exemplar vazio privado. Reutilizarbytes/identidades já existentes; qualquer conflito fica pendente. A CLI com--actor é apenas ensaioQA, não instalação autenticada ativa. Ainda falta preparar/demonstrar um ponto autenticado de importação emlote com as mesmas identidades; upload unitário não foi demonstrado equivalente.
|
||||
6. Cartografia emlote distinto, por pais e correspondências inequívocas; retratoQA408candidatas/173pendentes, Mandie/Guroambíguo.0geometrias importadas nesta frente; medir novamente no alvo. Publicação das camadas separada.
|
||||
7. Divulgar os conteúdos autorizados em ação própria: previsão21recursos deste lote; Mercado Financeiro permanece privado/vazio e nunca apresentado como página concluída. QA demonstrou divulgação autenticada do gestor14, sem presumir MFA não observado. Aprovação/consulta/cálculo/decisão externa são estados independentes.
|
||||
8. Disponibilizar consumidores/percurso incrementalmente após provas;0fixturesQA transferidos. `ready=true` não instala, não publica e não fecha requisitos.
|
||||
|
||||
**Reversão:** retirar divulgação e ocultar consumidores/flags com auditoria, mantendo originais, versões, fontes dos dossiers eeventos. Preferir reconciliação/restauro numaQA distinta comdump íntegro/chave preservada antes de corte; não reativar o cluster danificado nem eliminar tabelas utilizadas. Conservados o original danificado e a cópia física de{copy['files']}ficheiros (nomes/tamanhos/hashes iguais), sem afirmar restauro lógico de todos os históricos no novo runtime. Plano não executado na base ativa.
|
||||
|
||||
As secções abaixo preservam a evolução anterior; referências aC71aberto ou lote apenas preparado foram superadas pelas provas atuais, sem apagar dependências independentes.
|
||||
<!-- qa-c71-sectors-result-20261010:end -->
|
||||
'''
|
||||
reports = {'relatorio-executivo.md': block, 'legado-piloto-qa.md': block, 'backlog.md': legible(backlog_block), 'plano-ativacao.md': legible(activation_block)}
|
||||
for filename, current_block in reports.items():
|
||||
path = DOC / filename
|
||||
original = path.read_text(encoding='utf-8')
|
||||
archive = OUT / (filename + '.before')
|
||||
if not archive.exists(): archive.write_text(original, encoding='utf-8')
|
||||
original = re.sub(r'<!-- qa-c71-sectors-result-20261010:begin -->.*?<!-- qa-c71-sectors-result-20261010:end -->\s*', '', original, flags=re.S)
|
||||
title, body = original.split('\n', 1)
|
||||
path.write_text(title + '\n\n' + current_block + '\n' + body.lstrip(), encoding='utf-8')
|
||||
case_path = DOC / 'casos-reproducao.md'
|
||||
case = case_path.read_text(encoding='utf-8')
|
||||
case_block = f'''<!-- qa-c71-sector-cases-20261010:begin -->
|
||||
## Estado dos casos C71–C76 após o lote — {stamp}
|
||||
|
||||
**C71 / AUD-OPS-01:** causa/operação/reprodução históricas preservadas abaixo. CorrigidoQA: recuperação emcluster novo, comparação295tabelas/34.902linhas/31sequências e schema/permissões compatíveis; cópia física{copy['files']}ficheiros com hashes iguais. Prévia22itens renovada antes de aplicação; backup/restauro final295/103verificado. Processo que removeu26catálogos continua não identificado; nenhum remendo de catálogo ou rotação de chave. Requisito inteiro parcial pelas demais dependências/ativação. A manutenção foi retirada; GET/login200no18097. Ver provas no relatório atual.
|
||||
|
||||
**C72 / AUD-LEG-02/07:** título deInfraestruturas desaparecia pelo sanitizador excluirh1/filhos; h1→h2 preserva conteúdo e limpeza. Agora exemplar importado/scaneado/consultável e título observado no Chromium, captura `infra-original-heading`; original/hash intacto. CorrigidoQA, não ativo.
|
||||
|
||||
**C73 / AUD-UI-01/C37:** a manutenção técnica é independente das3rejeições de palavra-passe15:38–15:39UTC; estas ocorreram antes do MFA. QA18secret/chave/credencial reconfirmados15:57:57porREADONLY. 2FAS Auth já suportaTOTP; não requer nova aplicação/remoção da entrada antiga. C37continua pendente no navegador do responsável, sem novo ensaio MFA18 deste lote.
|
||||
|
||||
**C74 / AUD-LEG-07, consumidor documental:** reproduzir comimagem do manifesto semtema: login gestorQA14→PUT `/management/portal-services/resources/24` comAssuntovazio→HTTP422campo`topic`. Esperado: guardar metadados originais autorizados seminventarassociação. Causa: importador admite vazio, validaçãoAPI/frontend exigiamvalor. CorreçãoQA: tema nullable sobguarda realQA, vazio normalizado para stringcompatível; interfaceAssuntoopcional. RepetiçãoPUTs das5imagens+recuperaçãoSQL/download passaram; originais/hashes/scans/ausência de relações preservados. Teste dedicado1/11asserções; visualdoeditor ainda pendente. DivulgaçãoPOSTseparada, semcontorno da autorização.
|
||||
|
||||
**C75 / AUD-LEG-02/03, regressão detectada antes da divulgação:** GET `/explorar/agronegocio` no testeLegacyContentTest retornava500 na nova parametrização, `Undefined property stdClass::$title`. Causa: projeçãoSQL selecionava documento mas omitia título do recurso usado nas leituras relacionadas. Corrigido incluindo`r.title`; teste4/81eHTTP/Chromium passaram. Não atribuir ao middleware. Nenhuma ocorrência ativa alegada.
|
||||
|
||||
**C76 / AUD-LEG-03/06, regressão detectada antes da divulgação:** POST `/investimentos` comartigo válido retornava422. Esperado: validar ID da biblioteca e guardar fonte numdossier próprio. Causa: `value(d.external_id)`sobrebuilder comprojeção pré-existente lia primeiro campoUUID; não encontrava a identidade configurada. Correção: leitura explícita`first()?->external_id`, preservando controles público/hash/scan/contexto. Testes4/81; HTTP4dossiers/Chromium4dossiers eREADONLY8referências únicas. CorrigidoQA, nenhuma escrita ativa.
|
||||
|
||||
Falhas do harness conservadas separadamente: primeira leitura usou indevidamente rota pública de download para exemplar privado404 (a rota protegida correta é `/management/documents/{{UUID}}/download`); recompilação durante sessãoInertia produziu409/reload esperado; primeiras esperasnetworkidle/imagem ainda não decodificada falharam sob concorrência. Corrigida ordenação/espera no teste, sem alterar autenticação/aplicação para fabricar sucesso. Histórico de tentativasprivadas preservado; resultado final sófoi declarado após percursos completos.
|
||||
<!-- qa-c71-sector-cases-20261010:end -->
|
||||
'''
|
||||
case = re.sub(r'<!-- qa-c71-sector-cases-20261010:begin -->.*?<!-- qa-c71-sector-cases-20261010:end -->\s*', '', case, flags=re.S)
|
||||
title, body = case.split('\n', 1)
|
||||
case_path.write_text(title + '\n\n' + legible(case_block) + '\n' + body.lstrip(), encoding='utf-8')
|
||||
data, digest = table('inventario-fontes.csv'); h = data[0]; patches = []; source_updates = []
|
||||
for i, values in enumerate(data[1:], 1):
|
||||
item = dict(zip(h, values)); exemplar = Path(item['Exemplar/caminho'])
|
||||
if exemplar.parent == DOC and exemplar.name in ['legado-piloto-qa.md', 'legado-inventario.csv', 'legado-conteudos-reconciliados.csv']:
|
||||
patches.append({'row': i, 'values': {'Nota de leitura': 'C71 recuperado e 22 originais QA importados/scaneados; 21 divulgados separadamente; 16 novos artigos e 4 dossiers demonstrados. Históricos/restrições preservados. | Nota anterior: ' + item['Nota de leitura'],
|
||||
'Edição/versão identificada': item['Edição/versão identificada'] + ' | loteQAsetores' + stamp + '; exemplar anteriorSHA256=' + item['SHA256']}})
|
||||
source_updates.append({'row': i})
|
||||
add('inventario-fontes.csv', data, digest, patches, ['Nota de leitura', 'Edição/versão identificada', 'SHA256', 'Bytes'], source_hash_updates=source_updates)
|
||||
(OUT / 'csv-edits.json').write_text(json.dumps(packet, ensure_ascii=False), encoding='utf-8')
|
||||
(OUT / 'counts.json').write_text(json.dumps({'requirements': len(canonical), 'rows': len(rows) - 1, 'source_occurrences': 6845,
|
||||
'states': dict(states), 'canonical_backlog_missing_rows_added': len(appended), 'active_writes': 0}, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'prepared_csv_tables': 5, 'matrix_rows': len(updates), 'backlog_missing_rows': len(appended), 'states': dict(states), 'database_access': False}, ensure_ascii=False))
|
||||
@@ -0,0 +1,72 @@
|
||||
// Patch the existing CSV cells using Artifact Tool; never create a second requirements matrix.
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import crypto from 'node:crypto';
|
||||
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const DOC = path.join(ROOT, 'docs/investmoz/v2/auditoria-portal');
|
||||
const PRIVATE = path.join(ROOT, 'storage/app/private/investmoz-v2/portal-audit');
|
||||
const runtime = path.join(PRIVATE, 'legacy-sector-reconciliation-20261010');
|
||||
const requireRuntime = createRequire(path.join(runtime, 'package.json'));
|
||||
const { Workbook } = await import(pathToFileURL(requireRuntime.resolve('@oai/artifact-tool')).href);
|
||||
const packetName = process.argv[2] || 'legacy-sectors-finalize-20261010';
|
||||
assert(/^[a-z0-9-]+$/.test(packetName), 'Editing packet must remain inside the private audit directory');
|
||||
const packetDirectory = path.join(PRIVATE, packetName);
|
||||
const packet = JSON.parse(await fs.readFile(path.join(packetDirectory, 'csv-edits.json'), 'utf8'));
|
||||
const sha = b => crypto.createHash('sha256').update(b).digest('hex');
|
||||
const serialize = rows => '\ufeff' + rows.map(row => row.map(v => /[",\r\n]/.test(v) ? '"' + v.replaceAll('"', '""') + '"' : v).join(',')).join('\r\n') + '\r\n';
|
||||
const matches = (value, literal) => value instanceof Date ? value.getTime() === Date.parse(literal) : (value == null ? '' : String(value)) === literal;
|
||||
const outputs = [];
|
||||
for (const edit of packet.files) {
|
||||
const filename = edit.filename, file = path.join(DOC, filename), input = await fs.readFile(file, 'utf8');
|
||||
assert.equal(sha(Buffer.from(input)), edit.input_sha256, 'Source changed since targeted editing was prepared');
|
||||
const source = edit.rows, strings = source.map(row => [...row]);
|
||||
const workbook = await Workbook.fromCSV(input.replace(/^\ufeff/, ''), { sheetName: 'Registos' });
|
||||
const sheet = workbook.worksheets.getItem('Registos'), headers = source[0];
|
||||
const observed = sheet.getRangeByIndexes(0, 0, source.length, headers.length).values;
|
||||
assert(observed.every((r, i) => r.every((v, c) => matches(v, source[i][c]))), 'Imported cells must match existing source');
|
||||
const changes = [];
|
||||
for (const patch of edit.patches) {
|
||||
const row = patch.row;
|
||||
assert(row > 0 && row < source.length);
|
||||
const updated = [...strings[row]];
|
||||
for (const [field, value] of Object.entries(patch.values)) {
|
||||
const col = headers.indexOf(field); assert(col >= 0 && edit.allowed_columns.includes(field));
|
||||
updated[col] = value;
|
||||
}
|
||||
sheet.getRangeByIndexes(row, 0, 1, headers.length).values = [updated]; strings[row] = updated;
|
||||
changes.push({ row: row + 1, fields: Object.keys(patch.values) });
|
||||
}
|
||||
if (edit.append?.length) {
|
||||
assert.equal(filename, 'backlog-priorizado.csv', 'Only the existing backlog projection can gain missing canonical IDs');
|
||||
sheet.getRangeByIndexes(strings.length, 0, edit.append.length, headers.length).values = edit.append;
|
||||
strings.push(...edit.append);
|
||||
}
|
||||
// Source registry hashes refer to the final bytes authored above; original hashes remain in version history.
|
||||
if (filename === 'inventario-fontes.csv') {
|
||||
for (const patch of edit.source_hash_updates) {
|
||||
const row = patch.row, exemplar = strings[row][headers.indexOf('Exemplar/caminho')];
|
||||
const final = outputs.find(out => path.normalize(path.join(DOC, out.filename)) === path.normalize(exemplar));
|
||||
const bytes = final ? Buffer.from(final.text) : await fs.readFile(exemplar);
|
||||
strings[row][headers.indexOf('SHA256')] = sha(bytes);
|
||||
strings[row][headers.indexOf('Bytes')] = String(bytes.length);
|
||||
sheet.getRangeByIndexes(row, 0, 1, headers.length).values = [strings[row]];
|
||||
}
|
||||
}
|
||||
workbook.recalculate();
|
||||
const result = sheet.getRangeByIndexes(0, 0, strings.length, headers.length).values;
|
||||
assert(result.every((r, i) => r.every((v, c) => matches(v, strings[i][c]))), 'Authored fields differ from inspected workbook cells');
|
||||
const inspected = await workbook.inspect({ kind: 'table', range: 'Registos!A1:C3', include: 'values', tableMaxRows: 3, tableMaxCols: 3 });
|
||||
assert(inspected.ndjson);
|
||||
// Preserve all original literal identifiers/offset dates. CSV has no workbook formatting or rendering surface.
|
||||
const text = serialize(strings), temporary = path.join(packetDirectory, filename + '.verified');
|
||||
await fs.writeFile(temporary, text, 'utf8');
|
||||
outputs.push({ filename, text, temporary, changes, appended: edit.append?.length || 0 });
|
||||
}
|
||||
// Commit only after every table and dependency has passed preservation checks.
|
||||
for (const out of outputs) await fs.copyFile(out.temporary, path.join(DOC, out.filename));
|
||||
await fs.writeFile(path.join(packetDirectory, packet.proof_filename || 'csv-authoring-proof.json'), JSON.stringify({
|
||||
database_access: false, changed: outputs.map(({ filename, text, changes, appended }) => ({ filename, sha256: sha(Buffer.from(text)), changes, appended })) }, null, 2));
|
||||
console.log(JSON.stringify({ tables: outputs.length, changed: outputs.map(({ filename, changes, appended }) => ({ filename, rows: changes.length, appended })), database_writes: 0 }));
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Scoped C86/C87 evidence into the same canonical audit; no CSV authoring here."""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
csv.field_size_limit(16000000)
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DOC = ROOT / 'docs/investmoz/v2/auditoria-portal'
|
||||
PRIVATE = ROOT / 'storage/app/private/investmoz-v2/portal-audit'
|
||||
OUT = PRIVATE / 'territory-resource-finalize-20261010'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc)
|
||||
|
||||
def read(file):
|
||||
data = file.read_bytes()
|
||||
return json.loads(data.decode('utf-16' if data[:2] == b'\xff\xfe' else 'utf-8-sig'))
|
||||
|
||||
browser = read(PRIVATE / 'territory-resource-chromium-20261010.json')
|
||||
sql = read(PRIVATE / 'territory-resource-persistence-qa.json')
|
||||
shot = read(PRIVATE / 'territory-resource-map-shot-20261010.json')
|
||||
before = read(PRIVATE / 'location-country-before-fix-qa.json')
|
||||
unit = read(PRIVATE / 'location-country-after-fix-qa.json')
|
||||
assessment = read(PRIVATE / 'location-country-assessment-qa.json')
|
||||
status = read(PRIVATE / 'territory-resource-final-status-qa.json')
|
||||
backup = read(PRIVATE / 'profile-installation-backup-qa.json')
|
||||
closed = read(PRIVATE / 'c37-manual-close-20261010/acceptance.json')
|
||||
assert browser['result'] == sql['result'] == unit['result'] == assessment['result'] == 'passed'
|
||||
assert all(browser['checks'].values()) and sql['read_only'] and len(sql['resources']) == 2
|
||||
assert before['result'] == 'failed' and 'received 200' in before['failures'][0]['message']
|
||||
assert shot['result'] == 'passed_read_only_map_screenshot' and shot['administrative_edition_caption_correct'] and shot['assessment_foreign_hierarchy_not_required']
|
||||
assert closed['state'] == 'closed_scoped_qa18_authentication'
|
||||
assert status['active_writes'] == 0 and status['active_manifest_sha256'] == '75352185c2a85c34ab726613db864a598e301a5b342ebbc80ac8e678eed17a94'
|
||||
assert status['code_hash'] != backup['code_hash'], 'Old backup must not be silently presented as candidate-matched'
|
||||
tag = '[C86/C87 territorial QA 10/10/2026] '
|
||||
evidence = ('C86 reproduzido: API aceitava país PT com hierarquia MZ por HTTP200; agora422 em country_code sem alterar payload/versão/auditoria do draft. '
|
||||
'UI retira os IDs moçambicanos do preenchimento ao mudar país; outro país admite rascunho incompleto, sem exigir província/distrito MZ. '
|
||||
'QA21 oportunidade51: country MZ, Cabo Delgado→ANCUABE, guardar recurso→nova sessão→recuperar campos/mapa→F5; '
|
||||
'dois limites administrativos visíveis. Outro recurso PT recuperado sem IDs MZ. Trocar pai limpa descendentes; QA23 organização alheia recebe403 em consulta e escrita. '
|
||||
'PostgreSQL QA em transação READ ONLY confirma2recursos/versões1 e a relação pai; zero ligações territoriais aceites criadas e nenhuma publicação. '
|
||||
'C87: legenda fixa de limites oficiais por verificar contrariava o contexto administrativo; agora distingue edição administrativa de parcela, sem alterar fonte/dados/aceitação oficial. '
|
||||
'Provas: territory-resource-chromium-20261010.json, territory-resource-persistence-qa.json, territory-resource-map-shot-20261010.json, location-country-{before,after}-fix-qa.json e location-country-assessment-qa.json. '
|
||||
'C37 autenticação18 fechado por confirmação manual anterior; não repetido.')
|
||||
gap = ('Cobertura demonstrada somente para localizações de preparação da oportunidade51, QA21 e recusaQA23; '
|
||||
'concluir projetos/clusters/corredores e restantes campos/mapas, níveis inferiores e perfis. Tiles externos bloqueados e não demonstrados. '
|
||||
'País da localização não é origem de solicitação: formulários restantes, filtros e estatísticas positivas continuam pendentes. '
|
||||
'Documentos/derivadas, workflow/restauração, contratos especializados e decisões modulares/SLA preservados. '
|
||||
'Não importar geometrias para inferir parcelas/direitos nem relações aceites;173 correspondências anteriores e Mandie/Guro continuam pendentes. '
|
||||
'A/B, móvel/acessibilidade integral, candidata e ativação não encerrados.')
|
||||
ids = {'AUD-OP-02', 'AUD-GEO-01', 'AUD-GEO-02', 'AUD-GEO-03'}
|
||||
files = []
|
||||
for filename in ['matriz-requisitos.csv', 'backlog-priorizado.csv']:
|
||||
source = DOC / filename
|
||||
rows = list(csv.reader(source.open(encoding='utf-8-sig', newline='')))
|
||||
headers = rows[0]
|
||||
patches = []
|
||||
for index, row in enumerate(rows[1:], 1):
|
||||
old = dict(zip(headers, row))
|
||||
if old['ID'] not in ids:
|
||||
continue
|
||||
if filename.startswith('matriz'):
|
||||
values = {'Evidência funcional': evidence, 'Interface': 'QA21 recursos51: seleção, gravação, edição/recuperação noutra sessão em390px, mapa de Ancuabe/CaboDelgado e Portugal incompleto sem hierarquia MZ. C87 legenda por propósito administrativo.',
|
||||
'API': 'POST guardar201; alteração PT+hierarquia MZ422 country_code; payload/versão guardados preservados; QA23 GET/POST403; geometria selecionada disponível e2áreas de contexto.',
|
||||
'Gravação': 'Dois recursos privados na tabela existente opportunity_location_drafts, ambosv1; nenhuma nova tabela/importação/conta/ligação aceite/publicação.',
|
||||
'Recuperação': 'Campos MZ/província/distrito e PT sem IDs recuperados noutra sessão; consulta SQL READ ONLY confirma payloads e parent_id.',
|
||||
'Resultado apresentado': 'Dois polígonos administrativos visíveis; edição2019-06-07 e fonte INE mantidas. Lista não reabre autenticidade oficial nem apresenta limite como parcela.',
|
||||
'Consolidação': 'Subcritérios C86/C87 atualizados; fontes, versões, IDs e estados globais preservados.'}
|
||||
values = {field: old[field] + ' | ' + tag + value for field, value in values.items()}
|
||||
values['Lacuna'] = tag + gap + ' | Histórico preservado: ' + old['Lacuna']
|
||||
variants = json.loads(old['Critérios e versões por origem (JSON)'])
|
||||
for variant in variants:
|
||||
variant['incremento_territorial_c86_c87_20261010'] = {'registado_em': stamp.isoformat(), 'evidencia': evidence, 'lacuna_atual': gap, 'ativacao': False}
|
||||
values['Critérios e versões por origem (JSON)'] = json.dumps(variants, ensure_ascii=False, separators=(',', ':'))
|
||||
else:
|
||||
values = {'Pendência': tag + gap + ' | Histórico: ' + old['Pendência'],
|
||||
'Correção/componentes': old['Correção/componentes'] + ' | ' + tag + 'OpportunityResources country–hierarchy validation; editor and assessment applicability; InteractiveInvestmentMap caption by existing purpose. Existing entities/services reused.',
|
||||
'Aceitação': old['Aceitação'] + ' | ' + tag + 'QA21 localizações MZ/PT guardadas e recuperadas noutra sessão; mapa2limites; QA23 recusa403; SQLREADONLY2recursos. Demais consumidores continuam pendentes.'}
|
||||
patches.append({'row': index, 'values': values})
|
||||
assert len(rows) == 3394 and len(patches) == len(ids)
|
||||
files.append({'filename': filename, 'input_sha256': hashlib.sha256(source.read_bytes()).hexdigest(), 'rows': rows,
|
||||
'patches': patches, 'allowed_columns': sorted({field for patch in patches for field in patch['values']}), 'append': []})
|
||||
(OUT / 'csv-edits.json').write_text(json.dumps({'files': files}, ensure_ascii=False), encoding='utf-8')
|
||||
|
||||
section = f'''## Lote territorial C86/C87 — {stamp.astimezone(timezone(timedelta(hours=2))).isoformat()}
|
||||
|
||||
**C37 encerrado somente para autenticação QA18, por confirmação manual expressa. Não foi repetido.** Aceitação ligada aos recibos de logout/recuperação em `c37-manual-close-20261010/acceptance.json`. A/B, auditoria integral e ativação continuam independentes.
|
||||
|
||||
| Requisito/caso | Correção e prova QA | Limite do fecho |
|
||||
| --- | --- | --- |
|
||||
| OP02; GEO01/02; C86 | API antes aceitava PT com província MZ por200; agora422 junto a country_code, preservando versão/payload/auditoria. UI limpa somente a seleção administrativa no preenchimento ao mudar país; estrangeiro incompleto pode guardar e recuperar. Conferência deixou de exigir província/distrito MZ a Portugal. | Aplicado a recursos de localização; não encerra origem, filtros ou estatísticas das solicitações. Registos anteriores não foram migrados/corrigidos silenciosamente. |
|
||||
| GEO01/02/03 | QA21 oportunidade51: MZ→CaboDelgado→ANCUABE→guardar→nova sessão→campos e mapa recuperados→F5. Distrito `5b03104f-9c47-5542-8de3-e81b8d5fdd55`; dois limites disponíveis e dois polígonos renderizados. Segundo recurso PT recuperado sem IDs MZ. Troca de pai limpa descendentes. | Demais consumidores/projetos/clusters/corredores e níveis inferiores não encerrados. Tiles externos bloqueados no Chromium; mapa base não demonstrado. Não houve nova importação cartográfica ou inferência de parcelas/direitos. |
|
||||
| OP02; autorização | QA23 organização alheia: consulta403 e escrita403. PostgreSQL QA READ ONLY confirmou os2recursos v1 e o parentesco geográfico; ligações territoriais aceites permaneceram {sql['accepted_territorial_links']}. | Outros perfis, documentos/derivadas e lifecycle/workflow conservam provas/pendências próprias. |
|
||||
| GEO03; C87 | Lista repetia “limites oficiais por verificar” mesmo para administrativos do INE. Texto agora distingue edição administrativa de parcela; fonte e edição2019-06-07 preservadas. Popup já distinguia o propósito, lista foi alinhada. Chromium390px e imagem inspecionada. | A aceitação oficial não foi reaberta. A edição não afirma atualidade jurídica ou disponibilidade de investimento. Outras camadas e geometria de parcela têm critérios próprios. |
|
||||
|
||||
Provas sanitizadas: `territory-resource-chromium-20261010.json`, `territory-resource-persistence-qa.json`, `territory-resource-map-shot-20261010.json` e regressões `location-country-*qa.json`. Suite de recursos5/71 passou antes do ajuste de conferência; regressão específica final1/20 passou depois. TypeScript passou após o seletor; compilação QA finalexit0. Contagens de testes não substituem os percursos acima. Falhas do harness (versão Inertia omitida, seletor por atributo incorreto e caixa dos nomes) foram preservadas como tentativas; não são falhas do portal. Nome oficial ANCUABE não foi renomeado.
|
||||
|
||||
Mesma matriz:3393requisitos/3393linhas/6845ocorrências; quatro requisitos recebem subcritérios, sem alteração de estados globais. Países/origem/estatísticas restantes,173 correspondências anteriores com Mandie/Guro ambíguo, documentos, Response/§27, workflow/restauração e decisões modulares/SLA permanecem abertos. Não iniciar novas jornadas educativas.
|
||||
|
||||
**Código preparado e demonstração QA, sem disponibilidade ativa.** Zero migrações/instalações novas e zero escritas ativas. Manifesto ativo SHA256 `{status['active_manifest_sha256']}` intacto. O backup/restauro QA18:19 liga-se ao fingerprint anterior; não foi apresentado como prova da revisão nova. Para futura candidata: renovar verificações/backup/restauro associados à revisão, testar compatibilidade/reversão no clone do ativo e preservar **030000→040000→09/190000**, sem repetir instalações QA. O backup ativo READ ONLY17:48 permanece histórico; nenhum corte autorizado.
|
||||
'''
|
||||
for filename in ['relatorio-executivo.md', 'backlog.md']:
|
||||
file = DOC / filename
|
||||
file.write_text('<!-- qa-territory-resource-c86-c87-20261010:begin -->\n' + section + '\n<!-- qa-territory-resource-c86-c87-20261010:end -->\n\n' + file.read_text(encoding='utf-8-sig'), encoding='utf-8')
|
||||
cases = '''## Casos C86/C87 — recursos e cartografia
|
||||
|
||||
**C86 (P1, OP02/GEO02):** editar localização MZ com província e mudar payload para PT mantendo IDs MZ. Esperado422 no país e preservação do original; observadoantes200 e gravação contraditória. Causa: catálogo de países e seleção territorial validados separadamente; editor e conferência não aplicavam país. Correção: regra cruzada no servidor; UI retira a hierarquia somente no preenchimento após mudança explícita; rascunho estrangeiro incompleto aceite; conferência não pede campos MZ para estrangeiro. Provas: regressão antes/depois, browserQA21 e SQLREADONLY; tentativa rejeitada não muda payload/versão/auditoria. Dois recursos guardados/recuperados noutra sessão; QA23 consulta/escrita403. Nenhuma conversão de registos históricos.
|
||||
|
||||
**C87 (P1, GEO03):** recuperar recurso CaboDelgado/Ancuabe e ler a lista sob o mapa. Esperado limite administrativo/edição/fonte; observado “limites oficiais por verificar” genérico. Causa: lista tinha texto fixo, apesar de popup usar purpose=administrative_boundary. Correção: lista usa o propósito existente e distingue edição administrativa de polígono a comparar; fontes oficiais aceites preservadas. Chromium390px mostrou dois limites e legenda correta, origemINE/2019-06-07. Tiles externos não testados; geometrias/dados/relações não modificados. Provas: territory-resource-map-shot-20261010.json e imagens anteriores preservadas.
|
||||
|
||||
C37 já encerrado por confirmação manual somente para autenticação QA18; não reabrir pelo estado histórico dos recibos. A/B e demais critérios independentes continuam abertos.
|
||||
'''
|
||||
file = DOC / 'casos-reproducao.md'
|
||||
file.write_text('<!-- qa-territory-resource-cases-20261010:begin -->\n' + cases + '\n<!-- qa-territory-resource-cases-20261010:end -->\n\n' + file.read_text(encoding='utf-8-sig'), encoding='utf-8')
|
||||
file = DOC / 'plano-ativacao.md'
|
||||
activation = f'''## C86/C87 — efeito e reversão, sem instalação nova
|
||||
|
||||
Recursos030000, perfis040000 e percurso09/190000 continuam instalados emQA; não repetir. Este lote altera validação cruzada país/hierarquia, preenchimento/conferência e legenda do componente existente; **0 migrations,0 tabelas e0 novas contas**. Dois recursos de contexto criados apenas emQA não serão transferidos ao ativo.
|
||||
|
||||
Fingerprint atual `{status['code_hash']}` diverge do backupQA18:19 (`{backup['code_hash']}`); essa prova permanece datada e não habilita a candidata atual. Não repetir testes de C37 fechado. Fixar futura revisão após critérios independentes, conferir/regenerar provas de compatibilidade/backup/restauro correspondentes e aplicar somente instalações ausentes na sequência030000→040000→09/190000 por ação autenticada. Correções, importação cartográfica e publicação continuam operações distintas.
|
||||
|
||||
Compatibilidade: schema/IDs/JSON existentes preservados; registos contraditórios antigos permanecem recuperáveis com mensagem e correção explícita, sem migração silenciosa. Reversão do lote: código/build anteriores, conservando drafts/versões/auditoria e sem desfazer schema; dados gravados pelo lote são compatíveis com as tabelas anteriores. Reversão ainda deve ser ensaiada na futura candidata/clone do ativo. Zero escritas ativas; C37 fechado não equivale a ativação ou fechoA/B.
|
||||
'''
|
||||
file.write_text('<!-- qa-territory-resource-activation-20261010:begin -->\n' + activation + '\n<!-- qa-territory-resource-activation-20261010:end -->\n\n' + file.read_text(encoding='utf-8-sig'), encoding='utf-8')
|
||||
(OUT / 'scope-proof.json').write_text(json.dumps({'recorded_at': stamp.isoformat(), 'c37_closed_scoped': True, 'cases': ['C86', 'C87'],
|
||||
'requirements_touched': sorted(ids), 'current_code_hash': status['code_hash'],
|
||||
'old_qa_backup_matches_current_code': False, 'active_writes': 0}, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'prepared_tables': 2, 'requirements_touched': len(ids), 'cases': ['C86', 'C87'], 'c37_closed_scoped': True, '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