diff --git a/app/Http/Controllers/OfferPublicCatalogueController.php b/app/Http/Controllers/OfferPublicCatalogueController.php new file mode 100644 index 00000000..01d4ef9b --- /dev/null +++ b/app/Http/Controllers/OfferPublicCatalogueController.php @@ -0,0 +1,65 @@ +filters($request); + + return Inertia::render('offers/catalogue', [...$catalogue->page($request, $filters), 'filters' => $filters, + 'mapData' => in_array($filters['view'] ?? '', ['map', 'combined'], true) ? $catalogue->map($filters) : null, + 'territorySelection' => app(InvestmentGeography::class)->selection($filters), + 'references' => ['categories' => OpportunityManagement::CATEGORIES, 'options' => [], 'clusters' => [], 'corridors' => [], 'projects' => [], 'locations' => [], 'sectors' => []]]); + } + + public function show(int $id, OfferPublicCatalogue $catalogue): Response + { + return Inertia::render('offers/public-detail', ['offer' => $catalogue->detail($id)]); + } + + public function search(Request $request, OfferPublicCatalogue $catalogue): JsonResponse + { + return response()->json($catalogue->page($request, $catalogue->filters($request))); + } + + public function detail(int $id, OfferPublicCatalogue $catalogue): JsonResponse + { + return response()->json(['data' => $catalogue->detail($id)]); + } + + public function map(Request $request, OfferPublicCatalogue $catalogue): JsonResponse + { + return response()->json($catalogue->map($catalogue->filters($request))); + } + + public function export(Request $request, OfferPublicCatalogue $catalogue): StreamedResponse + { + $result = $catalogue->records($catalogue->filters($request)); + + return response()->streamDownload(function () use ($result) { + $stream = fopen('php://output', 'w'); + if ($stream === false) { + return; + } + fwrite($stream, "\xEF\xBB\xBF"); + fputcsv($stream, ['Título', 'Categoria', 'Localização', 'Preço', 'Moeda', 'Publicação', 'URL'], ';', '"', ''); + foreach ($result['records'] as $record) { + $fields = [$record['title'], $record['category'], implode(' / ', array_column($record['location'], 'name')), + $record['price']['mode'] === 'negotiable' ? 'A negociar' : $record['price']['amount'], $record['price']['currency'], $record['published_at'], url($record['url'])]; + fputcsv($stream, array_map(fn ($value) => preg_match('/^[\s]*[=+@-]/u', (string) $value) ? "'".$value : $value, $fields), ';', '"', ''); + } + fclose($stream); + }, 'ofertas.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'X-Export-Limit' => '1000', 'X-Results-Truncated' => $result['truncated'] ? 'true' : 'false']); + } +} diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 3bd96dbb..309136ed 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -21,7 +21,7 @@ class InvestmentCatalogue 'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']]; /** @return array */ - public function filters(Request $request): array + public function filters(Request $request, bool $offerPrices = false): array { $filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'], 'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'], 'preparation' => ['nullable', Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))], @@ -34,9 +34,9 @@ class InvestmentCatalogue 'zee_id' => ['nullable', 'uuid'], 'zfi_id' => ['nullable', 'uuid'], 'park_id' => ['nullable', 'uuid'], 'dimension' => ['nullable', 'in:cluster,corridor,zee,zfi'], 'view' => ['nullable', 'in:grid,list,map,combined,corridors'], 'maturity_level' => ['nullable', 'integer', 'between:1,8'], 'facets' => ['nullable', 'array', 'max:20'], 'facets.*' => ['integer', 'min:1'], - 'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], - 'max_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])], - 'currency' => ['nullable', 'required_with:min_amount,max_amount', 'in:MZN,USD,EUR,ZAR'], + 'min_amount' => $offerPrices ? ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'] : ['nullable', 'numeric', 'min:0', 'max:999999999999'], + 'max_amount' => $offerPrices ? ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'] : ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])], + 'currency' => ['nullable', 'required_with:min_amount,max_amount', $offerPrices ? 'in:MZN,USD' : 'in:MZN,USD,EUR,ZAR'], 'before' => ['nullable', 'date_format:Y-m-d'], 'sort' => ['nullable', 'in:recent,title,amount_asc,amount_desc'], 'association_type' => ['nullable', 'in:'.implode(',', array_keys(OpportunityClassification::TARGETS))], 'association_target' => ['nullable', 'string', 'max:80'], diff --git a/app/Services/OfferPublicCatalogue.php b/app/Services/OfferPublicCatalogue.php new file mode 100644 index 00000000..85a1b18d --- /dev/null +++ b/app/Services/OfferPublicCatalogue.php @@ -0,0 +1,236 @@ +syntheticFixtureMode && IsolatedQaDatabase::allowsSchemaChanges(); + } + + /** Exact ordering of decimal strings, without currency conversion or floating point. + * @param string $a @param string $b */ + public static function comparePrice(string $a, string $b): int + { + $parts = static function (string $value): array { + [$whole, $fraction] = [...explode('.', $value), '']; + + return [ltrim($whole, '0') ?: '0', str_pad($fraction, 2, '0')]; + }; + [$aw, $af] = $parts($a); + [$bw, $bf] = $parts($b); + + return (strlen($aw) <=> strlen($bw)) ?: (strcmp($aw, $bw) <=> 0) ?: (strcmp($af, $bf) <=> 0); + } + + /** @return array */ + public function filters(Request $request): array + { + $filters = array_intersect_key(app(InvestmentCatalogue::class)->filters($request, true), array_flip([ + 'q', 'category', 'province_id', 'district_id', 'currency', 'min_amount', 'max_amount', 'sort', 'view', 'page', 'per_page', + ])); + $extra = $request->validate(['price_mode' => 'nullable|in:amount,negotiable']); + $filters = [...$filters, ...$extra]; + if (isset($filters['min_amount'], $filters['max_amount']) && self::comparePrice($filters['min_amount'], $filters['max_amount']) > 0) { + throw ValidationException::withMessages(['max_amount' => 'O preço máximo deve ser igual ou superior ao mínimo.']); + } + if (in_array($filters['sort'] ?? '', ['amount_asc', 'amount_desc'], true) && empty($filters['currency'])) { + throw ValidationException::withMessages(['currency' => 'Seleccione uma moeda para ordenar por preço.']); + } + if (($filters['price_mode'] ?? '') === 'negotiable' && (isset($filters['min_amount']) || isset($filters['max_amount']))) { + throw ValidationException::withMessages(['price_mode' => 'Retire o intervalo de montantes para procurar preços a negociar.']); + } + if (($filters['price_mode'] ?? '') === 'negotiable' && ! empty($filters['currency'])) { + throw ValidationException::withMessages(['currency' => 'A moeda de um preço a negociar não foi indicada. Retire este filtro.']); + } + + return $filters; + } + + /** The explicit public snapshot, never a projection of current private columns. + * @return array|null */ + private function project(int $id, mixed $publication): ?array + { + if (! is_array($publication) || ($publication['contract'] ?? null) !== self::CONTRACT || ($publication['qa_fixture'] ?? false) !== true) { + return null; + } + $v = Validator::make($publication, [ + 'payload' => 'required|array:title,summary,category,province_id,district_id,price_mode,amount,currency', + 'payload.title' => 'required|string|max:150', 'payload.summary' => 'required|string|max:1500', + 'payload.category' => ['required', Rule::in(OpportunityManagement::CATEGORIES)], + 'payload.province_id' => 'nullable|uuid', 'payload.district_id' => 'nullable|uuid', + 'payload.price_mode' => 'required|in:amount,negotiable', + 'payload.amount' => ['nullable', 'string', 'required_if:payload.price_mode,amount', 'prohibited_unless:payload.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'], + 'payload.currency' => 'nullable|required_if:payload.price_mode,amount|prohibited_unless:payload.price_mode,amount|in:MZN,USD', + 'approval.state' => 'required|in:approved', 'approval.hash' => 'required|string|size:64', 'approval.actor_id' => 'required|integer|min:1', + 'approval.at' => 'required|date', 'publication.state' => 'required|in:published', 'publication.hash' => 'required|string|size:64', + 'publication.actor_id' => 'required|integer|min:1|different:approval.actor_id', 'publication.at' => 'required|date|after_or_equal:approval.at|before_or_equal:now', + 'revision' => 'required|integer|min:1', + ]); + if ($v->fails()) { + return null; + } + $p = $v->validated()['payload']; + $hash = self::projectionHash($p); + if (! hash_equals($hash, $publication['approval']['hash']) || ! hash_equals($hash, $publication['publication']['hash'])) { + return null; + } + try { + $path = app(InvestmentGeography::class)->selection(['province_id' => $p['province_id'] ?? null, 'district_id' => $p['district_id'] ?? null]); + } catch (ValidationException $error) { + return null; + } + + return ['id' => $id, 'title' => $p['title'], 'summary' => $p['summary'], 'category' => $p['category'], + 'location' => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'level' => $node->location_type], $path), + 'price' => ['mode' => $p['price_mode'], 'amount' => $p['amount'] ?? null, 'currency' => $p['currency'] ?? null], + 'status' => 'published', 'published_at' => CarbonImmutable::parse($publication['publication']['at'])->utc()->toIso8601String(), 'revision' => $publication['revision'], 'url' => '/ofertas/'.$id]; + } + + /** @param array $payload */ + public static function projectionHash(array $payload): string + { + ksort($payload); + + return hash('sha256', json_encode(['contract' => self::CONTRACT, 'payload' => $payload], JSON_THROW_ON_ERROR)); + } + + /** @param array $filters + * @return array{records:list>,truncated:bool} */ + public function records(array $filters = []): array + { + if (! $this->fixtureReadAllowed()) { + return ['records' => [], 'truncated' => false]; + } + $rows = Opportunity::query()->whereNotNull('workflow_context->private_offer_preparation')->whereNotNull('workflow_context->offer_publication') + ->orderBy('id')->limit(1001)->get(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot")]); + $records = []; + foreach ($rows->take(1000) as $row) { + if (! is_string($row->getAttribute('public_snapshot'))) { + continue; + } + try { + $publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $error) { + continue; + } + $p = $this->project($row->id, $publication); + if (! $p) { + continue; + } + if (! empty($filters['q']) && ! str_contains(mb_strtolower($p['title'].' '.$p['summary']), mb_strtolower($filters['q']))) { + continue; + } + if (! empty($filters['category']) && $p['category'] !== $filters['category']) { + continue; + } + if (! empty($filters['province_id']) && ($p['location'][0]['id'] ?? null) !== $filters['province_id']) { + continue; + } + if (! empty($filters['district_id']) && ($p['location'][1]['id'] ?? null) !== $filters['district_id']) { + continue; + } + if (! empty($filters['price_mode']) && $p['price']['mode'] !== $filters['price_mode']) { + continue; + } + if (! empty($filters['currency']) && $p['price']['currency'] !== $filters['currency']) { + continue; + } + if (isset($filters['min_amount']) && ($p['price']['amount'] === null || self::comparePrice($p['price']['amount'], $filters['min_amount']) < 0)) { + continue; + } + if (isset($filters['max_amount']) && ($p['price']['amount'] === null || self::comparePrice($p['price']['amount'], $filters['max_amount']) > 0)) { + continue; + } + $records[] = $p; + } + $sort = $filters['sort'] ?? 'recent'; + usort($records, static function ($a, $b) use ($sort): int { + if ($sort === 'title') { + return strnatcasecmp($a['title'], $b['title']) ?: ($a['id'] <=> $b['id']); + } + if (in_array($sort, ['amount_asc', 'amount_desc'], true)) { + if ($a['price']['amount'] === null || $b['price']['amount'] === null) { + return ($a['price']['amount'] === null) <=> ($b['price']['amount'] === null); + } + + return self::comparePrice($a['price']['amount'], $b['price']['amount']) * ($sort === 'amount_desc' ? -1 : 1) ?: ($a['id'] <=> $b['id']); + } + + return strcmp($b['published_at'], $a['published_at']) ?: ($b['id'] <=> $a['id']); + }); + + return ['records' => $records, 'truncated' => $rows->count() > 1000]; + } + + /** @return array */ + public function detail(int $id): array + { + abort_unless($this->fixtureReadAllowed(), 404); + $row = Opportunity::query()->whereKey($id)->whereNotNull('workflow_context->private_offer_preparation')->first(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot")]); + if (! $row || ! is_string($row->getAttribute('public_snapshot'))) { + abort(404); + } + try { + $publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $error) { + abort(404); + } + $record = $this->project($id, $publication); + abort_unless($record !== null, 404); + + return $record; + } + + /** @param array $filters + * @return array{results:LengthAwarePaginator>,truncated:bool} */ + public function page(Request $request, array $filters): array + { + $result = $this->records($filters); + $size = (int) ($filters['per_page'] ?? 12); + $page = (int) ($filters['page'] ?? 1); + + return ['results' => new LengthAwarePaginator(array_slice($result['records'], ($page - 1) * $size, $size), count($result['records']), $size, $page, + ['path' => $request->url(), 'query' => $filters]), 'truncated' => $result['truncated']]; + } + + /** @param array $filters + * @return array */ + public function map(array $filters): array + { + $result = $this->records($filters); + $areas = []; + $seen = []; + foreach ($result['records'] as $record) { + $id = $record['location'][array_key_last($record['location'])]['id'] ?? null; + if (! $id || isset($seen[$id])) { + continue; + } + $seen[$id] = true; + $areas = [...$areas, ...app(TerritoryGeometry::class)->context($id)['areas']]; + if (count($seen) >= 30) { + break; + } + } + + return ['features' => [], 'areas' => $areas, 'matched_count' => count($result['records']), 'exact_locations_disclosed' => false, + 'truncated' => $result['truncated'] || count($seen) >= 30]; + } +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index e5d56d52..3b1e39dd 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -10163,4 +10163,13 @@ Reutiliza perfis/MFA/independência, workflow, tarefas, auditoria e caixa de not QA sintética offers_phase1_20261114_test:55441: regressão 35/499, final F2 11/160, complemento de contribuidores 1/14 e HTML final 1/10 aprovados; grupos sobrepostos, não somar. Pint, PHPStan, tipos, lint/formatação focados e build QA aprovados. Testa autoria/contribuidores/âmbito/MFA/revogação, snapshots, rejeição/correcção/reenvio, reatribuição, fila sem duplicação, rollback, notificações idempotentes, fonte alterada, recuperação e recusa de publicação. Conferência visual mantém a ressalva da ferramenta; formatação global continua nos 61 ficheiros preexistentes exteriores ao delta, sem dispensa. Entregabilidade real não demonstrada. Relatório: docs/investmoz/v2/auditoria-portal/ofertas-fase2-entrega-qa-20261114.md. Recibo: outputs/offers-private-phase2-20261114/qa-result.json. -Saldo RF-MN-003: F1/F2 implementadas e verificadas tecnicamente em QA, não instaladas; requisito integral/Procuras permanecem parciais. Fase 3 e instalação em homologação/produção dependem de autorização expressa própria. Não foram iniciados catálogo público, NDA, comprovativos, fotografias ou validade. Próximo passo é rever esta entrega e conferir visualmente quando o ambiente permitir, sem reabrir provas técnicas aceites. \ No newline at end of file +Saldo RF-MN-003: F1/F2 implementadas e verificadas tecnicamente em QA, não instaladas; requisito integral/Procuras permanecem parciais. Fase 3 e instalação em homologação/produção dependem de autorização expressa própria. Não foram iniciados catálogo público, NDA, comprovativos, fotografias ou validade. Próximo passo é rever esta entrega e conferir visualmente quando o ambiente permitir, sem reabrir provas técnicas aceites. +# 14/11/2026 — Ofertas, Fase 3 em QA (RF-MN-003) + +Base F2 `91c18573`, F1 `1f2990e4` e plano `6e302db6` preservados. Consulta `/ofertas`, detalhe, API e CSV reutilizam filtros, paginação, mapa e layout público existentes. Categoria, província/distrito e preço são aplicados à projecção pública aprovada/publicada, separada da edição privada; intervalos e ordenação exigem moeda, sem câmbio. Mapas apresentam limites administrativos, sem pontos dos negócios. Contactos, documentos e dados financeiros privados não são seleccionados pelo consumidor. Sem reorganização do menu, novas tabelas, alterações em `mne`, instalações ou dados operacionais. + +A publicação efectiva permanece bloqueada, incluindo rotas alternativas. O catálogo normal fica vazio/detalhe 404. Apenas injecção interna de teste, com a guarda de ambiente testing/base isolada em 55441, permite demonstrar projecções sintéticas; parâmetros HTTP e JSON armazenado não abrem a barreira. Não se declarou uma publicação real nem se implementaram as Fases 4/5. Uma revisão privada pendente/rejeitada não substitui o snapshot público anterior. + +QA final: 10 testes/133 asserções aprovados; inclui acessos directos, estados inválidos, isolamento, preservação, hierarquia, decimais/moedas, mapa sem localização exacta, CSV, HTML compilado e regressão nativa autocontida. Passagem anterior: 36/41 aprovados; cinco casos nativos impedidos por referência Inhambane ausente na base sintética. Tipos, lint focado, Pint, PHPStan e build aprovados. Formatação global: os mesmos 61 ficheiros exteriores ao delta. Conferência visual/móvel pendente: `The browser access host is unavailable`; não substituída por aprovação técnica. Email real F2 mantém pendência. + +Estado: implementado em QA com barreira fechada, não instalado; módulo integral e Procuras parciais. Limites representados: 1.000 candidatos e contexto de 30 territórios, assinalados quando truncados. Relatório: `docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md`. Recibo: `outputs/offers-public-phase3-20261114/qa-result.json`. Próximo passo: conferência desktop/móvel e apreciação da entrega QA; sem instalação ou avanço de fase por inferência. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index 8dc782d9..5ac4e8e5 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -163,7 +163,7 @@ RF-MIN-005,"Controlar cessão, mudança de controlo e aprovações.",upgrade v4 RF-MIN-006,Pesquisar e corresponder oportunidades mineiras.,upgrade v4 SHA f0b9815…; p. 3275; requirements-audit.csv,oportunidades,/api/v1/business-opportunities; /api/v1/business-opportunities/{slug}; /investidor/terra-duat; /investidor/terra-duat/novo; /investidor/terra-duat/{id}; /investidor/terra-duat/{id}/actualizacoes; /investidor/terra-duat/{id}/documentos; /investidor/terra-duat/{id}/documentos/{attachment}; /investidor/terra-duat/{id}/editar; /investidor/terra-duat/{id}/imprimir; /investidor/terra-duat/{id}/solicitar-apoio; /management/opportunities,"OpportunityManagement, OpportunityCanonicalContract/Response, OpportunityReviewMateriality, PortalProfileAccess, OpportunityResources. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.",outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,Sem evidência suficiente,Reconciliar contrato/cláusula e provas por categoria; completar apenas falha/variante executável identificada. Response normativo não é completo. Critério individual original preservado; cobertura atual ainda não individualizada.,"030000→040000 já instalados; contratos/resources existentes, sessão/MFA, ClamD e worker para ficheiros; gates das seis categorias ON. Variantes modulares/offers continuam separadas. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.","RF-MIN-006: Filtros incluem recurso, fase, título, localização, valor, modalidade e infra-estrutura; recomendação é explicável e revista por gestor.",P2,RF-MIN-006: Relatório 25/09 conservado; estado histórico não promove conclusão,RF-MIN-006,1,"[{""id"":""RF-MIN-006"",""pedido"":""Pesquisar e corresponder oportunidades mineiras."",""fontes"":[""upgrade v4 SHA f0b9815…; p. 3275; requirements-audit.csv""],""aceitacao"":""Filtros incluem recurso, fase, título, localização, valor, modalidade e infra-estrutura; recomendação é explicável e revista por gestor."",""estado"":""parcial"",""variantes"":""Relatório 25/09 conservado; estado histórico não promove conclusão"",""modulo"":""oportunidades""}]",Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,public.opportunities: 6 registos; public.opportunity_images: tabela ausente; public.opportunity_documents: tabela ausente; public.opportunity_location_drafts: tabela ausente,"A evidência existente é do domínio ou uma declaração histórica; não identifica a demonstração integral desta cláusula. Não afirmar implementado/parcial apenas por componente, menu ou teste global.",Reconciliar contrato/cláusula e provas por categoria; completar apenas falha/variante executável identificada. Response normativo não é completo. Critério individual original preservado; cobertura atual ainda não individualizada.,"OpportunityManagement, OpportunityCanonicalContract/Response, OpportunityReviewMateriality, PortalProfileAccess, OpportunityResources","030000→040000 já instalados; contratos/resources existentes, sessão/MFA, ClamD e worker para ficheiros; gates das seis categorias ON. Variantes modulares/offers continuam separadas.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,Reconciliação e prova individual antes de estimar alteração,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: oportunidades. Esforço de código adicional só após lacuna reproduzida.,outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,oportunidades,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,Verificar individualmente este critério no contrato e percurso aplicável; a matriz inicial conserva texto/fonte sem o promover a aceitação.,"QA de UI quando browser disponível; reutilizar o domínio existente. Dados/decisões específicos no backlog, sem segunda validação de fonte oficial.",app/Services/OpportunityManagement.php; app/Services/OpportunityResources.php; resources/js/pages/management/opportunity-draft.tsx — componentes do domínio encontrados; correspondência integral deste requisito não presumida,"OpportunityManagementTest, OpportunityResourcesTest, OpportunityOriginReviewTest; suite global QA e GET por perfil disponíveis. Evidência do domínio não demonstra integralmente esta cláusula; UI individual pendente.",Não demonstrado individualmente; localizar critério e executar em QA.,,,,"0,5–2 h de análise/prova por cláusula sem correspondência, agrupada por contrato/consumidor; não somar estas faixas como projetos independentes. Implementação indeterminada até localizar lacuna.",,, RF-MN-001,Registar utilizadores singulares e colectivos com verificação de contactos.,upgrade v4 SHA f0b9815…; p. 2957; requirements-audit.csv | portal do investidor\upgrade do portal de investimentos.docx; SHA 471012abb0f56377956f4ab822fe3186e2561bee0471508a5d807091f2e0154f; w:p 573 | portal do investidor\Portal V4\upgrade do portal de investimentos v6 programacao.docx; SHA e1169a7743b13ff6c950d3ffd4672a2afea32e81da84885e11154b7d8de9241e; w:p 3409 | portal do investidor\Portal V4\upgrade do portal de investimentos v1.docx; SHA f70ce3075523c6278e00dce15b9c7acf2b690679e16959ae5f7d007e754cfc6e; w:p 580 | portal do investidor\Portal V4\upgrade do portal de investimentos v2.docx; SHA 51b8f0ad3296d1064a5c12b450874fc72640869f10a164319cd7f3153dd3a086; w:p 2214 | portal do investidor\Portal V4\upgrade do portal de investimentos v5 programacao.docx; SHA edece80c0519537244f8a8b86456af0aa92de1c526f791bb0ee9b85af7a7e8b2; w:p 3409,CRM/solicitações,//; /Simulador; /api/v1/documents/recommendations; /api/v1/investment/submissions; /api/v1/investment/submissions/{id}; /api/v1/investment/submissions/{id}/attachments; /api/v1/investment/submissions/{id}/attachments/{attachment}; /api/v1/investment/submissions/{id}/submit; /apoio; /balcao-virtual; /como-investir; /documentos,"investors/contacts/leads/tasks, OpportunityContacts, acompanhamento interno existente. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.",outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,Sem evidência suficiente,"Converter sem duplicar, tarefa/responsável/próxima ação recuperáveis; corrigir apenas falha comprovada. Critério individual original preservado; cobertura atual ainda não individualizada.",Relações mestre/contexto e autorizações; não depende de CRM externo. C131 sóQA. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,RF-MN-001: Conta só é activada após confirmação do correio electrónico ou telefone; tentativas e falhas ficam registadas. | MASTER-471012ab-0051: Registar utilizadores singulares e colectivos com verificação de contactos. | MASTER-e1169a77-0201: Registar utilizadores singulares e colectivos com verificação de contactos. | MASTER-f70ce307-0057: Registar utilizadores singulares e colectivos com verificação de contactos. | MASTER-51b8f0ad-0137: Registar utilizadores singulares e colectivos com verificação de contactos. | MASTER-edece80c-0201: Registar utilizadores singulares e colectivos com verificação de contactos.,P2,RF-MN-001: Relatório 25/09 conservado; estado histórico não promove conclusão | MASTER-471012ab-0051: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-e1169a77-0201: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-f70ce307-0057: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-51b8f0ad-0137: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-edece80c-0201: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas,RF-MN-001 | MASTER-471012ab-0051 | MASTER-e1169a77-0201 | MASTER-f70ce307-0057 | MASTER-51b8f0ad-0137 | MASTER-edece80c-0201,6,"[{""id"":""RF-MN-001"",""pedido"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""fontes"":[""upgrade v4 SHA f0b9815…; p. 2957; requirements-audit.csv""],""aceitacao"":""Conta só é activada após confirmação do correio electrónico ou telefone; tentativas e falhas ficam registadas."",""estado"":""parcial"",""variantes"":""Relatório 25/09 conservado; estado histórico não promove conclusão"",""modulo"":""CRM/solicitações""},{""id"":""MASTER-471012ab-0051"",""pedido"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""fontes"":[""portal do investidor\\upgrade do portal de investimentos.docx; SHA 471012abb0f56377956f4ab822fe3186e2561bee0471508a5d807091f2e0154f; w:p 573""],""aceitacao"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""CRM/solicitações""},{""id"":""MASTER-e1169a77-0201"",""pedido"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v6 programacao.docx; SHA e1169a7743b13ff6c950d3ffd4672a2afea32e81da84885e11154b7d8de9241e; w:p 3409""],""aceitacao"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""CRM/solicitações""},{""id"":""MASTER-f70ce307-0057"",""pedido"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v1.docx; SHA f70ce3075523c6278e00dce15b9c7acf2b690679e16959ae5f7d007e754cfc6e; w:p 580""],""aceitacao"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""CRM/solicitações""},{""id"":""MASTER-51b8f0ad-0137"",""pedido"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v2.docx; SHA 51b8f0ad3296d1064a5c12b450874fc72640869f10a164319cd7f3153dd3a086; w:p 2214""],""aceitacao"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""CRM/solicitações""},{""id"":""MASTER-edece80c-0201"",""pedido"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v5 programacao.docx; SHA edece80c0519537244f8a8b86456af0aa92de1c526f791bb0ee9b85af7a7e8b2; w:p 3409""],""aceitacao"":""Registar utilizadores singulares e colectivos com verificação de contactos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""CRM/solicitações""}]",Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,public.interest_requests: 4 registos; public.leads: 0 registos; public.investors: 3 registos,"A evidência existente é do domínio ou uma declaração histórica; não identifica a demonstração integral desta cláusula. Não afirmar implementado/parcial apenas por componente, menu ou teste global.","Converter sem duplicar, tarefa/responsável/próxima ação recuperáveis; corrigir apenas falha comprovada. Critério individual original preservado; cobertura atual ainda não individualizada.","investors/contacts/leads/tasks, OpportunityContacts, acompanhamento interno existente",Relações mestre/contexto e autorizações; não depende de CRM externo. C131 sóQA.,Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,Reconciliação e prova individual antes de estimar alteração,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: acompanhamento. Esforço de código adicional só após lacuna reproduzida.,outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,acompanhamento,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,Verificar individualmente este critério no contrato e percurso aplicável; a matriz inicial conserva texto/fonte sem o promover a aceitação.,"QA de UI quando browser disponível; reutilizar o domínio existente. Dados/decisões específicos no backlog, sem segunda validação de fonte oficial.",app/Http/Controllers/CrmController.php; app/Services/DeclaredOriginStatistics.php — componentes do domínio encontrados; correspondência integral deste requisito não presumida,"InterestSubmissionTest, InvestmentWorkspaceTest, PortalOriginCompatibilityTest; suite global QA e GET por perfil disponíveis. Evidência do domínio não demonstra integralmente esta cláusula; UI individual pendente.",Não demonstrado individualmente; localizar critério e executar em QA.,,,,"0,5–2 h de análise/prova por cláusula sem correspondência, agrupada por contrato/consumidor; não somar estas faixas como projetos independentes. Implementação indeterminada até localizar lacuna.",,, RF-MN-002,"Gerir perfis, representantes, funções e organizações.",upgrade v4 SHA f0b9815…; p. 2961; requirements-audit.csv,governação/outros,/api/v1/investment/admin/interests/{interest}/status; /management/history; /management/interests; /management/interests/{interest}; /management/interests/{interest}/consent/contact; /management/interests/{interest}/consent/proponent; /management/opportunities; /management/opportunities/create; /management/opportunities/drafts; /management/opportunities/{id}/draft; /management/opportunities/{id}/edit; /management/opportunities/{id}/lifecycle,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual. | 02/11/2026: profile-json preserva erros por campo e reautenticacao em falhas HTML/419 e rejeita JSON invalido; contexto de investimento rejeita falso sucesso HTTP 200 e liberta keepalive, sem repetir escritas. 26 testes focados, TypeScript e build aprovados. Prova local parcial; sem dados/permissoes/publicacao. | Retoma 02/11/2026: profileJson GET independente de cookie CSRF malformado; POST recusa antes do transporte com mensagem de recuperacao. Preservadas validacao e credenciais. 14 testes focados aprovados, incluindo separadores de cookie sem espaco; sem mudanca de direitos.",Sem evidência suficiente,Localizar obrigação verificável e consumidores/contrato atuais antes de estimar implementação ou declarar conclusão. Critério individual original preservado; cobertura atual ainda não individualizada.,"Ler secção/versão correspondente; distinguir requisito funcional, decisão e exemplo. Fontes oficiais já aceites. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.",RF-MN-002: Uma organização pode ter vários utilizadores; cada acção identifica o utilizador e a função.,P2,RF-MN-002: Relatório 25/09 conservado; estado histórico não promove conclusão,RF-MN-002,1,"[{""id"":""RF-MN-002"",""pedido"":""Gerir perfis, representantes, funções e organizações."",""fontes"":[""upgrade v4 SHA f0b9815…; p. 2961; requirements-audit.csv""],""aceitacao"":""Uma organização pode ter vários utilizadores; cada acção identifica o utilizador e a função."",""estado"":""parcial"",""variantes"":""Relatório 25/09 conservado; estado histórico não promove conclusão"",""modulo"":""governação/outros""}]",Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,Requisito transversal; consultar estado-ativo-after.json,"A evidência existente é do domínio ou uma declaração histórica; não identifica a demonstração integral desta cláusula. Não afirmar implementado/parcial apenas por componente, menu ou teste global.",Localizar obrigação verificável e consumidores/contrato atuais antes de estimar implementação ou declarar conclusão. Critério individual original preservado; cobertura atual ainda não individualizada.,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer","Ler secção/versão correspondente; distinguir requisito funcional, decisão e exemplo. Fontes oficiais já aceites.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,Reconciliação e prova individual antes de estimar alteração,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: governação e demais cláusulas. Esforço de código adicional só após lacuna reproduzida.,outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,governação e demais cláusulas,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,Verificar individualmente este critério no contrato e percurso aplicável; a matriz inicial conserva texto/fonte sem o promover a aceitação.,"QA de UI quando browser disponível; reutilizar o domínio existente. Dados/decisões específicos no backlog, sem segunda validação de fonte oficial.",AGENTS.md — componentes do domínio encontrados; correspondência integral deste requisito não presumida,; suite global QA e GET por perfil disponíveis. Evidência do domínio não demonstra integralmente esta cláusula; UI individual pendente.,Não demonstrado individualmente; localizar critério e executar em QA.,,,,"0,5–2 h de análise/prova por cláusula sem correspondência, agrupada por contrato/consumidor; não somar estas faixas como projetos independentes. Implementação indeterminada até localizar lacuna.",,, -RF-MN-003,"Criar, guardar, editar e submeter ofertas e procuras.",upgrade v4 SHA f0b9815…; p. 2965; requirements-audit.csv | portal do investidor\upgrade do portal de investimentos.docx; SHA 471012abb0f56377956f4ab822fe3186e2561bee0471508a5d807091f2e0154f; w:p 581 | portal do investidor\Portal V4\upgrade do portal de investimentos v6 programacao.docx; SHA e1169a7743b13ff6c950d3ffd4672a2afea32e81da84885e11154b7d8de9241e; w:p 3417 | portal do investidor\Portal V4\upgrade do portal de investimentos v1.docx; SHA f70ce3075523c6278e00dce15b9c7acf2b690679e16959ae5f7d007e754cfc6e; w:p 588 | portal do investidor\Portal V4\upgrade do portal de investimentos v2.docx; SHA 51b8f0ad3296d1064a5c12b450874fc72640869f10a164319cd7f3153dd3a086; w:p 2222 | portal do investidor\Portal V4\upgrade do portal de investimentos v5 programacao.docx; SHA edece80c0519537244f8a8b86456af0aa92de1c526f791bb0ee9b85af7a7e8b2; w:p 3417,governação/outros,/api/v1/investment/admin/interests/{interest}/status; /management/history; /management/interests; /management/interests/{interest}; /management/interests/{interest}/consent/contact; /management/interests/{interest}/consent/proponent; /management/opportunities; /management/opportunities/create; /management/opportunities/drafts; /management/opportunities/{id}/draft; /management/opportunities/{id}/edit; /management/opportunities/{id}/lifecycle,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual. | 14/11/2026 - Ofertas F1: oportunidades como cadastro principal; criar, guardar, recuperar, editar e submeter em privado; autorizacao existente, CRM e localizacao reutilizados; sem menu, publicacao ou instalacao. | 14/11/2026 F2 autorizada: circuito editorial privado no workflow e Dashboard existentes; revisao/atribuicao/rejeicao/correccao/reenvio/aprovacao interna; decisoes por snapshot/hash/ciclo, tarefas, auditoria e notificacoes. Sem publicacao ou instalacao.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual. | QA: PrivateOfferPreparationTest.php; regressao 35 testes/548 assercoes e passagem final focada 16/172 (grupos sobrepostos, nao somar). outputs/offers-private-phase1-20261114/qa-result.json. | F2 QA: 35 testes/499 assercoes; final focado 11/160; complemento contribuidores 1/14 e HTML compilado 1/10 aprovados, grupos sobrepostos. tests/Feature/PrivateOfferEditorialTest.php; outputs/offers-private-phase2-20261114/qa-result.json.",Parcial - Ofertas Fases 1 e 2 implementadas e verificadas tecnicamente em QA; nao instaladas,"Fase 3 e restantes fases nao autorizadas: catalogo/publicacao de Ofertas, NDA, comprovativos, fotografias, validade e restantes ramos. Procuras nao abrangidas. Conferencia visual pendente. Check global de formatacao: 61 ficheiros preexistentes fora do delta, falha nao dispensada. Email operacional/entregabilidade nao activados.",Autorizacao expressa para Fase 3 e para instalacao; designacoes operacionais e configuracao de entrega reais apenas quando aplicaveis. Sem novos papeis ou permissoes por inferencia.,"RF-MN-003: Rascunho persiste; submissão bloqueia campos essenciais e gera número de protocolo. | MASTER-471012ab-0052: Criar, guardar, editar e submeter ofertas e procuras. | MASTER-e1169a77-0202: Criar, guardar, editar e submeter ofertas e procuras. | MASTER-f70ce307-0058: Criar, guardar, editar e submeter ofertas e procuras. | MASTER-51b8f0ad-0138: Criar, guardar, editar e submeter ofertas e procuras. | MASTER-edece80c-0202: Criar, guardar, editar e submeter ofertas e procuras.",P2,RF-MN-003: Relatório 25/09 conservado; estado histórico não promove conclusão | MASTER-471012ab-0052: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-e1169a77-0202: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-f70ce307-0058: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-51b8f0ad-0138: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-edece80c-0202: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas,RF-MN-003 | MASTER-471012ab-0052 | MASTER-e1169a77-0202 | MASTER-f70ce307-0058 | MASTER-51b8f0ad-0138 | MASTER-edece80c-0202,6,"[{""id"":""RF-MN-003"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""upgrade v4 SHA f0b9815…; p. 2965; requirements-audit.csv""],""aceitacao"":""Rascunho persiste; submissão bloqueia campos essenciais e gera número de protocolo."",""estado"":""parcial"",""variantes"":""Relatório 25/09 conservado; estado histórico não promove conclusão"",""modulo"":""governação/outros""},{""id"":""MASTER-471012ab-0052"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\upgrade do portal de investimentos.docx; SHA 471012abb0f56377956f4ab822fe3186e2561bee0471508a5d807091f2e0154f; w:p 581""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-e1169a77-0202"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v6 programacao.docx; SHA e1169a7743b13ff6c950d3ffd4672a2afea32e81da84885e11154b7d8de9241e; w:p 3417""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-f70ce307-0058"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v1.docx; SHA f70ce3075523c6278e00dce15b9c7acf2b690679e16959ae5f7d007e754cfc6e; w:p 588""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-51b8f0ad-0138"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v2.docx; SHA 51b8f0ad3296d1064a5c12b450874fc72640869f10a164319cd7f3153dd3a086; w:p 2222""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-edece80c-0202"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v5 programacao.docx; SHA edece80c0519537244f8a8b86456af0aa92de1c526f791bb0ee9b85af7a7e8b2; w:p 3417""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""}]","QA - preparacao, revisao e historico privados; filtros e acesso pelo Dashboard existente; visual manual pendente",QA - operacoes autenticadas /minhas-oportunidades/ofertas e /{id}/revisao; MFA/ambito/conflitos verificados no servidor; publicacao recusada,"QA - cadastro Oportunidades, tarefas/auditoria/notificacoes nativos; snapshots e hashes por ciclo; sem tabelas ou migracoes novas","QA - recuperar oferta, correccao, decisao e versoes anteriores; conflitos, rollback e indisponibilidade sem downgrade",QA - aprovacao interna privada distinta de publicacao; versao publica nativa anterior preservada,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,Requisito transversal; consultar estado-ativo-after.json,"A evidência existente é do domínio ou uma declaração histórica; não identifica a demonstração integral desta cláusula. Não afirmar implementado/parcial apenas por componente, menu ou teste global.",Localizar obrigação verificável e consumidores/contrato atuais antes de estimar implementação ou declarar conclusão. Critério individual original preservado; cobertura atual ainda não individualizada.,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer","Ler secção/versão correspondente; distinguir requisito funcional, decisão e exemplo. Fontes oficiais já aceites.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,Reconciliação e prova individual antes de estimar alteração,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: governação e demais cláusulas. Esforço de código adicional só após lacuna reproduzida.,outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,governação e demais cláusulas,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,Verificar individualmente este critério no contrato e percurso aplicável; a matriz inicial conserva texto/fonte sem o promover a aceitação.,"QA de UI quando browser disponível; reutilizar o domínio existente. Dados/decisões específicos no backlog, sem segunda validação de fonte oficial.",AGENTS.md — componentes do domínio encontrados; correspondência integral deste requisito não presumida,; suite global QA e GET por perfil disponíveis. Evidência do domínio não demonstra integralmente esta cláusula; UI individual pendente.,Não demonstrado individualmente; localizar critério e executar em QA.,,,,"0,5–2 h de análise/prova por cláusula sem correspondência, agrupada por contrato/consumidor; não somar estas faixas como projetos independentes. Implementação indeterminada até localizar lacuna.",,, +RF-MN-003,"Criar, guardar, editar e submeter ofertas e procuras.",upgrade v4 SHA f0b9815…; p. 2965; requirements-audit.csv | portal do investidor\upgrade do portal de investimentos.docx; SHA 471012abb0f56377956f4ab822fe3186e2561bee0471508a5d807091f2e0154f; w:p 581 | portal do investidor\Portal V4\upgrade do portal de investimentos v6 programacao.docx; SHA e1169a7743b13ff6c950d3ffd4672a2afea32e81da84885e11154b7d8de9241e; w:p 3417 | portal do investidor\Portal V4\upgrade do portal de investimentos v1.docx; SHA f70ce3075523c6278e00dce15b9c7acf2b690679e16959ae5f7d007e754cfc6e; w:p 588 | portal do investidor\Portal V4\upgrade do portal de investimentos v2.docx; SHA 51b8f0ad3296d1064a5c12b450874fc72640869f10a164319cd7f3153dd3a086; w:p 2222 | portal do investidor\Portal V4\upgrade do portal de investimentos v5 programacao.docx; SHA edece80c0519537244f8a8b86456af0aa92de1c526f791bb0ee9b85af7a7e8b2; w:p 3417,governação/outros,/api/v1/investment/admin/interests/{interest}/status; /management/history; /management/interests; /management/interests/{interest}; /management/interests/{interest}/consent/contact; /management/interests/{interest}/consent/proponent; /management/opportunities; /management/opportunities/create; /management/opportunities/drafts; /management/opportunities/{id}/draft; /management/opportunities/{id}/edit; /management/opportunities/{id}/lifecycle; /ofertas; /ofertas/{id}; /api/v1/offers; /api/v1/offers/{id}; /api/v1/offers/map; /api/v1/offers/export,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual. | 14/11/2026 - Ofertas F1: oportunidades como cadastro principal; criar, guardar, recuperar, editar e submeter em privado; autorizacao existente, CRM e localizacao reutilizados; sem menu, publicacao ou instalacao. | 14/11/2026 F2 autorizada: circuito editorial privado no workflow e Dashboard existentes; revisao/atribuicao/rejeicao/correccao/reenvio/aprovacao interna; decisoes por snapshot/hash/ciclo, tarefas, auditoria e notificacoes. Sem publicacao ou instalacao. | 14/11/2026 F3 autorizada: consulta read-only da projeccao publica separada, filtros por categoria/localizacao/preco/moeda, grelha/lista/mapa administrativo e CSV. Barreira efectiva fechada; apenas amostras sinteticas em QA isolado podem ser consumidas por injeccao de teste.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.","outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual. | QA: PrivateOfferPreparationTest.php; regressao 35 testes/548 assercoes e passagem final focada 16/172 (grupos sobrepostos, nao somar). outputs/offers-private-phase1-20261114/qa-result.json. | F2 QA: 35 testes/499 assercoes; final focado 11/160; complemento contribuidores 1/14 e HTML compilado 1/10 aprovados, grupos sobrepostos. tests/Feature/PrivateOfferEditorialTest.php; outputs/offers-private-phase2-20261114/qa-result.json. | F3 QA: tests/Feature/OfferPublicCatalogueTest.php; outputs/offers-public-phase3-20261114/qa-result.json; sem instalacao ou publicacao real.",Parcial - Ofertas F1/F2 aceites tecnicamente; F3 consulta implementada em QA com barreira fechada; visual/movel pendente; nao instalada,"Ofertas: publicacao efectiva depende dos mecanismos de confidencialidade aplicaveis; F3 visual/movel pendente. F4/F5, NDA, comprovativos, fotografias, validade e restantes ramos nao iniciados. Procuras permanece por implementar. Email real F2 pendente.",Autorizacao expressa para Fase 3 e para instalacao; designacoes operacionais e configuracao de entrega reais apenas quando aplicaveis. Sem novos papeis ou permissoes por inferencia.,"RF-MN-003: Rascunho persiste; submissão bloqueia campos essenciais e gera número de protocolo. | MASTER-471012ab-0052: Criar, guardar, editar e submeter ofertas e procuras. | MASTER-e1169a77-0202: Criar, guardar, editar e submeter ofertas e procuras. | MASTER-f70ce307-0058: Criar, guardar, editar e submeter ofertas e procuras. | MASTER-51b8f0ad-0138: Criar, guardar, editar e submeter ofertas e procuras. | MASTER-edece80c-0202: Criar, guardar, editar e submeter ofertas e procuras.",P2,RF-MN-003: Relatório 25/09 conservado; estado histórico não promove conclusão | MASTER-471012ab-0052: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-e1169a77-0202: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-f70ce307-0058: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-51b8f0ad-0138: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-edece80c-0202: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas,RF-MN-003 | MASTER-471012ab-0052 | MASTER-e1169a77-0202 | MASTER-f70ce307-0058 | MASTER-51b8f0ad-0138 | MASTER-edece80c-0202,6,"[{""id"":""RF-MN-003"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""upgrade v4 SHA f0b9815…; p. 2965; requirements-audit.csv""],""aceitacao"":""Rascunho persiste; submissão bloqueia campos essenciais e gera número de protocolo."",""estado"":""parcial"",""variantes"":""Relatório 25/09 conservado; estado histórico não promove conclusão"",""modulo"":""governação/outros""},{""id"":""MASTER-471012ab-0052"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\upgrade do portal de investimentos.docx; SHA 471012abb0f56377956f4ab822fe3186e2561bee0471508a5d807091f2e0154f; w:p 581""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-e1169a77-0202"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v6 programacao.docx; SHA e1169a7743b13ff6c950d3ffd4672a2afea32e81da84885e11154b7d8de9241e; w:p 3417""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-f70ce307-0058"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v1.docx; SHA f70ce3075523c6278e00dce15b9c7acf2b690679e16959ae5f7d007e754cfc6e; w:p 588""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-51b8f0ad-0138"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v2.docx; SHA 51b8f0ad3296d1064a5c12b450874fc72640869f10a164319cd7f3153dd3a086; w:p 2222""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-edece80c-0202"",""pedido"":""Criar, guardar, editar e submeter ofertas e procuras."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v5 programacao.docx; SHA edece80c0519537244f8a8b86456af0aa92de1c526f791bb0ee9b85af7a7e8b2; w:p 3417""],""aceitacao"":""Criar, guardar, editar e submeter ofertas e procuras."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""}]","QA - preparacao, revisao e historico privados; filtros e acesso pelo Dashboard existente; visual manual pendente",QA - operacoes autenticadas /minhas-oportunidades/ofertas e /{id}/revisao; MFA/ambito/conflitos verificados no servidor; publicacao recusada,"QA - cadastro Oportunidades, tarefas/auditoria/notificacoes nativos; snapshots e hashes por ciclo; sem tabelas ou migracoes novas","QA - recuperar oferta, correccao, decisao e versoes anteriores; conflitos, rollback e indisponibilidade sem downgrade","QA - aprovacao interna privada distinta de publicacao; versao publica nativa anterior preservada | F3: projecao publica anterior preservada durante edicao/rejeicao da nova revisao; sem contacto, anexos ou coordenadas do negocio; moeda sem conversao.",Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,Requisito transversal; consultar estado-ativo-after.json,"A evidência existente é do domínio ou uma declaração histórica; não identifica a demonstração integral desta cláusula. Não afirmar implementado/parcial apenas por componente, menu ou teste global.",Localizar obrigação verificável e consumidores/contrato atuais antes de estimar implementação ou declarar conclusão. Critério individual original preservado; cobertura atual ainda não individualizada.,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer","Ler secção/versão correspondente; distinguir requisito funcional, decisão e exemplo. Fontes oficiais já aceites.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,Reconciliação e prova individual antes de estimar alteração,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: governação e demais cláusulas. Esforço de código adicional só após lacuna reproduzida.,outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,governação e demais cláusulas,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,Verificar individualmente este critério no contrato e percurso aplicável; a matriz inicial conserva texto/fonte sem o promover a aceitação.,"QA de UI quando browser disponível; reutilizar o domínio existente. Dados/decisões específicos no backlog, sem segunda validação de fonte oficial.",AGENTS.md — componentes do domínio encontrados; correspondência integral deste requisito não presumida,; suite global QA e GET por perfil disponíveis. Evidência do domínio não demonstra integralmente esta cláusula; UI individual pendente.,Não demonstrado individualmente; localizar critério e executar em QA.,,,,"0,5–2 h de análise/prova por cláusula sem correspondência, agrupada por contrato/consumidor; não somar estas faixas como projetos independentes. Implementação indeterminada até localizar lacuna.",,, RF-MN-004,"Aplicar campos condicionais por tipo de pessoa, modalidade e fase.",upgrade v4 SHA f0b9815…; p. 2969; requirements-audit.csv,governação/outros,/api/v1/investment/admin/interests/{interest}/status; /management/history; /management/interests; /management/interests/{interest}; /management/interests/{interest}/consent/contact; /management/interests/{interest}/consent/proponent; /management/opportunities; /management/opportunities/create; /management/opportunities/drafts; /management/opportunities/{id}/draft; /management/opportunities/{id}/edit; /management/opportunities/{id}/lifecycle,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.",outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,Sem evidência suficiente,Localizar obrigação verificável e consumidores/contrato atuais antes de estimar implementação ou declarar conclusão. Critério individual original preservado; cobertura atual ainda não individualizada.,"Ler secção/versão correspondente; distinguir requisito funcional, decisão e exemplo. Fontes oficiais já aceites. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.","RF-MN-004: Campos aparecem e tornam-se obrigatórios conforme regras da matriz, sem perda de dados válidos.",P2,RF-MN-004: Relatório 25/09 conservado; estado histórico não promove conclusão,RF-MN-004,1,"[{""id"":""RF-MN-004"",""pedido"":""Aplicar campos condicionais por tipo de pessoa, modalidade e fase."",""fontes"":[""upgrade v4 SHA f0b9815…; p. 2969; requirements-audit.csv""],""aceitacao"":""Campos aparecem e tornam-se obrigatórios conforme regras da matriz, sem perda de dados válidos."",""estado"":""parcial"",""variantes"":""Relatório 25/09 conservado; estado histórico não promove conclusão"",""modulo"":""governação/outros""}]",Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,Requisito transversal; consultar estado-ativo-after.json,"A evidência existente é do domínio ou uma declaração histórica; não identifica a demonstração integral desta cláusula. Não afirmar implementado/parcial apenas por componente, menu ou teste global.",Localizar obrigação verificável e consumidores/contrato atuais antes de estimar implementação ou declarar conclusão. Critério individual original preservado; cobertura atual ainda não individualizada.,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer","Ler secção/versão correspondente; distinguir requisito funcional, decisão e exemplo. Fontes oficiais já aceites.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,Reconciliação e prova individual antes de estimar alteração,Estimativa partilhada no lote correspondente de visao-consolidada-modulos.md; não somar por linha/cláusula/campo. Grupo: governação e demais cláusulas. Esforço de código adicional só após lacuna reproduzida.,outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,governação e demais cláusulas,12/10/2026 — reconciliação e HTTP; nova inspeção visual bloqueada,,,Verificar individualmente este critério no contrato e percurso aplicável; a matriz inicial conserva texto/fonte sem o promover a aceitação.,"QA de UI quando browser disponível; reutilizar o domínio existente. Dados/decisões específicos no backlog, sem segunda validação de fonte oficial.",AGENTS.md — componentes do domínio encontrados; correspondência integral deste requisito não presumida,; suite global QA e GET por perfil disponíveis. Evidência do domínio não demonstra integralmente esta cláusula; UI individual pendente.,Não demonstrado individualmente; localizar critério e executar em QA.,,,,"0,5–2 h de análise/prova por cláusula sem correspondência, agrupada por contrato/consumidor; não somar estas faixas como projetos independentes. Implementação indeterminada até localizar lacuna.",,, RF-MN-005,"Validar formatos, intervalos, dependências, duplicados e consentimentos.",upgrade v4 SHA f0b9815…; p. 2973; requirements-audit.csv | portal do investidor\upgrade do portal de investimentos.docx; SHA 471012abb0f56377956f4ab822fe3186e2561bee0471508a5d807091f2e0154f; w:p 589 | portal do investidor\Portal V4\upgrade do portal de investimentos v6 programacao.docx; SHA e1169a7743b13ff6c950d3ffd4672a2afea32e81da84885e11154b7d8de9241e; w:p 3425 | portal do investidor\Portal V4\upgrade do portal de investimentos v1.docx; SHA f70ce3075523c6278e00dce15b9c7acf2b690679e16959ae5f7d007e754cfc6e; w:p 596 | portal do investidor\Portal V4\upgrade do portal de investimentos v2.docx; SHA 51b8f0ad3296d1064a5c12b450874fc72640869f10a164319cd7f3153dd3a086; w:p 2230 | portal do investidor\Portal V4\upgrade do portal de investimentos v5 programacao.docx; SHA edece80c0519537244f8a8b86456af0aa92de1c526f791bb0ee9b85af7a7e8b2; w:p 3425,governação/outros,/api/v1/investment/admin/interests/{interest}/status; /management/history; /management/interests; /management/interests/{interest}; /management/interests/{interest}/consent/contact; /management/interests/{interest}/consent/proponent; /management/opportunities; /management/opportunities/create; /management/opportunities/drafts; /management/opportunities/{id}/draft; /management/opportunities/{id}/edit; /management/opportunities/{id}/lifecycle,"Entidades, schema/configuração e serviços existentes; correspondência individual por estabelecer. Capacidade existente associada ao domínio; correspondência integral desta cláusula só quando demonstrada na prova individual.","Ativo investemoz:5432, 295 tabelas, COUNTRY02/030000/040000 instalados; seis categorias e administração habilitadas. Leitura atual não demonstra esta cláusula individual. Legado, percurso integrado, modulares/ofertas e notificações desabilitados.",outputs/investmoz-audit-current-20261012/runtime-active.json; runtime-qa18445.json. Inventário/GET do domínio não demonstra esta cláusula individual.,Sem evidência suficiente,Localizar obrigação verificável e consumidores/contrato atuais antes de estimar implementação ou declarar conclusão. Critério individual original preservado; cobertura atual ainda não individualizada.,"Ler secção/versão correspondente; distinguir requisito funcional, decisão e exemplo. Fontes oficiais já aceites. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.","RF-MN-005: O sistema impede submissão inválida e apresenta mensagem associada ao campo e orientação de correcção. | MASTER-471012ab-0054: Validar formatos, intervalos, dependências, duplicados e consentimentos. | MASTER-e1169a77-0204: Validar formatos, intervalos, dependências, duplicados e consentimentos. | MASTER-f70ce307-0060: Validar formatos, intervalos, dependências, duplicados e consentimentos. | MASTER-51b8f0ad-0140: Validar formatos, intervalos, dependências, duplicados e consentimentos. | MASTER-edece80c-0204: Validar formatos, intervalos, dependências, duplicados e consentimentos.",P2,RF-MN-005: Relatório 25/09 conservado; estado histórico não promove conclusão | MASTER-471012ab-0054: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-e1169a77-0204: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-f70ce307-0060: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-51b8f0ad-0140: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas | MASTER-edece80c-0204: Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas,RF-MN-005 | MASTER-471012ab-0054 | MASTER-e1169a77-0204 | MASTER-f70ce307-0060 | MASTER-51b8f0ad-0140 | MASTER-edece80c-0204,6,"[{""id"":""RF-MN-005"",""pedido"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""fontes"":[""upgrade v4 SHA f0b9815…; p. 2973; requirements-audit.csv""],""aceitacao"":""O sistema impede submissão inválida e apresenta mensagem associada ao campo e orientação de correcção."",""estado"":""parcial"",""variantes"":""Relatório 25/09 conservado; estado histórico não promove conclusão"",""modulo"":""governação/outros""},{""id"":""MASTER-471012ab-0054"",""pedido"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""fontes"":[""portal do investidor\\upgrade do portal de investimentos.docx; SHA 471012abb0f56377956f4ab822fe3186e2561bee0471508a5d807091f2e0154f; w:p 589""],""aceitacao"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-e1169a77-0204"",""pedido"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v6 programacao.docx; SHA e1169a7743b13ff6c950d3ffd4672a2afea32e81da84885e11154b7d8de9241e; w:p 3425""],""aceitacao"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-f70ce307-0060"",""pedido"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v1.docx; SHA f70ce3075523c6278e00dce15b9c7acf2b690679e16959ae5f7d007e754cfc6e; w:p 596""],""aceitacao"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-51b8f0ad-0140"",""pedido"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v2.docx; SHA 51b8f0ad3296d1064a5c12b450874fc72640869f10a164319cd7f3153dd3a086; w:p 2230""],""aceitacao"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""},{""id"":""MASTER-edece80c-0204"",""pedido"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""fontes"":[""portal do investidor\\Portal V4\\upgrade do portal de investimentos v5 programacao.docx; SHA edece80c0519537244f8a8b86456af0aa92de1c526f791bb0ee9b85af7a7e8b2; w:p 3425""],""aceitacao"":""Validar formatos, intervalos, dependências, duplicados e consentimentos."",""estado"":""parcial"",""variantes"":""Todas as ocorrências exatas preservadas nas fontes; variantes de texto têm linhas distintas"",""modulo"":""governação/outros""}]",Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Não demonstrado individualmente; localizar critério e executar em QA.,Texto exatamente equivalente no mesmo módulo; sem fusão semântica aproximada,parcial,Requisito transversal; consultar estado-ativo-after.json,"A evidência existente é do domínio ou uma declaração histórica; não identifica a demonstração integral desta cláusula. Não afirmar implementado/parcial apenas por componente, menu ou teste global. diff --git a/docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md b/docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md new file mode 100644 index 00000000..c22f54df --- /dev/null +++ b/docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md @@ -0,0 +1,56 @@ +# Ofertas — Fase 3: consulta e filtros em QA + +14/11/2026. Desenvolvimento autorizado exclusivamente para a Fase 3. Base aceite: `91c18573` (Fase 2), preservando `1f2990e4` (Fase 1) e o plano `6e302db6`. Referência: RF-MN-003 da matriz existente. + +**Consulta implementada em QA; publicação efectiva bloqueada. Sem instalação em homologação ou produção. Conferência visual/móvel pendente. Não foram iniciadas as Fases 4 ou 5.** + +## Comportamento implementado + +`/ofertas` reutiliza os filtros, paginação, estados de pesquisa, estrutura pública e mapa do catálogo existente. Apresenta grelha/lista e lista com mapa administrativo. `/ofertas/{id}` mostra o resumo comercial, categoria, localização geral, preço e data da versão publicada. Não houve alteração do menu nem criação de outro cadastro ou dashboard. + +Filtros: texto do resumo público, categoria, província/distrito, montante ou preço a negociar, moeda e intervalo de preços. A hierarquia territorial é validada no servidor. Montantes são comparados como decimais exactos, incluindo valores grandes; ordenar ou filtrar por montante exige uma moeda. MZN e USD são as moedas actualmente suportadas na preparação de Ofertas. Não há conversão cambial. Os contratos de moeda/preço dos investimentos nativos mantêm-se. + +A API de leitura tem `/api/v1/offers`, `/offers/{id}`, `/offers/map` e `/offers/export`. A exportação CSV usa a mesma projecção e filtros da lista, com protecção contra fórmulas em células. Não existe operação HTTP de publicação neste consumidor. + +## Barreira de confidencialidade e limite desta entrega + +Esta fase não autoriza divulgar as Ofertas privadas já existentes. O modelo, os workflows e as rotas alternativas conservam os bloqueios de publicação aceites na Fase 2. O catálogo e os detalhes novos continuam vazios/404 na configuração normal, mesmo que exista JSON com um alegado estado publicado. + +O novo serviço só admite amostras sintéticas quando é explicitamente injectado pelo teste e a guarda existente confirma ambiente `testing`, ligação de loopback, porta `55441`, base terminada em `_test` e identidade PostgreSQL correspondente. Não há interruptor HTTP, variável de ambiente ou sinalizador em dados que active a consulta efectiva. O mesmo modo injectado fora desse ambiente é recusado. + +O consumidor lê exclusivamente uma projecção pública independente, com contrato, revisão, resumo autorizado, hash e decisões distintas de aprovação e publicação. O hash tem de corresponder ao conteúdo exacto; actores iguais, estados incompletos, datas futuras, referências territoriais inacessíveis e propriedades de conteúdo não previstas invalidam a projecção. Os testes constroem estas amostras: não representam decisões reais nem publicações realizadas. + +Contactos, anexos, fotografias, dados financeiros privados, localização exacta, rascunho e contexto editorial completo não são seleccionados nem serializados. Os mapas recebem apenas limites administrativos públicos; nunca marcadores, coordenadas do negócio ou geometrias privadas. Texto é apresentado como texto React, sem HTML introduzido pelo conteúdo. + +Uma edição privada ou rejeição da nova revisão não substitui a projecção anterior aprovada/publicada. Filtros, detalhe e CSV continuam a usar essa projecção; uma Oferta sem publicação válida não aparece. Eliminação lógica e retirada de uma referência territorial pública impedem a leitura. + +**A futura criação/autorização operacional dessa projecção, a classificação dos campos divulgáveis e o levantamento da barreira dependem dos mecanismos de confidencialidade aplicáveis e de autorização própria. Não foram implementados NDA nem os restantes percursos das Fases 4/5. Não declarar a publicação real concluída.** + +## Ficheiros alterados + +| Grupo | Finalidade | +| --- | --- | +| `app/Services/OfferPublicCatalogue.php` | Consumidor de projecções, guardas, filtros, ordenação decimal, paginação e mapas administrativos | +| `app/Http/Controllers/OfferPublicCatalogueController.php`, `routes/web.php`, `routes/api.php` | Páginas públicas e endpoints de leitura/CSV | +| `app/Services/InvestmentCatalogue.php` | Opção delimitada para preços decimais de Ofertas; comportamento nativo por defeito preservado | +| `resources/js/app.tsx`, `components/opportunity-search-filters.tsx` | Layout público e reutilização dos filtros existentes | +| `resources/js/pages/offers/catalogue.tsx`, `public-detail.tsx`, `components/public-offer-card.tsx`, `lib/public-offer.ts` | Consulta, detalhe e apresentação exacta do preço | +| `tests/Feature/OfferPublicCatalogueTest.php` | Isolamento, projecções, acesso directo, versões, filtros, mapas, CSV e HTML compilado | +| Matriz RF-MN-003 e manual existentes | Saldo actualizado, sem outro inventário | + +Sem migrações, dependências instaladas, modificações em `mne`, permissões novas ou alterações de dados operacionais. Testes usam apenas dados sintéticos e rollback na base QA isolada. + +## Verificação e pendências + +O recibo `outputs/offers-public-phase3-20261114/qa-result.json` regista os resultados finais e os hashes do delta. Os grupos de testes sobrepostos não devem ser somados. + +- Passagem final: **10 testes / 133 asserções aprovados**, incluindo a regressão dos filtros combinados, lista e exportação nativos com referências sintéticas autocontidas, sem alterar o ensaio anterior. +- Passagem de integração anterior: **41 testes, 36 aprovados e cinco impedidos**, 514 asserções. As Fases 1/2 e a gestão/preservação pública nativa passaram. Os cinco casos de `OpportunitySearchTest` exigem uma referência pública preexistente de Inhambane e não chegam às respectivas asserções nesta base vazia. A limitação foi registada, sem remover ou enfraquecer os casos; a nova prova autocontida cobre o contrato partilhado afectado. +- Pint, PHPStan sem erros, TypeScript, lint/formatação dos seis ficheiros frontend alterados e compilação QA aprovados. HTML real das duas páginas novas resolve os respectivos ficheiros compilados. +- A verificação global continua a acusar os **61 ficheiros preexistentes**, fora do delta; não foram reformatados. Permanecem os avisos não impeditivos do build sobre a dependência opcional `fontaine` e o tamanho de chunks. + +A estrutura responsiva foi preparada com campos em uma coluna no ecrã estreito, grelha progressiva, quebra de texto e controlos que se ajustam à largura. **Não há aceite visual ou prova de apresentação móvel:** a tentativa desta fase devolveu `Browsers: Error: The browser access host is unavailable`. O Playwright do caminho utilizado pelo antigo ensaio do repositório também não está disponível. Não foram instaladas ferramentas, reiniciados navegadores ou desactivados controlos para contornar a limitação. Compilação e respostas HTML não substituem essa prova. + +O consumidor limita a representação a 1.000 projecções candidatas; o CSV e a página assinalam resultados truncados. O mapa limita o contexto a 30 territórios. Estes limites não são uma demonstração de escala de produção e devem ser revistos antes de uma abertura efectiva com volume superior. + +Mantêm-se a conferência visual e a entrega real de email da Fase 2. A Fase 3 não altera o transporte de email. O módulo integral e Procuras não estão concluídos. Próxima tarefa: conferir desktop/móvel quando o conector estiver disponível e submeter esta entrega QA à apreciação da Tempo, mantendo a publicação bloqueada e sem instalação. diff --git a/outputs/offers-public-phase3-20261114/phpunit-final.log b/outputs/offers-public-phase3-20261114/phpunit-final.log new file mode 100644 index 00000000..ba1ecdc0 --- /dev/null +++ b/outputs/offers-public-phase3-20261114/phpunit-final.log @@ -0,0 +1 @@ +{"tool":"phpunit","result":"passed","tests":10,"passed":10,"assertions":133,"duration_ms":6992} diff --git a/outputs/offers-public-phase3-20261114/phpunit-final.xml b/outputs/offers-public-phase3-20261114/phpunit-final.xml new file mode 100644 index 00000000..49538898 --- /dev/null +++ b/outputs/offers-public-phase3-20261114/phpunit-final.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/outputs/offers-public-phase3-20261114/qa-result.json b/outputs/offers-public-phase3-20261114/qa-result.json new file mode 100644 index 00000000..023b6a8a --- /dev/null +++ b/outputs/offers-public-phase3-20261114/qa-result.json @@ -0,0 +1,141 @@ +{ + "phase": 3, + "base": "91c185736868b5a59b9290438464be7f8a853022", + "state": "QA implemented, visual/mobile pending, effective publication blocked", + "environment": { + "database": "offers_phase1_20261114_test", + "port": 55441, + "data": "synthetic, rollback" + }, + "final_focused": { + "tests": 10, + "passed": 10, + "assertions": 133, + "log": "phpunit-final.log", + "junit": "phpunit-final.xml" + }, + "integration_run": { + "tests": 41, + "passed": 36, + "errors": 5, + "assertions": 514, + "duration_ms": 186210, + "blocker": "Five OpportunitySearchTest cases require the pre-existing public Inhambane reference, absent in this empty synthetic database. Original tests unchanged. Final focused suite adds self-contained native filter/list/export regression." + }, + "checks": { + "pint": "passed", + "phpstan": "passed, zero errors", + "types": "passed", + "frontend_focused": "6 files passed", + "qa_build": "passed", + "html_compiled": "both new pages passed", + "global_format": { + "failures": 61, + "outside_delta": true, + "same_as_base": true, + "paths": [ + "resources/js/components/funding-classification.tsx", + "resources/js/components/institutional-source/source-text.tsx", + "resources/js/components/password-input.tsx", + "resources/js/components/sector-entry.tsx", + "resources/js/components/sector-resource-reference.tsx", + "resources/js/components/simulation-form.tsx", + "resources/js/components/simulation-irpc-annual.tsx", + "resources/js/components/structure-geography.tsx", + "resources/js/components/tempo-header-tools.tsx", + "resources/js/lib/investor-path-stage.ts", + "resources/js/lib/irpc-annual-settlement.ts", + "resources/js/pages/funding/assisted.tsx", + "resources/js/pages/funding/manage.tsx", + "resources/js/pages/funding/saved.tsx", + "resources/js/pages/investment/catalogue.tsx", + "resources/js/pages/investment/detail.tsx", + "resources/js/pages/investor-path/index.tsx", + "resources/js/pages/investor-path/preparation.tsx", + "resources/js/pages/investor-path/show.tsx", + "resources/js/pages/management/demography-install.tsx", + "resources/js/pages/portal/fiscal-forms.tsx", + "resources/js/pages/portal/search.tsx", + "resources/js/translation/page-translator.tsx", + "resources/js/translation/service.ts", + "resources/js/translation/types.ts", + "resources/js/translation/utils.ts", + "tests/frontend/cluster-editorial.test.mjs", + "tests/frontend/dashboard-navigation-sections.test.mjs", + "tests/frontend/editorial-completion.test.mjs", + "tests/frontend/editorial-dashboard-manifest.test.mjs", + "tests/frontend/editorial-dashboard-presentation.test.mjs", + "tests/frontend/editorial-edition.test.mjs", + "tests/frontend/education-review.test.mjs", + "tests/frontend/fiscal-form-entry.test.mjs", + "tests/frontend/funding-directory.test.mjs", + "tests/frontend/global-search.test.mjs", + "tests/frontend/helpers/irpc-advance-period-case.mjs", + "tests/frontend/helpers/irpc-annual-noncivil-case.mjs", + "tests/frontend/helpers/irpc-investment-combination-case.mjs", + "tests/frontend/investment-map-indicators.test.mjs", + "tests/frontend/investment-preparation.test.mjs", + "tests/frontend/irpc-advance-cashflow-ui.test.mjs", + "tests/frontend/irpc-advance-cashflow.test.mjs", + "tests/frontend/irpc-advance-period-ui.test.mjs", + "tests/frontend/irpc-advance-period.test.mjs", + "tests/frontend/irpc-annual-noncivil-ui.test.mjs", + "tests/frontend/irpc-annual-noncivil.test.mjs", + "tests/frontend/irpc-investment-carry-combination.test.mjs", + "tests/frontend/irpc-pec-correction.test.mjs", + "tests/frontend/irpc-pec-last-amount.test.mjs", + "tests/frontend/irpc-pec-obligation-ui.test.mjs", + "tests/frontend/irpc-pec-obligation.test.mjs", + "tests/frontend/irpc-ppc-obligation-ui.test.mjs", + "tests/frontend/licensing-stage.test.mjs", + "tests/frontend/navigation-sidebar-render.test.mjs", + "tests/frontend/opportunity-curation.test.mjs", + "tests/frontend/opportunity-information.test.mjs", + "tests/frontend/portal-institutional-contact.test.mjs", + "tests/frontend/portal-navigation.test.mjs", + "tests/frontend/portal-six-group-menu.test.mjs", + "tests/frontend/sector-resource-reference.test.mjs" + ] + } + }, + "visual": { + "status": "pending", + "error": "Browsers: Error: The browser access host is unavailable", + "desktop_mobile_accepted": false, + "existing_playwright_path_available": false + }, + "limits": { + "candidates": 1000, + "map_territories": 30 + }, + "publication": { + "effective": false, + "writer": false, + "synthetic_consumer_requires_verified_test_database": true, + "http_or_environment_activation": false + }, + "phase4_or_5_started": false, + "installed": false, + "operational_data_modified": false, + "migrations_added": false, + "mne_modified": false, + "phase2_real_email": "pending, untouched", + "source_sha256": { + "app/Http/Controllers/OfferPublicCatalogueController.php": "15cc84253aef5f9eafb46870abed49ac79010e9d74bc142478226b9e7ee2f6e7", + "app/Services/InvestmentCatalogue.php": "612cabf25f016f0cb83de907af681892a5bf7532a075a0ab40b9fc2ad00335ad", + "app/Services/OfferPublicCatalogue.php": "c5824b586f0a969db83df8d221dd50104c70c0ce541b283bd3c81defe80495c4", + "docs/investmoz/unification/traceability-and-manual.md": "fa119c9df752d3e6369aa1d3ce497ac416c276ce8e50cab61099d8c602e2f8bd", + "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv": "4334854f3199b5c23728eb661cd7ca925d60c3bdfc555400ad748508d785ffda", + "docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md": "7f3d71fcedac8894fd1965574a74a2b1a078afbc298faa6ed04bd8838705f225", + "resources/js/app.tsx": "23f297ae06e7a3afc4ed3151f79f0d9cc187d0ae8c2561ca5bd6a610afb6a21b", + "resources/js/components/opportunity-search-filters.tsx": "e5a8c73247257b85ee592ba62be91120ea75c7d243b30ca78a336dc5fd074d44", + "resources/js/components/public-offer-card.tsx": "dd88cc3623d44940357ae522479471b68e42d8affc012416a07783647bd883e5", + "resources/js/lib/public-offer.ts": "f1c38264edc138a227c319af820e240acf1e3f366ba6b028ef5f0c9ef71a8bbb", + "resources/js/pages/offers/catalogue.tsx": "a17dd461a9a0e15bf1d8d182f5b0ef5687b9b0c8237ad2d4082701b08ad3bb47", + "resources/js/pages/offers/public-detail.tsx": "cf4a35659f9f0e0a7fbeaf5a99ee74ba3ca8005b29726eb70dbef74201ca6838", + "routes/api.php": "8e7d21d0c9b8efbad243a1aaea3a2b409300d5c8f284c75aa95862ee9787f893", + "routes/web.php": "79fc4c6f77c624345c2667cbbf649ab582a6bb5d111a998c3ee87656f4be0160", + "tests/Feature/OfferPublicCatalogueTest.php": "852504357a2f9ec1c097bdaa8ea6b42895d01eb455263c9b1255f6842d64683c" + }, + "report": "docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md" +} diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 6b14af95..d18fec81 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -27,6 +27,8 @@ void createInertiaApp({ case name.startsWith('qa/'): case name.startsWith('portal/'): case name.startsWith('investment/'): + case name === 'offers/catalogue': + case name === 'offers/public-detail': case name.startsWith('portfolio/'): case name.startsWith('land-assistance/'): case name.startsWith('desk/'): diff --git a/resources/js/components/opportunity-search-filters.tsx b/resources/js/components/opportunity-search-filters.tsx index 6f9a63ba..bc40f6c9 100644 --- a/resources/js/components/opportunity-search-filters.tsx +++ b/resources/js/components/opportunity-search-filters.tsx @@ -76,6 +76,7 @@ export default function OpportunitySearchFilters({ submit, clear, errors, + mode = 'opportunities', }: { data: Values; references: References; @@ -85,6 +86,7 @@ export default function OpportunitySearchFilters({ submit: () => void; clear: () => void; errors: Record; + mode?: 'opportunities' | 'offers'; }) { const [expanded, setExpanded] = useState(false); useEffect(() => { @@ -222,6 +224,134 @@ export default function OpportunitySearchFilters({ (!Array.isArray(v) || v.length > 0) && !(k === 'territory_basis' && v === 'all'), ); + if (mode === 'offers') + return ( +
{ + event.preventDefault(); + submit(); + }} + > + + change({ q: event.target.value })} + /> + + {select('category', choicesFor('category'))} + {[0, 1].map((index) => ( + + territory([ + ...path.slice(0, index), + ...(value ? [value] : []), + ]) + } + /> + ))} + + + + + + + {[ + ['min_amount', 'Preço mínimo'], + ['max_amount', 'Preço máximo'], + ].map(([key, label]) => ( + + + change({ [key]: event.target.value }) + } + /> + + ))} + + + + {Object.keys(errors).length > 0 && ( +
    + {Object.entries(errors).map(([key, value]) => ( +
  • {value}
  • + ))} +
+ )} +
+ + +
+ + ); return (
+

+ {modalityLabels[offer.category]} ·{' '} + {offer.location.map((node) => node.name).join(' / ') || + 'Localização não indicada'} +

+ + {detail ? ( + offer.title + ) : ( + + {offer.title} + + )} + +

{offer.summary}

+

+ {offerPrice(offer.price)} +

+

+ Publicada em{' '} + {new Date(offer.published_at).toLocaleDateString('pt-MZ')} +

+ + ); +} diff --git a/resources/js/lib/public-offer.ts b/resources/js/lib/public-offer.ts new file mode 100644 index 00000000..edd76e01 --- /dev/null +++ b/resources/js/lib/public-offer.ts @@ -0,0 +1,22 @@ +export type PublicOffer = { + id: number; + title: string; + summary: string; + category: string; + location: { id: string; name: string; level: string }[]; + price: { + mode: 'amount' | 'negotiable'; + amount: string | null; + currency: string | null; + }; + status: 'published'; + published_at: string; + revision: number; + url: string; +}; +/** Display exact decimal text, including large amounts; never converts currency. */ +export function offerPrice(price: PublicOffer['price']): string { + if (price.mode === 'negotiable') return 'A negociar'; + const [whole, fraction = ''] = (price.amount ?? '').split('.'); + return `${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}${fraction ? `,${fraction.padEnd(2, '0')}` : ',00'} ${price.currency ?? ''}`; +} diff --git a/resources/js/pages/offers/catalogue.tsx b/resources/js/pages/offers/catalogue.tsx new file mode 100644 index 00000000..dd103dda --- /dev/null +++ b/resources/js/pages/offers/catalogue.tsx @@ -0,0 +1,162 @@ +import { Link, router, useForm } from '@inertiajs/react'; +import { useState } from 'react'; +import { Frame } from '@/pages/investment/shared'; +import type { References } from '@/pages/investment/shared'; +import OpportunitySearchFilters from '@/components/opportunity-search-filters'; +import type { TerritoryOption } from '@/components/investment-territory'; +import CatalogueInvestmentMap from '@/components/catalogue-investment-map'; +import type { InvestmentMapArea } from '@/components/interactive-investment-map'; +import SearchState from '@/components/search-state'; +import PublicOfferCard from '@/components/public-offer-card'; +import type { PublicOffer } from '@/lib/public-offer'; + +export default function OfferCatalogue({ + filters, + results, + references, + territorySelection, + mapData, + truncated, +}: { + filters: Record; + results: { + data: PublicOffer[]; + total: number; + prev_page_url: string | null; + next_page_url: string | null; + }; + references: References; + territorySelection: TerritoryOption[]; + mapData: { areas: InvestmentMapArea[] } | null; + truncated: boolean; +}) { + const form = useForm({ + q: filters.q ?? '', + category: filters.category ?? '', + province_id: filters.province_id ?? '', + district_id: filters.district_id ?? '', + currency: filters.currency ?? '', + price_mode: filters.price_mode ?? '', + min_amount: filters.min_amount ?? '', + max_amount: filters.max_amount ?? '', + sort: filters.sort ?? 'recent', + view: filters.view ?? 'grid', + }); + const [path, setPath] = useState(territorySelection); + const query = new URLSearchParams( + Object.entries(filters) + .filter(([, value]) => value !== '' && value !== null) + .map(([key, value]) => [key, String(value)]), + ); + return ( + +
+

Ofertas

+

Consulte ofertas por categoria, localização e preço.

+
+ + form.setData((previous) => ({ ...previous, ...values })) + } + territory={(next) => { + setPath(next); + form.setData((previous) => ({ + ...previous, + province_id: next[0]?.id ?? '', + district_id: next[1]?.id ?? '', + })); + }} + submit={() => form.get('/ofertas', { preserveScroll: true })} + clear={() => router.get('/ofertas')} + errors={form.errors} + /> + form.get('/ofertas')} + countLabel="ofertas publicadas" + /> + {truncated && ( +

+ Apresentação limitada a 1.000 ofertas. Refine os filtros; os + totais referem-se aos resultados representados. +

+ )} +
+ {['grid', 'list', 'combined'].map((view) => ( + + { + ( + { + grid: 'Grelha', + list: 'Lista', + combined: 'Lista e mapa', + } as Record + )[view] + } + + ))} + + Exportar CSV + +
+ {mapData && ( +
+

+ Localização geral +

+

+ Os limites administrativos são contexto territorial e + não indicam a localização exacta dos negócios. +

+ +
+ )} +
+ {results.data.map((offer) => ( + + ))} +
+ + + ); +} diff --git a/resources/js/pages/offers/public-detail.tsx b/resources/js/pages/offers/public-detail.tsx new file mode 100644 index 00000000..01aa4c88 --- /dev/null +++ b/resources/js/pages/offers/public-detail.tsx @@ -0,0 +1,29 @@ +import { Link } from '@inertiajs/react'; +import { Frame } from '@/pages/investment/shared'; +import type { PublicOffer } from '@/lib/public-offer'; +import PublicOfferCard from '@/components/public-offer-card'; +import CatalogueInvestmentMap from '@/components/catalogue-investment-map'; + +export default function OfferPublicDetail({ offer }: { offer: PublicOffer }) { + const territoryId = offer.location.at(-1)?.id; + return ( + + + Voltar às ofertas + + + {territoryId && ( +
+

+ Contexto territorial +

+ +
+ )} + + ); +} diff --git a/routes/api.php b/routes/api.php index 07b49d34..a90f1e55 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Api\V1\SimulationController; use App\Http\Controllers\Api\V1\SimulationFormDraftController; use App\Http\Controllers\Api\V1\SimulationOfferDocumentController; use App\Http\Controllers\Api\V1\SimulationTaxParameterController; +use App\Http\Controllers\OfferPublicCatalogueController; use App\Http\Controllers\PortalServicesController; use App\Http\Controllers\PublicBusinessOpportunityController; use App\Http\Middleware\CorrelationId; @@ -31,6 +32,10 @@ Route::prefix('v1')->middleware(['throttle:api', CorrelationId::class])->group(f Route::get('/sectors/{slug}', [SectorController::class, 'show']); Route::get('/opportunities/search', [OpportunityController::class, 'search']); + Route::get('/offers', [OfferPublicCatalogueController::class, 'search']); + Route::get('/offers/map', [OfferPublicCatalogueController::class, 'map']); + Route::get('/offers/export', [OfferPublicCatalogueController::class, 'export']); + Route::get('/offers/{id}', [OfferPublicCatalogueController::class, 'detail'])->whereNumber('id'); Route::get('/opportunities/{slug}', [OpportunityController::class, 'show']); Route::get('/business-opportunities', [PublicBusinessOpportunityController::class, 'index']); diff --git a/routes/web.php b/routes/web.php index 3f907023..66d9da63 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,6 +32,7 @@ use App\Http\Controllers\InvestmentStructureRelationController; use App\Http\Controllers\InvestmentTerritoryController; use App\Http\Controllers\InvestorWorkspaceController; use App\Http\Controllers\LegacyContentController; +use App\Http\Controllers\OfferPublicCatalogueController; use App\Http\Controllers\OpportunityClassificationController; use App\Http\Controllers\OpportunityCurationController; use App\Http\Controllers\OpportunityManagementController; @@ -145,6 +146,8 @@ Route::get('/carteira/{id}', [InvestmentPortfolioController::class, 'show'])->wh Route::get('/carteira/{id}/interesse', [InvestmentPortfolioController::class, 'resumeInterest'])->whereUuid('id')->middleware(['auth', VerifiedOrSimpleParticipation::class])->name('portfolio.interest'); Route::get('/pesquisa', GlobalSearchController::class)->name('portal.search'); Route::get('/oportunidades', [InvestmentDiscoveryController::class, 'index'])->name('portal.opportunities'); +Route::get('/ofertas', [OfferPublicCatalogueController::class, 'index'])->name('offers.public.index'); +Route::get('/ofertas/{id}', [OfferPublicCatalogueController::class, 'show'])->whereNumber('id')->name('offers.public.show'); Route::get('/oportunidades/{slug}', [InvestmentDiscoveryController::class, 'detail'])->name('portal.opportunity'); Route::get('/negocios', [PublicBusinessOpportunityController::class, 'index'])->name('business-marketplace.index'); Route::get('/negocios/{slug}/manifestar-interesse', [PublicBusinessOpportunityController::class, 'interest'])->name('business-marketplace.interest'); diff --git a/tests/Feature/OfferPublicCatalogueTest.php b/tests/Feature/OfferPublicCatalogueTest.php new file mode 100644 index 00000000..cef1cfd4 --- /dev/null +++ b/tests/Feature/OfferPublicCatalogueTest.php @@ -0,0 +1,228 @@ +withoutVite(); + } + + private function fixture(array $payload = [], array $changes = []): Opportunity + { + $payload = array_replace(['title' => 'Synthetic public offer', 'summary' => 'Approved public summary.', + 'category' => 'business_sale', 'province_id' => null, 'district_id' => null, + 'price_mode' => 'amount', 'amount' => '12345.67', 'currency' => 'MZN'], $payload); + $hash = OfferPublicCatalogue::projectionHash($payload); + $publication = array_replace_recursive(['contract' => OfferPublicCatalogue::CONTRACT, 'qa_fixture' => true, + 'revision' => 1, 'payload' => $payload, + 'approval' => ['state' => 'approved', 'hash' => $hash, 'actor_id' => 1, 'at' => now()->subDays(2)->toIso8601String()], + 'publication' => ['state' => 'published', 'hash' => $hash, 'actor_id' => 2, 'at' => now()->subDay()->toIso8601String()]], $changes); + $record = Opportunity::create(['title' => 'PRIVATE_CANARY', 'slug' => 'qa-'.Str::uuid(), 'reference' => 'QA-'.Str::uuid(), + 'description' => 'PRIVATE_CANARY financial details', 'status' => 'draft']); + $record->forceFill(['management_draft' => json_encode(['contact' => 'PRIVATE_CANARY', 'coordinates' => [33.123456, -18.654321]]), + 'workflow_context' => ['private_offer_preparation' => ['version' => 2, 'working_state' => 'draft'], 'offer_publication' => $publication]])->save(); + + return $record; + } + + private function enableSyntheticConsumer(): void + { + $this->app->instance(OfferPublicCatalogue::class, new OfferPublicCatalogue(true)); + } + + public function test_effective_publication_remains_closed_even_with_a_stored_projection(): void + { + $offer = $this->fixture(); + $this->getJson('/api/v1/offers?qa_fixture=true')->assertOk()->assertJsonPath('results.total', 0); + $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); + $this->get('/ofertas/'.$offer->id)->assertNotFound(); + $this->assertStringNotContainsString('Synthetic public offer', $this->get('/api/v1/offers/export')->assertOk()->streamedContent()); + $this->enableSyntheticConsumer(); + $this->app['env'] = 'production'; + try { + $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0); + $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); + } finally { + $this->app['env'] = 'testing'; + } + } + + public function test_pages_api_csv_and_map_only_use_the_explicit_public_projection(): void + { + $this->enableSyntheticConsumer(); + $offer = $this->fixture(); + $response = $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 1) + ->assertJsonPath('results.data.0.title', 'Synthetic public offer'); + $this->assertStringNotContainsString('PRIVATE_CANARY', $response->getContent()); + $this->get('/ofertas')->assertOk()->assertInertia(fn (Assert $page) => $page->component('offers/catalogue')->has('results.data', 1)); + $this->get('/ofertas/'.$offer->id)->assertOk()->assertInertia(fn (Assert $page) => $page->component('offers/public-detail')->where('offer.title', 'Synthetic public offer')); + $detail = $this->getJson('/api/v1/offers/'.$offer->id)->assertOk(); + $this->assertSame(['id', 'title', 'summary', 'category', 'location', 'price', 'status', 'published_at', 'revision', 'url'], array_keys($detail->json('data'))); + $csv = $this->get('/api/v1/offers/export')->assertOk()->streamedContent(); + $this->assertStringContainsString('Synthetic public offer', $csv); + $this->assertStringNotContainsString('PRIVATE_CANARY', $csv); + $this->getJson('/api/v1/offers/map')->assertOk()->assertJsonPath('features', [])->assertJsonPath('exact_locations_disclosed', false); + $this->getJson('/api/v1/opportunities/'.$offer->slug)->assertNotFound(); + $this->assertSame('draft', $offer->fresh()->status); + $this->assertNull($offer->fresh()->published_at); + } + + public function test_invalid_unapproved_unpublished_or_sensitive_snapshots_are_not_readable(): void + { + $this->enableSyntheticConsumer(); + foreach ([['approval' => ['state' => 'rejected']], ['publication' => ['state' => 'draft']], + ['publication' => ['actor_id' => 1]], ['publication' => ['at' => now()->addDay()->toIso8601String()]], + ['approval' => ['hash' => str_repeat('0', 64)]], ['payload' => ['contact' => 'PRIVATE_CANARY']], + ['contract' => 'unknown'], ['qa_fixture' => false]] as $invalid) { + $offer = $this->fixture([], $invalid); + $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); + } + $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0); + } + + public function test_pending_private_edits_do_not_replace_the_previously_approved_projection(): void + { + $this->enableSyntheticConsumer(); + $offer = $this->fixture(); + $before = $this->getJson('/api/v1/offers/'.$offer->id)->assertOk()->json('data'); + $context = $offer->workflow_context; + $context['private_offer_preparation']['working_state'] = 'rejected'; + $context['draft'] = ['title' => 'PRIVATE_CANARY revision', 'amount' => '999999.99']; + $offer->forceFill(['title' => 'PRIVATE_CANARY edited', 'workflow_context' => $context])->save(); + $this->assertSame($before, $this->getJson('/api/v1/offers/'.$offer->id)->assertOk()->json('data')); + $this->getJson('/api/v1/offers?q=PRIVATE_CANARY')->assertJsonPath('results.total', 0); + $this->getJson('/api/v1/offers?q=Synthetic')->assertJsonPath('results.total', 1); + $offer->delete(); + $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); + } + + public function test_prices_are_exact_and_currencies_are_never_converted(): void + { + $this->enableSyntheticConsumer(); + $first = $this->fixture(['amount' => '9999999999999999.01', 'currency' => 'USD']); + $second = $this->fixture(['amount' => '9999999999999999.02', 'currency' => 'USD']); + $this->fixture(['amount' => '9999999999999999.02', 'currency' => 'MZN']); + $this->fixture(['price_mode' => 'negotiable', 'amount' => null, 'currency' => null]); + $this->getJson('/api/v1/offers?currency=USD&min_amount=9999999999999999.02')->assertOk()->assertJsonPath('results.total', 1)->assertJsonPath('results.data.0.id', $second->id); + $this->getJson('/api/v1/offers?currency=USD&sort=amount_asc')->assertOk()->assertJsonPath('results.data.0.id', $first->id); + $this->getJson('/api/v1/offers?price_mode=negotiable')->assertOk()->assertJsonPath('results.total', 1); + foreach (['min_amount=1', 'currency=EUR', 'sort=amount_asc', 'currency=MZN&min_amount=2&max_amount=1', 'price_mode=negotiable¤cy=USD'] as $query) { + $this->getJson('/api/v1/offers?'.$query)->assertUnprocessable(); + } + } + + public function test_csv_formula_text_is_escaped_and_http_has_no_publication_operation(): void + { + $this->enableSyntheticConsumer(); + $offer = $this->fixture(['title' => '=SYNTHETIC()']); + $csv = $this->get('/api/v1/offers/export')->assertOk()->streamedContent(); + $this->assertStringContainsString("'=SYNTHETIC()", $csv); + $this->postJson('/api/v1/offers/'.$offer->id)->assertMethodNotAllowed(); + $this->postJson('/api/v1/offers/'.$offer->id.'/publish')->assertNotFound(); + $this->assertSame('draft', $offer->fresh()->status); + } + + public function test_location_category_and_pagination_use_only_the_published_snapshot(): void + { + $this->enableSyntheticConsumer(); + $country = $this->territory('country'); + $province = $this->territory('province', $country); + $district = $this->territory('district', $province); + $other = $this->territory('province', $country); + $boundary = (string) Str::uuid(); + DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'polygon',ST_GeomFromText('POLYGON((32 -20,33 -20,33 -19,32 -19,32 -20))',4326),?::jsonb,'public')", + [$boundary, $district, json_encode(['purpose' => 'administrative_boundary', 'authority' => 'Synthetic QA'])]); + DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'point',ST_GeomFromText('POINT(33.123456 -18.654321)',4326),?::jsonb,'confidential')", + [(string) Str::uuid(), $district, json_encode(['purpose' => 'investment_site', 'contact' => 'PRIVATE_CANARY'])]); + $offer = $this->fixture(['province_id' => $province, 'district_id' => $district]); + $offer->forceFill(['primary_territory_id' => $other])->save(); + $this->fixture(['title' => 'Another offer', 'province_id' => $province]); + $this->getJson('/api/v1/offers?category=business_sale&province_id='.$province.'&district_id='.$district) + ->assertOk()->assertJsonPath('results.total', 1)->assertJsonPath('results.data.0.id', $offer->id); + $this->getJson('/api/v1/offers?province_id='.$other)->assertOk()->assertJsonPath('results.total', 0); + $this->getJson('/api/v1/offers?province_id='.$other.'&district_id='.$district)->assertUnprocessable(); + $this->getJson('/api/v1/offers?district_id='.$district)->assertUnprocessable(); + $this->getJson('/api/v1/offers?category=invalid')->assertUnprocessable(); + $this->getJson('/api/v1/offers?per_page=1&page=1&sort=title&private_flag=secret')->assertOk() + ->assertJsonPath('results.total', 2)->assertJsonPath('results.data.0.title', 'Another offer'); + $next = $this->getJson('/api/v1/offers?per_page=1&page=1')->json('results.next_page_url'); + $this->assertStringNotContainsString('private_flag', $next); + $this->getJson('/api/v1/offers?per_page=1&page=2&sort=title')->assertOk()->assertJsonPath('results.data.0.id', $offer->id); + $map = $this->getJson('/api/v1/offers/map?province_id='.$province)->assertOk()->assertJsonPath('features', [])->assertJsonPath('exact_locations_disclosed', false); + $this->assertContains($boundary, array_column($map->json('areas'), 'id')); + $this->assertStringNotContainsString('33.123456', $map->getContent()); + $this->assertStringNotContainsString('PRIVATE_CANARY', $map->getContent()); + DB::table('geo_locations')->where('id', $province)->update(['data_classification' => 'internal']); + $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); + $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0); + } + + private function territory(string $type, ?string $parent = null): string + { + $id = (string) Str::uuid(); + DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Synthetic '.$type, 'location_type' => $type, + 'parent_id' => $parent, 'source_system' => 'cartografia-workbook', + 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id, 'data_classification' => 'public']); + + return $id; + } + + public function test_public_html_resolves_the_real_compiled_pages_without_private_fields(): void + { + $this->enableSyntheticConsumer(); + $offer = $this->fixture(); + $directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build'; + $manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR); + $this->app->instance(Vite::class, (new Vite)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-offers-no-hot'))); + foreach (['offers/catalogue' => '/ofertas', 'offers/public-detail' => '/ofertas/'.$offer->id] as $page => $url) { + $key = 'resources/js/pages/'.$page.'.tsx'; + $this->assertArrayHasKey($key, $manifest); + $this->get($url, ['Accept' => 'text/html'])->assertOk()->assertSee($manifest[$key]['file'], false)->assertDontSee('PRIVATE_CANARY'); + } + } + + public function test_existing_catalogue_keeps_its_own_currency_and_price_validation_contract(): void + { + $filters = app(InvestmentCatalogue::class)->filters(Request::create('/oportunidades', 'GET', ['currency' => 'EUR', 'min_amount' => '10', 'max_amount' => '20'])); + $this->assertSame('EUR', $filters['currency']); + $this->assertSame('10', $filters['min_amount']); + $this->getJson('/api/v1/offers?currency=EUR&min_amount=10')->assertUnprocessable(); + } + + public function test_native_combined_filters_and_export_with_self_contained_synthetic_references(): void + { + $province = $this->territory('province', $this->territory('country')); + DB::table('geo_locations')->where('id', $province)->update(['name' => 'Inhambane']); + Sector::create(['slug' => 'turismo', 'name' => 'Synthetic QA tourism', 'is_active' => true]); + $fixture = OpportunitySearchFixture::create(); + $this->getJson('/api/v1/investment/search?max_amount=500000')->assertUnprocessable()->assertJsonValidationErrors('currency'); + $filters = ['q' => $fixture['query'], 'sector' => 'turismo', 'province_id' => $province, + 'category' => 'investment_project', 'preparation' => 'historical:2', 'max_amount' => '500000', 'currency' => 'USD', 'park_id' => $fixture['zone']->id]; + $query = http_build_query($filters); + $this->getJson('/api/v1/investment/search?'.$query)->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $fixture['associated']->id); + $this->getJson('/api/v1/investment/search?'.http_build_query([...$filters, 'max_amount' => '1']))->assertOk()->assertJsonPath('total', 0); + $this->get('/oportunidades?'.$query)->assertOk()->assertInertia(fn (Assert $page) => $page->component('investment/catalogue')->where('results.total', 1)); + $csv = $this->get('/api/v1/investment/export?'.$query)->assertOk()->streamedContent(); + $this->assertStringContainsString($fixture['associated']->title, $csv); + $this->assertStringNotContainsString($fixture['private']->title, $csv); + $this->assertStringNotContainsString($fixture['nearby']->title, $csv); + } +}