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

171 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
/** Factual aggregates over authorised operational records. No visitor tracking or success score. */
final class ProductUsageMetrics
{
public function __construct(private readonly VirtualDesk $desk, private readonly PublicSearchMetrics $searchMetrics) {}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function report(User $actor, array $input): array
{
abort_unless($actor->can('manage-portal'), 403);
$now = CarbonImmutable::now('UTC');
$v = Validator::make($input, ['from' => 'nullable|date_format:Y-m-d', 'to' => 'nullable|date_format:Y-m-d', 'service_code' => 'nullable|string|max:100', 'search_definition' => 'nullable|string|max:100'])->validate();
$from = CarbonImmutable::parse($v['from'] ?? $now->subDays(29)->toDateString(), 'UTC')->startOfDay();
$to = CarbonImmutable::parse($v['to'] ?? $now->toDateString(), 'UTC')->startOfDay();
abort_unless($from <= $to && $to <= $now->startOfDay(), 422, 'Escolha um período válido, sem datas futuras.');
$cutoff = $to->addDay()->min($now);
$service = $v['service_code'] ?? null;
$service = $service === '' ? null : $service;
$searchDefinition = $v['search_definition'] ?? null;
return DB::transaction(function () use ($from, $to, $cutoff, $service, $now, $searchDefinition) {
// Production GET owns a consistent read-only snapshot. PHPUnit may already own its rollback transaction.
$consistency = DB::transactionLevel() === 1 ? 'repeatable_read' : 'existing_transaction';
if ($consistency === 'repeatable_read') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$services = Schema::hasTable('desk_service_versions') ? DB::table('desk_service_versions')->orderBy('code')->orderByDesc('version')->get(['code', 'definition', 'version'])->unique('code')->map(fn ($s) => ['code' => $s->code, 'title' => json_decode($s->definition, true)['title'] ?? $s->code, 'version' => (int) $s->version])->values()->all() : [];
abort_if($service !== null && ! in_array($service, array_column($services, 'code'), true), 422, 'Escolha um serviço existente, incluindo as suas versões históricas.');
$start = $from->format('Y-m-d H:i:s.uP');
$end = $cutoff->format('Y-m-d H:i:s.uP');
$requestSources = Schema::hasTable('desk_requests') && Schema::hasTable('desk_service_versions');
$eventsReady = Schema::hasTable('desk_events') && Schema::hasColumn('desk_events', 'event_order');
$metrics = [];
$coverage = ['cohort_requests' => null, 'complete_histories' => null, 'unknown_histories' => null, 'classified_returned_requests' => null, 'unclassified_returned_requests' => null, 'valid_duration_requests' => null, 'invalid_duration_requests' => null];
$createdCases = null;
if (Schema::hasTable('desk_cases') && ($service === null || $requestSources)) {
$q = DB::table('desk_cases')->where('created_at', '>=', $start)->where('created_at', '<', $end);
if ($service !== null) {
$q->whereExists(fn ($sub) => $sub->selectRaw('1')->from('desk_requests as r')->join('desk_service_versions as s', 's.id', '=', 'r.service_version_id')
->whereColumn('r.case_id', 'desk_cases.id')->where('s.code', $service)->where('r.created_at', '>=', $start)->where('r.created_at', '<', $end));
}
$createdCases = $q->count();
}
$metrics[] = $this->metric('dossiers_created', 'Dossiers criados', $createdCases, 'dossiers', null, null, 'desk_cases.created_at; no segmento de serviço, exige também um pedido desse serviço criado no período.', 'Uma linha por dossier, sem somar pedidos ou inferir pessoas únicas.');
if ($requestSources) {
$requests = DB::table('desk_requests as r')->join('desk_service_versions as s', 's.id', '=', 'r.service_version_id')
->where('r.created_at', '>=', $start)->where('r.created_at', '<', $end)->when($service !== null, fn ($q) => $q->where('s.code', $service));
$total = (clone $requests)->count();
$coverage = ['cohort_requests' => $total, 'complete_histories' => 0, 'unknown_histories' => $total, 'classified_returned_requests' => 0, 'unclassified_returned_requests' => 0, 'valid_duration_requests' => 0, 'invalid_duration_requests' => 0];
$submitted = $returned = $reasonKnown = $reasonUnknown = $errorDocuments = $unsubmitted = $durationCount = $durationInvalid = 0;
$durationSum = 0.0;
if ($eventsReady) {
$requests->select(['r.id', 'r.case_id', 'r.version', 'r.created_at', 'r.updated_at'])->orderBy('r.id')->chunk(100, function ($batch) use ($end, &$coverage, &$submitted, &$returned, &$reasonKnown, &$reasonUnknown, &$errorDocuments, &$unsubmitted, &$durationCount, &$durationInvalid, &$durationSum) {
$events = DB::table('desk_events')->whereIn('request_id', $batch->pluck('id'))->where('created_at', '<', $end)->orderBy('event_order')
->select(['id', 'request_id', 'version', 'action', 'created_at', 'event_order'])
->selectRaw("CASE WHEN action = 'request_returned' THEN payload ELSE NULL END AS classification_payload")->get()->groupBy('request_id');
foreach ($batch as $request) {
$history = $events->get($request->id);
if ($history === null) {
continue;
}
$versions = $history->pluck('version')->map(fn ($version) => (int) $version)->unique();
$max = $versions->max() ?? 0;
$created = $history->contains(fn ($event) => $event->action === 'draft_created' && (int) $event->version === 1);
$currentAtCutoff = $request->updated_at === null || CarbonImmutable::parse($request->updated_at)->lt(CarbonImmutable::parse($end));
if (! $created || $max === 0 || $versions->min() !== 1 || $versions->count() !== $max || ($currentAtCutoff && $max !== (int) $request->version)) {
continue;
}
$coverage['complete_histories']++;
$first = $history->firstWhere('action', 'submitted');
if ($first === null) {
$unsubmitted++;
continue;
}
$submitted++;
$elapsed = CarbonImmutable::parse($request->created_at)->diffInSeconds(CarbonImmutable::parse($first->created_at), false);
if ($elapsed >= 0) {
$durationCount++;
$durationSum += $elapsed;
} else {
$durationInvalid++;
}
$returns = $history->filter(fn ($event) => $event->action === 'request_returned' && $event->event_order > $first->event_order);
if ($returns->isEmpty()) {
continue;
}
$returned++;
$unknown = $bad = false;
foreach ($returns as $event) {
try {
$category = $this->desk->decode($event->classification_payload)['return_reason_code'] ?? 'unspecified';
} catch (\Throwable) {
$category = 'unspecified';
}
$known = in_array($category, ['validation_error', 'missing_documents', 'other'], true);
$unknown = $unknown || ! $known;
$bad = $bad || in_array($category, ['validation_error', 'missing_documents'], true);
}
if ($bad) {
$errorDocuments++;
$reasonKnown++;
} elseif ($unknown) {
$reasonUnknown++;
} else {
$reasonKnown++;
}
}
});
}
$complete = $coverage['complete_histories'];
$coverage = [...$coverage, 'unknown_histories' => $total - $complete, 'classified_returned_requests' => $reasonKnown, 'unclassified_returned_requests' => $reasonUnknown, 'valid_duration_requests' => $durationCount, 'invalid_duration_requests' => $durationInvalid];
$metrics[] = $this->metric('requests_created', 'Pedidos criados', $total, 'pedidos', null, null, 'desk_requests.created_at e versão do serviço ligada ao pedido.', 'Coorte de pedidos criados no período; inclui cancelamentos. Não são pessoas únicas.');
$metrics[] = $this->metric('submission_rate', 'Pedidos com primeira submissão observada', $complete > 0 ? round(100 * $submitted / $complete, 2) : null, '%', $submitted, $complete, 'Primeiro evento submitted por pedido com draft_created v1 e versões contíguas até ao corte.', 'Apenas o subconjunto com historial completo; reenvios não aumentam o numerador. Não mede aprovação.', $total > $complete ? 'partial' : null);
$metrics[] = $this->metric('return_rate', 'Pedidos submetidos que tiveram devolução', $submitted > 0 ? round(100 * $returned / $submitted, 2) : null, '%', $returned, $submitted, 'Pelo menos um request_returned posterior à primeira submissão, até ao corte.', 'Mesmo pedido conta uma vez, ainda que devolvido/reenviado várias vezes. Inclui qualquer motivo.', $total > $complete ? 'partial' : null);
$metrics[] = $this->metric('error_document_return_rate', 'Devoluções por erro ou documentação em falta', $submitted > 0 && $reasonUnknown === 0 ? round(100 * $errorDocuments / $submitted, 2) : null, '%', $errorDocuments, $submitted, 'Categoria explícita no evento de devolução: validation_error ou missing_documents.', 'Motivo livre não é classificado por palavras. Devoluções históricas sem categoria podem impedir uma taxa exacta; casos com causa explicitamente comprovada contam uma vez.', $reasonUnknown > 0 || $total > $complete ? 'partial' : null);
$metrics[] = $this->metric('first_submission_seconds', 'Tempo do rascunho até à primeira submissão', $durationCount > 0 ? round($durationSum / $durationCount, 1) : null, 'segundos', null, $durationCount, 'Média de first(submitted.created_at) − desk_requests.created_at, com ordem e intervalo não negativo.', 'Não é tempo de criação do projecto, cronómetro do navegador ou SLA. Intervalos negativos ficam fora da média e são identificados.', $durationInvalid > 0 || $total > $complete ? 'partial' : null);
$metrics[] = $this->metric('not_submitted', 'Pedidos ainda sem submissão observada', $eventsReady && ($complete > 0 || $total === 0) ? $unsubmitted : null, 'pedidos', $unsubmitted, $complete, 'Pedidos com historial completo e sem submitted até ao corte.', 'Não classificar estes rascunhos/cancelamentos como abandono.', $total > $complete ? 'partial' : null);
} else {
foreach (['requests_created' => 'Pedidos criados', 'submission_rate' => 'Pedidos com primeira submissão observada', 'return_rate' => 'Pedidos submetidos que tiveram devolução', 'error_document_return_rate' => 'Devoluções por erro ou documentação em falta', 'first_submission_seconds' => 'Tempo até à primeira submissão', 'not_submitted' => 'Pedidos ainda sem submissão observada'] as $id => $label) {
$metrics[] = $this->metric($id, $label, null, '', null, null, 'Fonte do Balcão não instalada neste destino.', 'Sem fonte disponível, não apresentar zero.');
}
}
foreach (['company_comparison_saved' => ['saved_comparison', 'Dossiers com comparação guardada'], 'investment_guide_saved' => ['saved_guide', 'Dossiers com referência de guia guardada']] as $action => [$id, $label]) {
$count = null;
if ($eventsReady) {
$q = DB::table('desk_events')->where('action', $action)->where('created_at', '>=', $start)->where('created_at', '<', $end);
if ($service !== null) {
$q->whereExists(fn ($sub) => $sub->selectRaw('1')->from('desk_requests as r')->join('desk_service_versions as s', 's.id', '=', 'r.service_version_id')->whereColumn('r.case_id', 'desk_events.case_id')->where('s.code', $service)->where('r.created_at', '>=', $start)->where('r.created_at', '<', $end));
}
$count = $q->distinct()->count('case_id');
}
$metrics[] = $this->metric($id, $label, $count, 'dossiers', null, null, 'desk_events.action; case_id distinto no período, com segmento de pedidos quando seleccionado.', 'Versões/repetições de um mesmo dossier contam uma vez. Não mede consultas públicas ou pessoas únicas.');
}
$search = $this->searchMetrics->report($from->toDateString(), $to->toDateString(), $service, $searchDefinition);
$searchSource = 'Contadores diários UTC por canal/edição de recolha, lidos no snapshot do relatório. Edição '.$search['definition_version'].'.';
$searchLimits = $service === null ? 'Só respostas registadas; não mede pessoas, pesquisas únicas ou toda a utilização do portal. Sem registos não é zero.' : 'Sem associação comprovada à coorte do serviço seleccionado; não calcular para este filtro.';
$metrics[] = $this->metric('recorded_search_responses', 'Respostas de pesquisa registadas', $search['responses'], 'respostas', null, null, $searchSource, $searchLimits, $search['responses'] === null ? 'unmeasured' : 'partial');
$metrics[] = $this->metric('recorded_zero_search_responses', 'Respostas de pesquisa sem resultados principais', $search['zero_responses'], 'respostas', null, null, $searchSource, $searchLimits, $search['responses'] === null ? 'unmeasured' : 'partial');
$metrics[] = $this->metric('recorded_zero_search_rate', 'Taxa sem resultados entre respostas registadas', $search['zero_rate'], '%', $search['zero_responses'], $search['responses'], $searchSource, $searchLimits, $search['responses'] === null ? 'unmeasured' : 'partial');
$report = ['definition_version' => config('product-metrics.version'), 'generated_at' => $now->format('Y-m-d\TH:i:s.uP'),
'filters' => ['from' => $from->toDateString(), 'to' => $to->toDateString(), 'service_code' => $service, 'search_definition' => $search['definition_version']], 'timezone' => 'UTC', 'observed_before' => $cutoff->format('Y-m-d\TH:i:s.uP'),
'services' => $services, 'coverage' => $coverage, 'metrics' => $metrics, 'unmeasured' => config('product-metrics.unmeasured'), 'read_consistency' => $consistency, 'search' => $search,
'limits' => ['Contagens de dossiers, pedidos, respostas HTTP e pessoas não são somáveis.', 'As taxas do Balcão referem-se à coorte de pedidos com historial verificável; a taxa de pesquisa tem como base as respostas registadas. Não são um funil geral do portal.', 'Eventos do Balcão usam o corte temporal indicado; pesquisas usam contagens diárias no snapshot da leitura, com o dia actual parcial e sem corte por fracção de segundo.', 'A leitura usa as permissões existentes. Os contadores de pesquisa não guardam termos, valores de filtros, IP, cookies, utilizadores ou identificadores de visitantes.', 'O fingerprint identifica o conteúdo deste relatório, não certifica a veracidade dos registos de origem.']];
$report['fingerprint'] = hash('sha256', json_encode($report, JSON_THROW_ON_ERROR));
return $report;
});
}
/** @return array<string,mixed> */
private function metric(string $id, string $title, int|float|null $value, string $unit, ?int $numerator, ?int $denominator, string $source, string $limits, ?string $state = null): array
{
return ['id' => $id, 'title' => $title, 'value' => $value, 'unit' => $unit, 'numerator' => $numerator, 'denominator' => $denominator,
'state' => $state ?? ($value === null ? 'unmeasured' : 'observed'), 'source' => $source, 'limits' => $limits];
}
}