presseportale/app/Services/Growth/GrowthReport.php
Kevin Adametz b4870f98bb admin: Reports ▸ Wachstum mit den Abbruchbedingungen, Umami nur lesend angebunden
Roadmap 6.1/6.16: Block 1 bis 3 mit Ist, zeitanteiligem Soll, Schwelle und
Status, Trichter seit 17.09., Transparenzseiten aus Umami. Neue Befehle
growth:report und umami:report. Zugang über UMAMI_API_* (View-only-Benutzer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:55:29 +00:00

271 lines
11 KiB
PHP

<?php
namespace App\Services\Growth;
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Models\ConversionEvent;
use App\Services\Analytics\UmamiClient;
use App\Services\Analytics\UmamiUnavailable;
use App\Services\PressRelease\FeedFetchReport;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Stand der Abbruchbedingungen für Block 1 bis 3 (Roadmap 6.1, 6.16).
*
* Je Bedingung: Ist-Wert, Schwelle, Status. Für Bedingungen, die über den
* Zeitraum hinweg zusammenkommen (Block 1), zusätzlich „auf Kurs“: Liegt der
* Ist-Wert mindestens beim zeitanteiligen Soll? Block 2 und 3 sind
* Momentaufnahmen der letzten 30 Tage und haben kein Soll unterwegs.
*
* Quellen: eigene Ereignisdaten (Trichter, Feed-Abrufe) und Umami. Fällt
* Umami aus, bleibt der Rest stehen; die Umami-Werte zeigen dann den Grund.
*/
class GrowthReport
{
/** Herkünfte, die als „über Mail oder Claim“ zählen. */
private const array CLAIM_SOURCES = [
ConversionSource::MailPublish,
ConversionSource::MailReject,
ConversionSource::MailReview,
ConversionSource::ProfileClaim,
];
public function __construct(
private readonly UmamiClient $umami,
private readonly FeedFetchReport $feeds,
) {}
/**
* @return array{
* period: array{start: Carbon, review: Carbon, end: Carbon, elapsed_share: float},
* blocks: list<array{key: string, title: string, consequence: string, criteria: list<array<string, mixed>>}>,
* umami: array{configured: bool, error: string|null},
* pages: list<array{brand: string, page: string, views: int, search_views: int}>,
* }
*/
public function build(bool $fresh = false, ?Carbon $now = null): array
{
$now ??= Carbon::now();
$start = Carbon::parse((string) config('growth.period.start'))->startOfDay();
$review = Carbon::parse((string) config('growth.period.review'))->startOfDay();
$end = Carbon::parse((string) config('growth.period.end'))->endOfDay();
$elapsedShare = $now->lessThanOrEqualTo($start) ? 0.0 : min(1.0, $start->diffInSeconds($now) / max(1, $start->diffInSeconds($end)));
$ended = $now->greaterThan($end);
$umami = $this->searchViews($fresh, $now);
return [
'period' => ['start' => $start, 'review' => $review, 'end' => $end, 'elapsed_share' => $elapsedShare],
'blocks' => [
[
'key' => 'block1',
'title' => 'Block 1 — Konversion aus dem Bestand',
'consequence' => 'Wenn verfehlt: Block 1 einfrieren, Gewicht auf Block 3.',
'criteria' => [
$this->criterion('claims', 'Firmen mit eingelöstem Verwaltungslink', $this->claimedCompanies($start), (int) config('growth.block1.claims_min'), $elapsedShare, $ended, cumulative: true,
note: $this->claimsWithoutCompanyNote($start)),
$this->criterion('bookings', 'Erstbuchungen über Mail oder Claim', $this->companiesWith(ConversionEventType::FirstBooking, $start), (int) config('growth.block1.bookings_min'), $elapsedShare, $ended, cumulative: true),
$this->criterion('reject_clicks', 'Klicks aus der Ablehnungsmail', $this->rejectMailClicks($start), (int) config('growth.block1.reject_clicks_min'), $elapsedShare, $ended, cumulative: true),
],
],
[
'key' => 'block2',
'title' => 'Block 2 — Organische Sichtbarkeit',
'consequence' => 'Wenn verfehlt: Marketing-Teil nicht weiter ausbauen, nur 2.1 bis 2.3 behalten.',
'criteria' => [
$this->criterion('search_views', 'Aufrufe der Transparenzseiten aus der Suche (30 Tage)', $umami['total'], (int) config('growth.block2.search_views_min'), $elapsedShare, $ended, cumulative: false,
note: $umami['error'] ?? 'Gewertet wird der Monat '.Carbon::parse((string) config('growth.block2.evaluation_month').'-01')->locale('de')->isoFormat('MMMM YYYY').'.'),
],
],
[
'key' => 'block3',
'title' => 'Block 3 — Reichweite',
'consequence' => 'Wenn verfehlt: RSS ist kein Verkaufsargument; der Digest muss sich eigenständig begründen.',
'criteria' => [
$this->criterion('feed_subscribers', 'Gemeldete Feed-Abonnenten (30 Tage)', $this->feedSubscribers(), (int) config('growth.block3.feed_subscribers_min'), $elapsedShare, $ended, cumulative: false,
note: 'Nur Reader-Dienste, die Abonnenten im User-Agent melden (Feedly, Inoreader …).'),
],
],
],
'umami' => ['configured' => $this->umami->isConfigured(), 'error' => $umami['error']],
'pages' => $umami['pages'],
];
}
public static function statusLabel(string $status): string
{
return match ($status) {
'reached' => 'erreicht',
'on_track' => 'auf Kurs',
'behind' => 'hinter Plan',
'open' => 'noch nicht erreicht',
'missed' => 'verfehlt',
default => 'keine Daten',
};
}
/**
* Flux-Badge-Farbe je Status.
*/
public static function statusColor(string $status): string
{
return match ($status) {
'reached', 'on_track' => 'green',
'behind', 'open' => 'amber',
'missed' => 'red',
default => 'zinc',
};
}
/**
* @return array<string, mixed>
*/
private function criterion(string $key, string $label, ?int $actual, int $threshold, float $elapsedShare, bool $ended, bool $cumulative, ?string $note = null): array
{
$expected = $cumulative ? (int) ceil($threshold * $elapsedShare) : null;
$status = match (true) {
$actual === null => 'unknown',
$actual >= $threshold => 'reached',
$ended => 'missed',
$cumulative && $actual >= $expected => 'on_track',
$cumulative => 'behind',
default => 'open',
};
return [
'key' => $key,
'label' => $label,
'actual' => $actual,
'threshold' => $threshold,
'expected' => $expected,
'status' => $status,
'note' => $note,
];
}
private function claimedCompanies(Carbon $start): int
{
return $this->companiesWith(ConversionEventType::MagicLinkConsumed, $start);
}
private function companiesWith(ConversionEventType $type, Carbon $start): int
{
return ConversionEvent::query()
->where('type', $type->value)
->whereIn('source', array_map(static fn (ConversionSource $source): string => $source->value, self::CLAIM_SOURCES))
->whereNotNull('company_id')
->where('created_at', '>=', $start)
->distinct()
->count('company_id');
}
/**
* Einlösungen vor v1.10 tragen keine Firma (Roadmap 6.16) und fehlen
* deshalb in der Zählung — der Hinweis macht die Lücke sichtbar.
*/
private function claimsWithoutCompanyNote(Carbon $start): ?string
{
$count = ConversionEvent::query()
->where('type', ConversionEventType::MagicLinkConsumed->value)
->whereIn('source', array_map(static fn (ConversionSource $source): string => $source->value, self::CLAIM_SOURCES))
->whereNull('company_id')
->where('created_at', '>=', $start)
->count();
return $count > 0
? sprintf('Dazu %d %s ohne Firmenbezug (vor der Korrektur am 13.10.2026 erfasst).', $count, $count === 1 ? 'Einlösung' : 'Einlösungen')
: null;
}
private function rejectMailClicks(Carbon $start): int
{
return ConversionEvent::query()
->where('type', ConversionEventType::MailLinkClicked->value)
->where('source', ConversionSource::MailReject->value)
->where('created_at', '>=', $start)
->count();
}
private function feedSubscribers(): int
{
return (int) collect($this->feeds->byReader(30))->sum(static fn (array $reader): int => (int) $reader['subscribers']);
}
/**
* Aufrufe der Transparenzseiten der letzten 30 Tage, gesamt und aus der
* Suche, je Marke und Seite. Deutsch und Englisch zusammen
* (Filter „enthält“ auf den Slug).
*
* @return array{total: int|null, error: string|null, pages: list<array{brand: string, page: string, views: int, search_views: int}>}
*/
private function searchViews(bool $fresh, Carbon $now): array
{
if (! $this->umami->isConfigured()) {
return ['total' => null, 'error' => 'Umami-API nicht verbunden (UMAMI_API_* in der .env).', 'pages' => []];
}
$key = 'growth.block2.'.$now->toDateString();
if ($fresh) {
Cache::forget($key);
}
try {
/** @var list<array{brand: string, page: string, views: int, search_views: int}> $pages */
$pages = Cache::remember($key, now()->addMinutes((int) config('growth.cache_minutes', 360)), function () use ($now): array {
$from = $now->copy()->subDays(30);
$engines = (array) config('growth.block2.search_referrers', []);
$rows = [];
foreach ((array) config('growth.block2.brands', []) as $brand) {
$websiteId = $this->umami->websiteId((string) $brand);
if ($websiteId === null) {
continue;
}
foreach ((array) config('growth.block2.pages', []) as $page) {
$filter = ['path' => 'c.'.$page];
$views = $this->umami->stats($websiteId, $from, $now, $filter)['pageviews'];
$search = 0;
if ($views > 0) {
foreach ($this->umami->metrics($websiteId, 'referrer', $from, $now, $filter, 100) as $referrer) {
if (self::isSearchReferrer($referrer['x'], $engines)) {
$search += $referrer['y'];
}
}
}
$rows[] = ['brand' => (string) $brand, 'page' => (string) $page, 'views' => $views, 'search_views' => $search];
}
}
return $rows;
});
} catch (UmamiUnavailable $e) {
return ['total' => null, 'error' => $e->getMessage(), 'pages' => []];
}
return ['total' => (int) array_sum(array_column($pages, 'search_views')), 'error' => null, 'pages' => $pages];
}
/**
* @param array<int, mixed> $engines
*/
private static function isSearchReferrer(string $referrer, array $engines): bool
{
$referrer = mb_strtolower($referrer);
foreach ($engines as $engine) {
if ($engine !== '' && str_contains($referrer, mb_strtolower((string) $engine))) {
return true;
}
}
return false;
}
}