feat(offers): add gated phase three public catalogue in QA
This commit is contained in:
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? ''}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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¤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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user