syntheticFixtureMode && IsolatedQaDatabase::allowsSchemaChanges(); } /** Exact ordering of decimal strings, without currency conversion or floating point. * @param string $a @param string $b */ public static function comparePrice(string $a, string $b): int { $parts = static function (string $value): array { [$whole, $fraction] = [...explode('.', $value), '']; return [ltrim($whole, '0') ?: '0', str_pad($fraction, 2, '0')]; }; [$aw, $af] = $parts($a); [$bw, $bf] = $parts($b); return (strlen($aw) <=> strlen($bw)) ?: (strcmp($aw, $bw) <=> 0) ?: (strcmp($af, $bf) <=> 0); } /** @return array */ public function filters(Request $request): array { $filters = array_intersect_key(app(InvestmentCatalogue::class)->filters($request, true), array_flip([ 'q', 'category', 'province_id', 'district_id', 'currency', 'min_amount', 'max_amount', 'sort', 'view', 'page', 'per_page', ])); $extra = $request->validate(['price_mode' => 'nullable|in:amount,negotiable']); $filters = [...$filters, ...$extra]; if (isset($filters['min_amount'], $filters['max_amount']) && self::comparePrice($filters['min_amount'], $filters['max_amount']) > 0) { throw ValidationException::withMessages(['max_amount' => 'O preço máximo deve ser igual ou superior ao mínimo.']); } if (in_array($filters['sort'] ?? '', ['amount_asc', 'amount_desc'], true) && empty($filters['currency'])) { throw ValidationException::withMessages(['currency' => 'Seleccione uma moeda para ordenar por preço.']); } if (($filters['price_mode'] ?? '') === 'negotiable' && (isset($filters['min_amount']) || isset($filters['max_amount']))) { throw ValidationException::withMessages(['price_mode' => 'Retire o intervalo de montantes para procurar preços a negociar.']); } if (($filters['price_mode'] ?? '') === 'negotiable' && ! empty($filters['currency'])) { throw ValidationException::withMessages(['currency' => 'A moeda de um preço a negociar não foi indicada. Retire este filtro.']); } return $filters; } /** The explicit public snapshot, never a projection of current private columns. * @return array|null */ private function project(int $id, mixed $publication): ?array { if (! is_array($publication) || ($publication['contract'] ?? null) !== self::CONTRACT || ($publication['qa_fixture'] ?? false) !== true) { return null; } $v = Validator::make($publication, [ 'payload' => 'required|array:title,summary,category,province_id,district_id,price_mode,amount,currency', 'payload.title' => 'required|string|max:150', 'payload.summary' => 'required|string|max:1500', 'payload.category' => ['required', Rule::in(OpportunityManagement::CATEGORIES)], 'payload.province_id' => 'nullable|uuid', 'payload.district_id' => 'nullable|uuid', 'payload.price_mode' => 'required|in:amount,negotiable', 'payload.amount' => ['nullable', 'string', 'required_if:payload.price_mode,amount', 'prohibited_unless:payload.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'], 'payload.currency' => 'nullable|required_if:payload.price_mode,amount|prohibited_unless:payload.price_mode,amount|in:MZN,USD', 'approval.state' => 'required|in:approved', 'approval.hash' => 'required|string|size:64', 'approval.actor_id' => 'required|integer|min:1', 'approval.at' => 'required|date', 'publication.state' => 'required|in:published', 'publication.hash' => 'required|string|size:64', 'publication.actor_id' => 'required|integer|min:1|different:approval.actor_id', 'publication.at' => 'required|date|after_or_equal:approval.at|before_or_equal:now', 'revision' => 'required|integer|min:1', ]); if ($v->fails()) { return null; } $p = $v->validated()['payload']; $hash = self::projectionHash($p); if (! hash_equals($hash, $publication['approval']['hash']) || ! hash_equals($hash, $publication['publication']['hash'])) { return null; } try { $path = app(InvestmentGeography::class)->selection(['province_id' => $p['province_id'] ?? null, 'district_id' => $p['district_id'] ?? null]); } catch (ValidationException $error) { return null; } return ['id' => $id, 'title' => $p['title'], 'summary' => $p['summary'], 'category' => $p['category'], 'location' => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'level' => $node->location_type], $path), 'price' => ['mode' => $p['price_mode'], 'amount' => $p['amount'] ?? null, 'currency' => $p['currency'] ?? null], 'status' => 'published', 'published_at' => CarbonImmutable::parse($publication['publication']['at'])->utc()->toIso8601String(), 'revision' => $publication['revision'], 'url' => '/ofertas/'.$id]; } /** @param array $payload */ public static function projectionHash(array $payload): string { ksort($payload); return hash('sha256', json_encode(['contract' => self::CONTRACT, 'payload' => $payload], JSON_THROW_ON_ERROR)); } /** @param array $filters * @return array{records:list>,truncated:bool} */ public function records(array $filters = []): array { if (! $this->fixtureReadAllowed()) { return ['records' => [], 'truncated' => false]; } $rows = Opportunity::query()->whereNotNull('workflow_context->private_offer_preparation')->whereNotNull('workflow_context->offer_publication') ->orderBy('id')->limit(1001)->get(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot"), 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 */ 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 $filters * @return array{results:LengthAwarePaginator>,truncated:bool} */ public function page(Request $request, array $filters): array { $result = $this->records($filters); $size = (int) ($filters['per_page'] ?? 12); $page = (int) ($filters['page'] ?? 1); return ['results' => new LengthAwarePaginator(array_slice($result['records'], ($page - 1) * $size, $size), count($result['records']), $size, $page, ['path' => $request->url(), 'query' => $filters]), 'truncated' => $result['truncated']]; } /** @param array $filters * @return array */ public function map(array $filters): array { $result = $this->records($filters); $areas = []; $seen = []; foreach ($result['records'] as $record) { $id = $record['location'][array_key_last($record['location'])]['id'] ?? null; if (! $id || isset($seen[$id])) { continue; } $seen[$id] = true; $areas = [...$areas, ...app(TerritoryGeometry::class)->context($id)['areas']]; if (count($seen) >= 30) { break; } } return ['features' => [], 'areas' => $areas, 'matched_count' => count($result['records']), 'exact_locations_disclosed' => false, 'truncated' => $result['truncated'] || count($seen) >= 30]; } }