251 lines
12 KiB
PHP
251 lines
12 KiB
PHP
<?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"), DB::raw("workflow_context->'offer_operation' AS operation_state")]);
|
|
$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);
|
|
$operation = json_decode($row->getAttribute('operation_state') ?? 'null', true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException $error) {
|
|
continue;
|
|
}
|
|
if (! app(OfferAvailability::class)->publicAllowed($operation, $publication)) {
|
|
continue;
|
|
}
|
|
$p = $this->project($row->id, $publication);
|
|
if (! $p) {
|
|
continue;
|
|
}
|
|
$expires = app(OfferAvailability::class)->view($operation, $publication)['expires_at'];
|
|
if ($expires !== null) {
|
|
$p['expires_at'] = $expires;
|
|
}
|
|
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"), DB::raw("workflow_context->'offer_operation' AS operation_state")]);
|
|
if (! $row || ! is_string($row->getAttribute('public_snapshot'))) {
|
|
abort(404);
|
|
}
|
|
try {
|
|
$publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR);
|
|
$operation = json_decode($row->getAttribute('operation_state') ?? 'null', true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException $error) {
|
|
abort(404);
|
|
}
|
|
abort_unless(app(OfferAvailability::class)->publicAllowed($operation, $publication), 404);
|
|
$record = $this->project($id, $publication);
|
|
abort_unless($record !== null, 404);
|
|
$expires = app(OfferAvailability::class)->view($operation, $publication)['expires_at'];
|
|
if ($expires !== null) {
|
|
$record['expires_at'] = $expires;
|
|
}
|
|
|
|
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];
|
|
}
|
|
}
|