- VisibilityStats: Firmen-Kennzahlen ueber alle Meldungen, Aufrufe je Meldung (30 Tage), lueckenlose Tagesreihe (Tage vor der Zaehlung sind null, nicht 0), Sitemap-seit und Google-Suchlink (Indexierung Variante B). - Firmenseite: alle Meldungen der Firma statt nur eigene (Beansprucher sahen eine leere Liste), Aufrufe-Spalten, echtes Statistik-Panel. - Meldungsseite: Panel Sichtbarkeit mit SVG-Tagesbalken 30/90 Tage. - Claim aus der Veroeffentlichungsmail landet auf der Meldung (payload press_release_id), Mail-Hinweis erweitert, ein Element (E4). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
7.6 KiB
PHP
196 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\PressRelease;
|
|
|
|
use App\Enums\PressReleaseEventType;
|
|
use App\Enums\PressReleaseShareChannel;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\Company;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseEvent;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* Sichtbarkeit belegen (Roadmap 1c): Aufrufe je Meldung und je Firma, als
|
|
* Rohzahlen — Entscheidung E2, keine Einordnung gegen den Portalschnitt.
|
|
*
|
|
* Zwei Quellen, bewusst getrennt gelesen:
|
|
*
|
|
* - `press_releases.hits` ist die Summe seit jeher, inklusive Altsystem.
|
|
* Sie wird vom Scheduler aus den Ereignissen nachgeführt und ist die
|
|
* einzige Zahl, die auch für Meldungen vor Juli 2026 stimmt.
|
|
* - `press_release_events` trägt je Aufruf einen Zeitstempel (seit
|
|
* 17.07.2026, 13 Monate Aufbewahrung) und liefert den Verlauf. Ein
|
|
* Tageswert älter als die Aufbewahrung existiert nicht mehr — die
|
|
* Verlaufsgrafik darf ihn nicht als „0 Aufrufe" ausgeben.
|
|
*
|
|
* Der Indexierungsstatus ist in dieser Stufe bewusst ohne Fremddienst
|
|
* (Entscheidung 16.09.2026, Variante B): „in der Sitemap seit" plus ein
|
|
* Link auf die Suche. Die Search-Console-Anbindung kommt als eigenes Paket.
|
|
*/
|
|
class VisibilityStats
|
|
{
|
|
/**
|
|
* Kennzahlen einer Firma über alle ihre Meldungen — unabhängig davon, wer
|
|
* sie eingereicht hat. Ein Beansprucher sieht damit auch, was connektar
|
|
* für ihn eingeliefert hat.
|
|
*
|
|
* @return array{releases: int, published: int, views_total: int, views_recent: int, last_published_at: Carbon|null}
|
|
*/
|
|
public function companySummary(Company $company, int $days = 30): array
|
|
{
|
|
$base = PressRelease::query()->withoutGlobalScopes()->where('company_id', $company->id);
|
|
|
|
$row = $base->clone()
|
|
->selectRaw('count(*) as releases')
|
|
->selectRaw('sum(case when status = ? then 1 else 0 end) as published', [PressReleaseStatus::Published->value])
|
|
->selectRaw('coalesce(sum(hits), 0) as views_total')
|
|
->selectRaw('max(published_at) as last_published_at')
|
|
->toBase()
|
|
->first();
|
|
|
|
$recent = PressReleaseEvent::query()
|
|
->where('type', PressReleaseEventType::View->value)
|
|
->where('created_at', '>=', now()->subDays($days))
|
|
->whereIn('press_release_id', $base->clone()->select('id'))
|
|
->count();
|
|
|
|
return [
|
|
'releases' => (int) ($row->releases ?? 0),
|
|
'published' => (int) ($row->published ?? 0),
|
|
'views_total' => (int) ($row->views_total ?? 0),
|
|
'views_recent' => $recent,
|
|
'last_published_at' => ($row->last_published_at ?? null) ? Carbon::parse($row->last_published_at) : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Aufrufe der letzten N Tage je Meldung, für eine Liste.
|
|
*
|
|
* @param list<int> $pressReleaseIds
|
|
* @return array<int, int>
|
|
*/
|
|
public function recentViewsFor(array $pressReleaseIds, int $days = 30): array
|
|
{
|
|
if ($pressReleaseIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return PressReleaseEvent::query()
|
|
->where('type', PressReleaseEventType::View->value)
|
|
->where('created_at', '>=', now()->subDays($days))
|
|
->whereIn('press_release_id', $pressReleaseIds)
|
|
->selectRaw('press_release_id, count(*) as views')
|
|
->groupBy('press_release_id')
|
|
->pluck('views', 'press_release_id')
|
|
->map(fn ($views): int => (int) $views)
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Tagesreihe der Aufrufe, lückenlos über die letzten N Tage (heute
|
|
* eingeschlossen). Tage vor Beginn der Ereignisaufzeichnung oder vor der
|
|
* Veröffentlichung sind `null`, nicht 0 — es gab dort keine Messung.
|
|
*
|
|
* @return list<array{date: Carbon, views: int|null}>
|
|
*/
|
|
public function dailyViews(PressRelease $pressRelease, int $days = 30): array
|
|
{
|
|
$days = max(1, min($days, (int) config('press_releases.events.retention_days', 395)));
|
|
$start = now()->startOfDay()->subDays($days - 1);
|
|
|
|
$counts = $pressRelease->events()
|
|
->where('type', PressReleaseEventType::View->value)
|
|
->where('created_at', '>=', $start)
|
|
->selectRaw('date(created_at) as day, count(*) as views')
|
|
->groupBy('day')
|
|
->pluck('views', 'day');
|
|
|
|
$measuredFrom = $this->measurementStart($pressRelease);
|
|
$series = [];
|
|
|
|
for ($i = 0; $i < $days; $i++) {
|
|
$day = $start->copy()->addDays($i);
|
|
$series[] = [
|
|
'date' => $day,
|
|
'views' => $measuredFrom !== null && $day->lt($measuredFrom->copy()->startOfDay())
|
|
? null
|
|
: (int) ($counts[$day->toDateString()] ?? 0),
|
|
];
|
|
}
|
|
|
|
return $series;
|
|
}
|
|
|
|
/**
|
|
* Einzelwerte einer Meldung für das Panel „Sichtbarkeit".
|
|
*
|
|
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, search_url: string|null}
|
|
*/
|
|
public function releaseSummary(PressRelease $pressRelease): array
|
|
{
|
|
$views = $pressRelease->events()
|
|
->where('type', PressReleaseEventType::View->value)
|
|
->selectRaw('min(created_at) as first_view, max(created_at) as last_view')
|
|
->toBase()
|
|
->first();
|
|
|
|
$shares = $pressRelease->events()
|
|
->where('type', PressReleaseEventType::Share->value)
|
|
->selectRaw('channel, count(*) as clicks')
|
|
->groupBy('channel')
|
|
->pluck('clicks', 'channel')
|
|
->mapWithKeys(fn ($clicks, $channel): array => [
|
|
PressReleaseShareChannel::tryFrom((string) $channel)?->label() ?? (string) $channel => (int) $clicks,
|
|
])
|
|
->sortDesc();
|
|
|
|
$links = $pressRelease->isVisibleInArchive() ? $pressRelease->publicPortalLinks() : [];
|
|
$firstUrl = $links[0]['url'] ?? null;
|
|
|
|
return [
|
|
'hits' => (int) $pressRelease->hits,
|
|
'first_view_at' => $views?->first_view ? Carbon::parse($views->first_view) : null,
|
|
'last_view_at' => $views?->last_view ? Carbon::parse($views->last_view) : null,
|
|
'shares' => $shares->all(),
|
|
'shares_total' => (int) $shares->sum(),
|
|
// Die Sitemap listet jede im Archiv sichtbare Meldung — ab dem
|
|
// Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht
|
|
// über Google.
|
|
'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
|
|
'search_url' => $firstUrl !== null ? 'https://www.google.com/search?q='.rawurlencode('site:'.preg_replace('#^https?://#', '', $firstUrl)) : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Ab wann es für diese Meldung überhaupt Tageswerte geben kann: der
|
|
* spätere von Veröffentlichung und Beginn der Ereignisaufzeichnung.
|
|
*/
|
|
private function measurementStart(PressRelease $pressRelease): ?Carbon
|
|
{
|
|
$candidates = collect([
|
|
$pressRelease->published_at,
|
|
$this->eventsSince(),
|
|
])->filter();
|
|
|
|
return $candidates->isEmpty() ? null : $candidates->max();
|
|
}
|
|
|
|
private function eventsSince(): ?Carbon
|
|
{
|
|
$since = config('press_releases.events.recorded_since');
|
|
|
|
return $since ? Carbon::parse((string) $since) : null;
|
|
}
|
|
|
|
/**
|
|
* Umschaltbare Zeiträume der Verlaufsgrafik.
|
|
*
|
|
* @return Collection<int, int>
|
|
*/
|
|
public static function ranges(): Collection
|
|
{
|
|
return collect([30, 90]);
|
|
}
|
|
}
|