Files
Investidor-V2/app/Services/OfferPublicCatalogue.php
T

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];
}
}