feat(offers): add gated phase three public catalogue in QA

This commit is contained in:
2026-11-14 16:49:58 +02:00
parent 91c1857368
commit 7691cab3bc
18 changed files with 1153 additions and 6 deletions
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use App\Services\OfferPublicCatalogue;
use App\Services\OpportunityManagement;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class OfferPublicCatalogueController extends Controller
{
public function index(Request $request, OfferPublicCatalogue $catalogue): Response
{
$filters = $catalogue->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']);
}
}
+4 -4
View File
@@ -21,7 +21,7 @@ class InvestmentCatalogue
'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']];
/** @return array<string,mixed> */
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'],
+236
View File
@@ -0,0 +1,236 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Support\IsolatedQaDatabase;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Read-only consumer. Effective publication remains unavailable until confidentiality is delivered.
* The injected fixture mode is never configurable by HTTP, environment flags or stored content. */
final class OfferPublicCatalogue
{
public const CONTRACT = 'offer-public-projection-v1';
public function __construct(private bool $syntheticFixtureMode = false) {}
private function fixtureReadAllowed(): bool
{
return $this->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<string,mixed> */
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<string,mixed>|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<string,mixed> $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<string,mixed> $filters
* @return array{records:list<array<string,mixed>>,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<string,mixed> */
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<string,mixed> $filters
* @return array{results:LengthAwarePaginator<int,array<string,mixed>>,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<string,mixed> $filters
* @return array<string,mixed> */
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];
}
}
@@ -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.
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.
File diff suppressed because one or more lines are too long
@@ -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.
@@ -0,0 +1 @@
{"tool":"phpunit","result":"passed","tests":10,"passed":10,"assertions":133,"duration_ms":6992}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="Tests\Feature\OfferPublicCatalogueTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" tests="10" assertions="133" errors="0" failures="0" skipped="0" time="6.988758">
<testcase name="test_effective_publication_remains_closed_even_with_a_stored_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="51" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="9" time="4.618124"/>
<testcase name="test_pages_api_csv_and_map_only_use_the_explicit_public_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="68" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="35" time="0.191592"/>
<testcase name="test_invalid_unapproved_unpublished_or_sensitive_snapshots_are_not_readable" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="88" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="10" time="0.165098"/>
<testcase name="test_pending_private_edits_do_not_replace_the_previously_approved_projection" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="101" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="6" time="0.134415"/>
<testcase name="test_prices_are_exact_and_currencies_are_never_converted" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="117" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="12" time="0.175313"/>
<testcase name="test_csv_formula_text_is_escaped_and_http_has_no_publication_operation" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="132" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="5" time="0.162825"/>
<testcase name="test_location_category_and_pagination_use_only_the_published_snapshot" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="143" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="23" time="0.369867"/>
<testcase name="test_public_html_resolves_the_real_compiled_pages_without_private_fields" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="188" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="8" time="0.144665"/>
<testcase name="test_existing_catalogue_keeps_its_own_currency_and_price_validation_contract" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="202" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="3" time="0.094199"/>
<testcase name="test_native_combined_filters_and_export_with_self_contained_synthetic_references" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferPublicCatalogueTest.php" line="210" class="Tests\Feature\OfferPublicCatalogueTest" classname="Tests.Feature.OfferPublicCatalogueTest" assertions="22" time="0.932659"/>
</testsuite>
</testsuites>
@@ -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"
}
+2
View File
@@ -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/'):
@@ -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<string, string>;
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 (
<form
className="grid min-w-0 gap-4 rounded-xl border bg-white p-4 sm:grid-cols-2 lg:grid-cols-3"
aria-label="Pesquisar ofertas"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<Field label="O que procura">
<input
className={input}
value={String(data.q ?? '')}
maxLength={120}
onChange={(event) => change({ q: event.target.value })}
/>
</Field>
{select('category', choicesFor('category'))}
{[0, 1].map((index) => (
<TerritoryLevel
key={`${index}-${path[index - 1]?.id ?? ''}`}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint="/api/v1/investment/territory-options"
onChange={(value) =>
territory([
...path.slice(0, index),
...(value ? [value] : []),
])
}
/>
))}
<Field label="Preço">
<select
className={input}
value={String(data.price_mode ?? '')}
onChange={(event) =>
change({
price_mode: event.target.value,
...(event.target.value === 'negotiable'
? {
currency: '',
min_amount: '',
max_amount: '',
sort: 'recent',
}
: {}),
})
}
>
<option value="">Todos os preços</option>
<option value="amount">Com montante indicado</option>
<option value="negotiable">A negociar</option>
</select>
</Field>
<Field label="Moeda">
<select
className={input}
disabled={data.price_mode === 'negotiable'}
value={String(data.currency ?? '')}
onChange={(event) =>
change({ currency: event.target.value })
}
>
<option value="">Todas as moedas</option>
<option value="MZN">MZN</option>
<option value="USD">USD</option>
</select>
</Field>
{[
['min_amount', 'Preço mínimo'],
['max_amount', 'Preço máximo'],
].map(([key, label]) => (
<Field key={key} label={label}>
<input
className={input}
inputMode="decimal"
disabled={data.price_mode === 'negotiable'}
value={String(data[key] ?? '')}
maxLength={19}
placeholder="Ex.: 250000.00"
onChange={(event) =>
change({ [key]: event.target.value })
}
/>
</Field>
))}
<Field label="Ordenar por">
<select
className={input}
value={String(data.sort ?? 'recent')}
onChange={(event) =>
change({ sort: event.target.value })
}
>
<option value="recent">Mais recentes</option>
<option value="title">Título</option>
<option value="amount_asc">
Preço crescente, na moeda seleccionada
</option>
<option value="amount_desc">
Preço decrescente, na moeda seleccionada
</option>
</select>
</Field>
{Object.keys(errors).length > 0 && (
<ul role="alert" className="sm:col-span-2 lg:col-span-3">
{Object.entries(errors).map(([key, value]) => (
<li key={key}>{value}</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-3 sm:col-span-2 lg:col-span-3">
<button type="submit" className={button}>
Pesquisar
</button>
<button
type="button"
className="rounded-lg border px-4 py-2"
onClick={clear}
>
Limpar filtros
</button>
</div>
</form>
);
return (
<form
className="catalogue-filters"
@@ -0,0 +1,43 @@
import { Link } from '@inertiajs/react';
import { modalityLabels } from '@/components/opportunity-search-filters';
import { offerPrice, type PublicOffer } from '@/lib/public-offer';
import { card } from '@/pages/investment/shared';
export default function PublicOfferCard({
offer,
detail = false,
}: {
offer: PublicOffer;
detail?: boolean;
}) {
const Heading = detail ? 'h1' : 'h2';
return (
<article className={`${card} min-w-0 space-y-3 break-words`}>
<p className="text-sm text-slate-600">
{modalityLabels[offer.category]} ·{' '}
{offer.location.map((node) => node.name).join(' / ') ||
'Localização não indicada'}
</p>
<Heading className="text-xl font-semibold">
{detail ? (
offer.title
) : (
<Link
className="underline decoration-emerald-700 underline-offset-4"
href={offer.url}
>
{offer.title}
</Link>
)}
</Heading>
<p className="whitespace-pre-line">{offer.summary}</p>
<p className="font-semibold text-emerald-900">
{offerPrice(offer.price)}
</p>
<p className="text-sm text-slate-600">
Publicada em{' '}
{new Date(offer.published_at).toLocaleDateString('pt-MZ')}
</p>
</article>
);
}
+22
View File
@@ -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 ?? ''}`;
}
+162
View File
@@ -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<string, string>;
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 (
<Frame title="Ofertas">
<header className="mb-6 space-y-2">
<h1 className="text-3xl font-semibold">Ofertas</h1>
<p>Consulte ofertas por categoria, localização e preço.</p>
</header>
<OpportunitySearchFilters
mode="offers"
data={form.data}
references={references}
path={path}
change={(values) =>
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}
/>
<SearchState
loading={form.processing}
total={results.total}
retry={() => form.get('/ofertas')}
countLabel="ofertas publicadas"
/>
{truncated && (
<p role="note">
Apresentação limitada a 1.000 ofertas. Refine os filtros; os
totais referem-se aos resultados representados.
</p>
)}
<div
className="my-4 flex flex-wrap items-center gap-4"
aria-label="Apresentação e exportação"
>
{['grid', 'list', 'combined'].map((view) => (
<Link
key={view}
href={`/ofertas?${new URLSearchParams({ ...Object.fromEntries(query), view })}`}
aria-current={
(filters.view ?? 'grid') === view
? 'page'
: undefined
}
>
{
(
{
grid: 'Grelha',
list: 'Lista',
combined: 'Lista e mapa',
} as Record<string, string>
)[view]
}
</Link>
))}
<a
className="underline"
href={`/api/v1/offers/export?${query}`}
>
Exportar CSV
</a>
</div>
{mapData && (
<section className="my-5">
<h2 className="mb-2 text-xl font-semibold">
Localização geral
</h2>
<p className="mb-3">
Os limites administrativos são contexto territorial e
não indicam a localização exacta dos negócios.
</p>
<CatalogueInvestmentMap
points={[]}
areas={mapData.areas}
territoryId={
filters.district_id ||
filters.province_id ||
undefined
}
mapKind="administrative"
/>
</section>
)}
<div
className={`grid min-w-0 gap-4 ${filters.view === 'list' ? '' : 'md:grid-cols-2 xl:grid-cols-3'}`}
>
{results.data.map((offer) => (
<PublicOfferCard key={offer.id} offer={offer} />
))}
</div>
<nav
className="mt-6 flex flex-wrap gap-5"
aria-label="Páginas de ofertas"
>
{results.prev_page_url && (
<Link href={results.prev_page_url}>Anterior</Link>
)}
{results.next_page_url && (
<Link href={results.next_page_url}>Seguinte</Link>
)}
</nav>
</Frame>
);
}
@@ -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 (
<Frame title={offer.title} mainClassName="max-w-4xl">
<Link className="mb-5 inline-block underline" href="/ofertas">
Voltar às ofertas
</Link>
<PublicOfferCard offer={offer} detail />
{territoryId && (
<section className="mt-6">
<h2 className="mb-3 text-xl font-semibold">
Contexto territorial
</h2>
<CatalogueInvestmentMap
points={[]}
territoryId={territoryId}
mapKind="administrative"
/>
</section>
)}
</Frame>
);
}
+5
View File
@@ -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']);
+3
View File
@@ -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');
+228
View File
@@ -0,0 +1,228 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentCatalogue;
use App\Services\OfferPublicCatalogue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\Support\OpportunitySearchFixture;
use Tests\TestCase;
final class OfferPublicCatalogueTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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&currency=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);
}
}