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>
271 lines
11 KiB
PHP
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;
|
|
}
|
|
}
|