, * totals: array{eingereicht: int, veroeffentlicht: int, abgelehnt: int, offen: int, quote: float|null}, * entscheidungen_ohne_einreichung: int, * } */ public function build(Carbon $from, ?Carbon $to = null): array { $to ??= Carbon::now(); $logs = PressReleaseStatusLog::query() ->whereIn('to_status', [ PressReleaseStatus::Review->value, PressReleaseStatus::Published->value, PressReleaseStatus::Rejected->value, ]) ->whereBetween('created_at', [$from, $to]) ->orderBy('press_release_id') ->orderBy('id') ->get(['press_release_id', 'to_status', 'source', 'changed_by_user_id']); $buckets = []; $orphans = 0; $open = null; foreach ($logs as $log) { $status = $log->to_status; if ($status === PressReleaseStatus::Review) { // Zwei Einreichungen ohne Entscheidung dazwischen: die erste // ist noch offen (in Prüfung) und wird als solche gezählt. if ($open !== null) { $this->add($buckets, $open, 'offen'); } $open = ['source' => (string) $log->source, 'user_id' => $log->changed_by_user_id]; $this->add($buckets, $open, 'eingereicht'); continue; } if ($open === null) { // Entscheidung zu einer Einreichung, die vor dem Zeitraum lag. // Nicht zuordenbar — wird gemeldet statt stillschweigend // verschluckt, sonst sähe ein zu kurz gewählter Zeitraum wie // eine niedrige Quote aus. $orphans++; continue; } $this->add($buckets, $open, $status === PressReleaseStatus::Rejected ? 'abgelehnt' : 'veroeffentlicht'); $open = null; } if ($open !== null) { $this->add($buckets, $open, 'offen'); } return $this->summarise($buckets, $from, $to, $orphans); } /** * @param array> $buckets * @param array{source: string, user_id: int|null} $submission */ private function add(array &$buckets, array $submission, string $field): void { $key = $this->bucketKey($submission); $buckets[$key] ??= [ 'key' => $key, 'user_id' => $submission['user_id'], 'source' => $submission['source'], 'eingereicht' => 0, 'veroeffentlicht' => 0, 'abgelehnt' => 0, 'offen' => 0, ]; $buckets[$key][$field]++; } /** * Jeder API-Client ist eine eigene Quelle — der Sinn der Auswertung ist ja * gerade, sie zu vergleichen. Einreichungen aus dem Kundenbereich fallen * dagegen zu „direkt" zusammen; dort ist der einzelne Absender keine * Quelle, sondern ein Einzelfall. * * @param array{source: string, user_id: int|null} $submission */ private function bucketKey(array $submission): string { return $submission['source'] === 'api' ? 'api:'.($submission['user_id'] ?? 0) : $submission['source']; } /** * @param array> $buckets * @return array */ private function summarise(array $buckets, Carbon $from, Carbon $to, int $orphans): array { $names = User::query() ->whereIn('id', array_filter(array_column($buckets, 'user_id'))) ->get(['id', 'name', 'agency_name']) ->mapWithKeys(fn (User $u): array => [$u->id => trim((string) $u->agency_name) ?: (string) $u->name]); $sources = []; $totals = ['eingereicht' => 0, 'veroeffentlicht' => 0, 'abgelehnt' => 0, 'offen' => 0]; foreach ($buckets as $bucket) { $sources[] = [ 'key' => $bucket['key'], 'label' => $this->label($bucket, $names->all()), 'eingereicht' => $bucket['eingereicht'], 'veroeffentlicht' => $bucket['veroeffentlicht'], 'abgelehnt' => $bucket['abgelehnt'], 'offen' => $bucket['offen'], 'quote' => $this->rate($bucket['veroeffentlicht'], $bucket['abgelehnt']), ]; foreach ($totals as $field => $value) { $totals[$field] = $value + $bucket[$field]; } } usort($sources, static fn (array $a, array $b): int => $b['eingereicht'] <=> $a['eingereicht']); $totals['quote'] = $this->rate($totals['veroeffentlicht'], $totals['abgelehnt']); return [ 'from' => $from->toDateString(), 'to' => $to->toDateString(), 'sources' => $sources, 'totals' => $totals, 'entscheidungen_ohne_einreichung' => $orphans, ]; } /** * @param array $bucket * @param array $names */ private function label(array $bucket, array $names): string { if ($bucket['source'] !== 'api') { return match ($bucket['source']) { 'customer', 'web' => 'direkt (Kundenbereich)', 'admin' => 'Redaktion', default => (string) $bucket['source'], }; } $userId = $bucket['user_id']; return sprintf('API: %s (#%s)', $names[$userId] ?? 'unbekannt', $userId ?? '?'); } /** * Bezugsgröße sind die ENTSCHIEDENEN Einreichungen. Noch offene Prüfungen * gehören nicht in den Nenner — sonst sänke die Quote allein dadurch, dass * der Zeitraum bis an den heutigen Tag reicht. */ private function rate(int $published, int $rejected): ?float { $decided = $published + $rejected; return $decided === 0 ? null : round($rejected / $decided * 100, 1); } }