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>
This commit is contained in:
parent
e8cf684f02
commit
b4870f98bb
12 changed files with 1098 additions and 0 deletions
|
|
@ -278,6 +278,14 @@ UMAMI_ENABLED=false
|
|||
# erst nach Ergänzung der Datenschutzerklärung einschalten.
|
||||
UMAMI_PORTAL_ENABLED=false
|
||||
UMAMI_IDENTIFY_USERS=false
|
||||
# Nur lesender API-Zugang für Admin ▸ Reports ▸ Wachstum und `umami:report`
|
||||
# (Umami-Benutzer mit der Rolle „View only“, Mitglied im Team mit den drei
|
||||
# Websites). Ohne diese Werte zeigt die Seite die Umami-Kennzahlen als
|
||||
# „nicht verbunden“.
|
||||
UMAMI_API_URL=
|
||||
UMAMI_API_USERNAME=
|
||||
UMAMI_API_PASSWORD=
|
||||
UMAMI_API_TEAM_ID=
|
||||
|
||||
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
|
||||
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
|
||||
|
|
|
|||
74
app/Console/Commands/ReportGrowth.php
Normal file
74
app/Console/Commands/ReportGrowth.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Growth\GrowthReport;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Stand der Abbruchbedingungen für Block 1 bis 3 (Roadmap 6.1) — dieselben
|
||||
* Zahlen wie Admin ▸ Reports ▸ Wachstum.
|
||||
*/
|
||||
class ReportGrowth extends Command
|
||||
{
|
||||
protected $signature = 'growth:report
|
||||
{--fresh : Umami-Werte neu abfragen statt aus dem Zwischenspeicher}
|
||||
{--json : Ausgabe als JSON statt als Tabelle}';
|
||||
|
||||
protected $description = 'Abbruchbedingungen der Wachstumsblöcke: Ist, Schwelle, Status.';
|
||||
|
||||
public function handle(GrowthReport $report): int
|
||||
{
|
||||
$data = $report->build((bool) $this->option('fresh'));
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$period = $data['period'];
|
||||
$this->info(sprintf(
|
||||
'Wachstum — Zeitraum %s bis %s, Zwischenblick %s, %d %% der Zeit vergangen',
|
||||
$period['start']->format('d.m.Y'),
|
||||
$period['end']->format('d.m.Y'),
|
||||
$period['review']->format('d.m.Y'),
|
||||
(int) round($period['elapsed_share'] * 100),
|
||||
));
|
||||
|
||||
foreach ($data['blocks'] as $block) {
|
||||
$this->newLine();
|
||||
$this->line("<comment>{$block['title']}</comment>");
|
||||
|
||||
$this->table(
|
||||
['Bedingung', 'Ist', 'Soll heute', 'Schwelle', 'Status'],
|
||||
array_map(static fn (array $criterion): array => [
|
||||
$criterion['label'],
|
||||
$criterion['actual'] ?? '–',
|
||||
$criterion['expected'] ?? '–',
|
||||
$criterion['threshold'],
|
||||
GrowthReport::statusLabel($criterion['status']),
|
||||
], $block['criteria']),
|
||||
);
|
||||
|
||||
foreach ($block['criteria'] as $criterion) {
|
||||
if ($criterion['note'] !== null) {
|
||||
$this->line(' '.$criterion['note']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' '.$block['consequence']);
|
||||
}
|
||||
|
||||
if ($data['pages'] !== []) {
|
||||
$this->newLine();
|
||||
$this->line('<comment>Transparenzseiten, letzte 30 Tage (Umami)</comment>');
|
||||
$this->table(
|
||||
['Portal', 'Seite', 'Aufrufe', 'aus der Suche'],
|
||||
array_map(static fn (array $row): array => [$row['brand'], $row['page'], $row['views'], $row['search_views']], $data['pages']),
|
||||
);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
103
app/Console/Commands/ReportUmami.php
Normal file
103
app/Console/Commands/ReportUmami.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Analytics\UmamiClient;
|
||||
use App\Services\Analytics\UmamiUnavailable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Nur lesende Abfrage der Umami-Webanalyse (Roadmap 6.16): Kennzahlen je
|
||||
* Website, dazu eine Rangliste (Seiten, Herkunft, Kanäle …), optional auf
|
||||
* Pfade gefiltert. Zugangsdaten aus `UMAMI_API_*`, werden nie ausgegeben.
|
||||
*/
|
||||
class ReportUmami extends Command
|
||||
{
|
||||
protected $signature = 'umami:report
|
||||
{--from= : Startdatum (Standard: vor 30 Tagen)}
|
||||
{--to= : Enddatum (Standard: jetzt)}
|
||||
{--site=* : pressekonto, presseecho, businessportal24 (Standard: alle)}
|
||||
{--path= : nur Pfade, die diesen Text enthalten, z. B. gute-pressemitteilung}
|
||||
{--metric=channel : Rangliste nach path, referrer, channel, country, device, browser, event}
|
||||
{--limit=10 : Zeilen der Rangliste}
|
||||
{--json : Ausgabe als JSON statt als Tabelle}';
|
||||
|
||||
protected $description = 'Umami-Kennzahlen je Website (nur lesend).';
|
||||
|
||||
private const array METRICS = ['path', 'referrer', 'channel', 'country', 'device', 'browser', 'os', 'event', 'title', 'entry', 'exit'];
|
||||
|
||||
public function handle(UmamiClient $umami): int
|
||||
{
|
||||
if (! $umami->isConfigured()) {
|
||||
$this->error('Umami-API nicht konfiguriert: UMAMI_API_URL, UMAMI_API_USERNAME und UMAMI_API_PASSWORD setzen.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$metric = (string) $this->option('metric');
|
||||
|
||||
if (! in_array($metric, self::METRICS, true)) {
|
||||
$this->error('Unbekannte Rangliste. Möglich: '.implode(', ', self::METRICS).'.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to'))->endOfDay() : Carbon::now();
|
||||
$from = $this->option('from') !== null ? Carbon::parse((string) $this->option('from'))->startOfDay() : $to->copy()->subDays(30);
|
||||
$sites = (array) $this->option('site') ?: UmamiClient::BRANDS;
|
||||
$filters = filled($this->option('path')) ? ['path' => 'c.'.$this->option('path')] : [];
|
||||
$limit = max(1, min(500, (int) $this->option('limit')));
|
||||
|
||||
$result = [];
|
||||
|
||||
try {
|
||||
foreach ($sites as $site) {
|
||||
$websiteId = $umami->websiteId((string) $site);
|
||||
|
||||
if ($websiteId === null) {
|
||||
$this->warn("Keine Umami-Website für „{$site}“ in config/domains.php.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$site] = [
|
||||
'stats' => $umami->stats($websiteId, $from, $to, $filters),
|
||||
$metric => $umami->metrics($websiteId, $metric, $from, $to, $filters, $limit),
|
||||
];
|
||||
}
|
||||
} catch (UmamiUnavailable $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode([
|
||||
'from' => $from->toIso8601String(),
|
||||
'to' => $to->toIso8601String(),
|
||||
'filters' => $filters,
|
||||
'sites' => $result,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Umami — %s bis %s%s', $from->format('d.m.Y'), $to->format('d.m.Y'), $filters === [] ? '' : ', Pfad enthält „'.$this->option('path').'“'));
|
||||
|
||||
foreach ($result as $site => $data) {
|
||||
$this->newLine();
|
||||
$this->line("<comment>{$site}</comment>");
|
||||
$this->table(
|
||||
['Aufrufe', 'Besucher', 'Besuche', 'Absprünge'],
|
||||
[[$data['stats']['pageviews'], $data['stats']['visitors'], $data['stats']['visits'], $data['stats']['bounces']]],
|
||||
);
|
||||
$this->table(
|
||||
[ucfirst($metric), 'Anzahl'],
|
||||
array_map(static fn (array $row): array => [$row['x'], $row['y']], $data[$metric]),
|
||||
);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
166
app/Services/Analytics/UmamiClient.php
Normal file
166
app/Services/Analytics/UmamiClient.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Analytics;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Nur lesender Client für die selbst gehostete Umami-Instanz (Roadmap 6.16).
|
||||
*
|
||||
* Anmeldung mit einem Umami-Benutzer der Rolle „View only“; das Token wird
|
||||
* zwischengespeichert und bei einer 401 einmal erneuert. Die Websites hängen
|
||||
* am Team, nicht am Benutzer — ihre IDs stehen je Marke in
|
||||
* `config/domains.php`.
|
||||
*
|
||||
* Eigenheiten der Umami-v3-API: Seiten heißen `path` (nicht `url`), ein
|
||||
* Filter `path=c.<Teil>` sucht nach „enthält“, Zeiten in Millisekunden.
|
||||
*/
|
||||
class UmamiClient
|
||||
{
|
||||
private const string TOKEN_CACHE_KEY = 'umami.api-token';
|
||||
|
||||
/** Marken mit eigener Umami-Website. */
|
||||
public const array BRANDS = ['pressekonto', 'presseecho', 'businessportal24'];
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled(config('services.umami.api_url'))
|
||||
&& filled(config('services.umami.api_username'))
|
||||
&& filled(config('services.umami.api_password'));
|
||||
}
|
||||
|
||||
public function websiteId(string $brand): ?string
|
||||
{
|
||||
$id = config('domains.domains.'.$brand.'.brand.umami_website_id');
|
||||
|
||||
return is_string($id) && $id !== '' ? $id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennzahlen eines Zeitraums, optional gefiltert (z. B. `['path' => 'c.preise']`).
|
||||
*
|
||||
* @param array<string, string> $filters
|
||||
* @return array{pageviews: int, visitors: int, visits: int, bounces: int, totaltime: int}
|
||||
*/
|
||||
public function stats(string $websiteId, CarbonInterface $from, CarbonInterface $to, array $filters = []): array
|
||||
{
|
||||
$data = $this->get('/api/websites/'.$websiteId.'/stats', $this->range($from, $to) + $filters);
|
||||
|
||||
return [
|
||||
'pageviews' => (int) ($data['pageviews'] ?? 0),
|
||||
'visitors' => (int) ($data['visitors'] ?? 0),
|
||||
'visits' => (int) ($data['visits'] ?? 0),
|
||||
'bounces' => (int) ($data['bounces'] ?? 0),
|
||||
'totaltime' => (int) ($data['totaltime'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rangliste einer Dimension: `path`, `referrer`, `channel`, `country`,
|
||||
* `browser`, `os`, `device`, `event` …
|
||||
*
|
||||
* @param array<string, string> $filters
|
||||
* @return list<array{x: string, y: int}>
|
||||
*/
|
||||
public function metrics(string $websiteId, string $type, CarbonInterface $from, CarbonInterface $to, array $filters = [], int $limit = 10): array
|
||||
{
|
||||
$data = $this->get('/api/websites/'.$websiteId.'/metrics', $this->range($from, $to) + $filters + [
|
||||
'type' => $type,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (array $row): array => ['x' => (string) ($row['x'] ?? ''), 'y' => (int) ($row['y'] ?? 0)],
|
||||
array_filter($data, 'is_array'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, scalar> $query
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function get(string $path, array $query): array
|
||||
{
|
||||
$response = $this->send($path, $query, $this->token());
|
||||
|
||||
if ($response->status() === 401) {
|
||||
Cache::forget(self::TOKEN_CACHE_KEY);
|
||||
$response = $this->send($path, $query, $this->token());
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new UmamiUnavailable(sprintf('Umami antwortet mit %d auf %s.', $response->status(), $path));
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, scalar> $query
|
||||
*/
|
||||
private function send(string $path, array $query, string $token): Response
|
||||
{
|
||||
try {
|
||||
return Http::baseUrl($this->baseUrl())
|
||||
->withToken($token)
|
||||
->acceptJson()
|
||||
->connectTimeout(5)
|
||||
->timeout((int) config('services.umami.api_timeout_seconds', 15))
|
||||
->get($path, $query);
|
||||
} catch (ConnectionException $e) {
|
||||
throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new UmamiUnavailable('Umami-API nicht konfiguriert (UMAMI_API_URL, UMAMI_API_USERNAME, UMAMI_API_PASSWORD).');
|
||||
}
|
||||
|
||||
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addHours(12), function (): string {
|
||||
try {
|
||||
$response = Http::baseUrl($this->baseUrl())
|
||||
->acceptJson()
|
||||
->connectTimeout(5)
|
||||
->timeout((int) config('services.umami.api_timeout_seconds', 15))
|
||||
->post('/api/auth/login', [
|
||||
'username' => (string) config('services.umami.api_username'),
|
||||
'password' => (string) config('services.umami.api_password'),
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e);
|
||||
}
|
||||
|
||||
$token = $response->json('token');
|
||||
|
||||
if (! $response->successful() || ! is_string($token) || $token === '') {
|
||||
throw new UmamiUnavailable(sprintf('Umami-Anmeldung abgelehnt (%d).', $response->status()));
|
||||
}
|
||||
|
||||
return $token;
|
||||
});
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('services.umami.api_url'), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{startAt: int, endAt: int}
|
||||
*/
|
||||
private function range(CarbonInterface $from, CarbonInterface $to): array
|
||||
{
|
||||
return [
|
||||
'startAt' => $from->getTimestampMs(),
|
||||
'endAt' => $to->getTimestampMs(),
|
||||
];
|
||||
}
|
||||
}
|
||||
11
app/Services/Analytics/UmamiUnavailable.php
Normal file
11
app/Services/Analytics/UmamiUnavailable.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Analytics;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Umami war nicht erreichbar oder hat die Anfrage abgelehnt. Auswertungen
|
||||
* fangen das ab und zeigen „nicht verbunden“ statt abzubrechen.
|
||||
*/
|
||||
class UmamiUnavailable extends RuntimeException {}
|
||||
271
app/Services/Growth/GrowthReport.php
Normal file
271
app/Services/Growth/GrowthReport.php
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
59
config/growth.php
Normal file
59
config/growth.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Abbruchbedingungen der Wachstumsblöcke (Roadmap 6.1, 13.10.2026)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Grundlage für Admin ▸ Reports ▸ Wachstum und `growth:report`. Ein
|
||||
| gemeinsamer Zeitraum für alle Blöcke, ein Zwischenblick, danach Audit im
|
||||
| Quartal. Die Schwellen sind Entscheidungen des Betreibers — geändert
|
||||
| werden sie hier und in der Roadmap, nicht nur an einer Stelle.
|
||||
|
|
||||
*/
|
||||
|
||||
'period' => [
|
||||
'start' => '2026-09-17',
|
||||
'review' => '2026-12-17',
|
||||
'end' => '2027-03-17',
|
||||
],
|
||||
|
||||
// Block 1 — Konversion aus dem Bestand. Gezählt werden eindeutige Firmen.
|
||||
'block1' => [
|
||||
'claims_min' => 30,
|
||||
'bookings_min' => 3,
|
||||
'reject_clicks_min' => 10,
|
||||
],
|
||||
|
||||
// Block 2 — Organische Sichtbarkeit: Aufrufe der Transparenzseiten (2.4)
|
||||
// aus der Suche, gemessen in Umami über die letzten 30 Tage. Gewertet
|
||||
// wird der Februar 2027.
|
||||
'block2' => [
|
||||
'search_views_min' => 100,
|
||||
'evaluation_month' => '2027-02',
|
||||
'pages' => [
|
||||
'was-kostet-eine-pressemitteilung',
|
||||
'pressemitteilung-veroeffentlichen-ohne-abo',
|
||||
'pressemitteilung-loeschen-oder-aendern-lassen',
|
||||
'pressemitteilung-kostenlos-veroeffentlichen',
|
||||
],
|
||||
'brands' => ['presseecho', 'businessportal24'],
|
||||
// Verweisende Domains, die als Suche zählen (enthält).
|
||||
'search_referrers' => [
|
||||
'google.', 'googlequicksearchbox', 'bing.', 'duckduckgo.', 'ecosia.', 'yahoo.',
|
||||
'search.brave.', 'qwant.', 'startpage.', 'yandex.', 'web.de', 'gmx.', 't-online.',
|
||||
],
|
||||
],
|
||||
|
||||
// Block 3 — Reichweite: Abonnenten, die Reader-Dienste im User-Agent
|
||||
// melden (Feedly, Inoreader …), über alle Feeds, letzte 30 Tage.
|
||||
'block3' => [
|
||||
'feed_subscribers_min' => 25,
|
||||
],
|
||||
|
||||
// Umami-Abfragen werden so lange zwischengespeichert.
|
||||
'cache_minutes' => (int) env('GROWTH_REPORT_CACHE_MINUTES', 360),
|
||||
|
||||
];
|
||||
|
|
@ -63,6 +63,16 @@ return [
|
|||
// Ergänzung der Datenschutzerklärung einschalten.
|
||||
'portal_enabled' => env('UMAMI_PORTAL_ENABLED', false),
|
||||
'identify_users' => env('UMAMI_IDENTIFY_USERS', false),
|
||||
|
||||
// Nur lesender API-Zugang für Auswertungen (Roadmap 6.16): ein
|
||||
// Umami-Benutzer mit der Rolle „View only“, die Websites hängen am
|
||||
// Team. Selbst gehostetes Umami kennt keine API-Schlüssel, nur
|
||||
// Login und Token.
|
||||
'api_url' => env('UMAMI_API_URL'),
|
||||
'api_username' => env('UMAMI_API_USERNAME'),
|
||||
'api_password' => env('UMAMI_API_PASSWORD'),
|
||||
'api_team_id' => env('UMAMI_API_TEAM_ID'),
|
||||
'api_timeout_seconds' => (int) env('UMAMI_API_TIMEOUT', 15),
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -230,6 +230,10 @@
|
|||
{{-- Reports --}}
|
||||
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
|
||||
|
||||
<flux:navlist.item icon="arrow-trending-up" :href="route('admin.reports.growth')"
|
||||
:current="request()->routeIs('admin.reports.growth')" wire:navigate>
|
||||
{{ __('Wachstum') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')"
|
||||
:current="request()->routeIs('admin.reports.slow-requests')" wire:navigate>
|
||||
{{ __('Performance') }}
|
||||
|
|
|
|||
163
resources/views/livewire/admin/reports/growth.blade.php
Normal file
163
resources/views/livewire/admin/reports/growth.blade.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Conversion\ConversionFunnelReport;
|
||||
use App\Services\Growth\GrowthReport;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Admin ▸ Reports ▸ Wachstum (Roadmap 6.1, 6.16).
|
||||
*
|
||||
* Die Abbruchbedingungen für Block 1 bis 3 mit Ist, Soll und Status, dazu
|
||||
* der Trichter seit Beginn des Zeitraums und die Transparenzseiten aus
|
||||
* Umami. Eigene Ereignisdaten sind immer aktuell; Umami wird
|
||||
* zwischengespeichert und lässt sich hier neu abfragen.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Wachstum')] class extends Component
|
||||
{
|
||||
public bool $fresh = false;
|
||||
|
||||
public function refreshUmami(): void
|
||||
{
|
||||
$this->fresh = true;
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$report = app(GrowthReport::class)->build($this->fresh);
|
||||
$this->fresh = false;
|
||||
|
||||
return [
|
||||
'report' => $report,
|
||||
'funnel' => app(ConversionFunnelReport::class)->build($report['period']['start']),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Reports')"
|
||||
:title="__('Wachstum')"
|
||||
:description="__('Abbruchbedingungen der Wachstumsblöcke (Roadmap 6.1): Ist-Wert, zeitanteiliges Soll und Schwelle. Block 1 sammelt über den Zeitraum, Block 2 und 3 sind Momentaufnahmen der letzten 30 Tage. Darunter der Trichter seit Beginn des Zeitraums und die Transparenzseiten aus Umami.')"
|
||||
/>
|
||||
|
||||
@php
|
||||
$period = $report['period'];
|
||||
@endphp
|
||||
|
||||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<x-portal.stat-card variant="primary" :label="__('Zeitraum')" :value="$period['start']->format('d.m.y').' – '.$period['end']->format('d.m.y')">
|
||||
<x-slot:meta>{{ __(':percent % der Zeit vergangen', ['percent' => (int) round($period['elapsed_share'] * 100)]) }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Zwischenblick')" :value="$period['review']->format('d.m.Y')">
|
||||
<x-slot:meta>{{ __('danach Audit im Quartal') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card :variant="$report['umami']['error'] === null ? 'ok' : 'warn'" :label="__('Umami')" :value="$report['umami']['error'] === null ? __('verbunden') : __('nicht verbunden')">
|
||||
<x-slot:meta>{{ $report['umami']['error'] ?? __('zwischengespeichert, siehe unten') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Quelle')" value="growth:report">
|
||||
<x-slot:meta>{{ __('dieselben Zahlen auf der Konsole') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
</section>
|
||||
|
||||
@foreach ($report['blocks'] as $block)
|
||||
<article class="panel overflow-hidden" wire:key="block-{{ $block['key'] }}">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ $block['title'] }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $block['consequence'] }}</span>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Bedingung') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Ist') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Soll heute') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Schwelle') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@foreach ($block['criteria'] as $criterion)
|
||||
<flux:table.row :key="$block['key'].'-'.$criterion['key']">
|
||||
<flux:table.cell>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $criterion['label'] }}</span>
|
||||
@if ($criterion['note'])
|
||||
<span class="block max-w-xl whitespace-normal text-[11px] text-[color:var(--color-ink-4)]">{{ $criterion['note'] }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell><span class="text-[13px] font-semibold tabular-nums text-[color:var(--color-ink)]">{{ $criterion['actual'] ?? '—' }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-3)]">{{ $criterion['expected'] ?? '—' }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $criterion['threshold'] }}</span></flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" :color="GrowthReport::statusColor($criterion['status'])">{{ GrowthReport::statusLabel($criterion['status']) }}</flux:badge>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</article>
|
||||
@endforeach
|
||||
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Trichter seit :date', ['date' => $period['start']->format('d.m.Y')]) }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('eindeutige Firmen je Stufe; „ohne Firma“ steht daneben, nicht im Nenner') }}</span>
|
||||
</div>
|
||||
@forelse ($funnel['sources'] as $source)
|
||||
<div class="px-5 pt-4 text-[12px] font-semibold text-[color:var(--color-ink-2)]">{{ $source['label'] }}</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>#</flux:table.column>
|
||||
<flux:table.column>{{ __('Stufe') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Firmen') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Übergang') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('ohne Firma') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Ein Abbruch hier bedeutet') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@foreach ($source['steps'] as $step)
|
||||
<flux:table.row :key="$source['source'].'-'.$step['type']">
|
||||
<flux:table.cell><span class="text-[12px] tabular-nums text-[color:var(--color-ink-4)]">{{ $step['step'] }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] text-[color:var(--color-ink)]">{{ $step['label'] }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] font-semibold tabular-nums text-[color:var(--color-ink)]">{{ $step['firmen'] }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $step['uebergang'] === null ? '–' : number_format($step['uebergang'], 1, ',', '.').' %' }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-3)]">{{ $step['ohne_firma'] ?: '' }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $step['diagnosis'] }}</span></flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@empty
|
||||
<div class="px-5 py-4 text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Keine Ereignisse im Zeitraum.') }}</div>
|
||||
@endforelse
|
||||
</article>
|
||||
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Transparenzseiten · letzte 30 Tage') }}</span>
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-path" wire:click="refreshUmami">{{ __('Umami neu abfragen') }}</flux:button>
|
||||
</div>
|
||||
@if ($report['pages'] === [])
|
||||
<div class="px-5 py-4 text-[12.5px] text-[color:var(--color-ink-3)]">{{ $report['umami']['error'] ?? __('Keine Daten.') }}</div>
|
||||
@else
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Seite') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aufrufe') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('aus der Suche') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@foreach ($report['pages'] as $row)
|
||||
<flux:table.row :key="'page-'.$row['brand'].'-'.$row['page']">
|
||||
<flux:table.cell><span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $row['brand'] }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="font-mono text-[12px] text-[color:var(--color-ink)]">{{ $row['page'] }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $row['views'] }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] font-semibold tabular-nums text-[color:var(--color-ink)]">{{ $row['search_views'] }}</span></flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -161,6 +161,10 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
// nach Direkteinreichung/Syndikation — Grundlage für die Kundenansicht.
|
||||
Route::livewire('admin/reports/indexierung', 'admin.reports.index-status')->name('admin.reports.index-status');
|
||||
|
||||
// Wachstum (Roadmap 6.1, 6.16): Abbruchbedingungen der Blöcke 1 bis 3,
|
||||
// Trichter seit Beginn des Zeitraums, Transparenzseiten aus Umami.
|
||||
Route::livewire('admin/reports/wachstum', 'admin.reports.growth')->name('admin.reports.growth');
|
||||
|
||||
Route::middleware('can:settings:manage')->group(function (): void {
|
||||
Route::livewire('admin/reports/system-health', 'admin.reports.system-health')->name('admin.reports.system-health');
|
||||
});
|
||||
|
|
|
|||
225
tests/Feature/Admin/GrowthReportTest.php
Normal file
225
tests/Feature/Admin/GrowthReportTest.php
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ConversionEventType;
|
||||
use App\Enums\ConversionSource;
|
||||
use App\Models\Company;
|
||||
use App\Models\ConversionEvent;
|
||||
use App\Models\FeedFetch;
|
||||
use App\Models\User;
|
||||
use App\Services\Analytics\UmamiClient;
|
||||
use App\Services\Analytics\UmamiUnavailable;
|
||||
use App\Services\Growth\GrowthReport;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Admin ▸ Reports ▸ Wachstum (Roadmap 6.1, 6.16): Abbruchbedingungen der
|
||||
* Blöcke 1 bis 3 aus eigenen Ereignisdaten und Umami.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
Cache::flush();
|
||||
config()->set('services.umami.api_url', null);
|
||||
});
|
||||
|
||||
function connectUmami(): void
|
||||
{
|
||||
config()->set('services.umami.api_url', 'https://umami.test');
|
||||
config()->set('services.umami.api_username', 'reports');
|
||||
config()->set('services.umami.api_password', 'geheim');
|
||||
}
|
||||
|
||||
function growthEvent(ConversionEventType $type, ConversionSource $source, ?Company $company, string $at = '2026-10-01'): void
|
||||
{
|
||||
ConversionEvent::query()->create(['type' => $type, 'source' => $source, 'company_id' => $company?->id])
|
||||
->forceFill(['created_at' => Carbon::parse($at)])->save();
|
||||
}
|
||||
|
||||
function growthCriterion(array $report, string $key): array
|
||||
{
|
||||
foreach ($report['blocks'] as $block) {
|
||||
foreach ($block['criteria'] as $criterion) {
|
||||
if ($criterion['key'] === $key) {
|
||||
return $criterion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Bedingung fehlt: '.$key);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- Umami-Client
|
||||
|
||||
test('the umami client logs in once, keeps the token and sends the range in milliseconds', function () {
|
||||
/** @var TestCase $this */
|
||||
connectUmami();
|
||||
Http::fake([
|
||||
'umami.test/api/auth/login' => Http::response(['token' => 'tok-1']),
|
||||
'umami.test/api/websites/*/stats*' => Http::response(['pageviews' => 12, 'visitors' => 5, 'visits' => 7, 'bounces' => 2, 'totaltime' => 90]),
|
||||
]);
|
||||
|
||||
$client = app(UmamiClient::class);
|
||||
$from = Carbon::parse('2026-09-17 00:00:00', 'UTC');
|
||||
$to = Carbon::parse('2026-10-13 00:00:00', 'UTC');
|
||||
|
||||
$client->stats('site-1', $from, $to, ['path' => 'c.preise']);
|
||||
$stats = $client->stats('site-1', $from, $to);
|
||||
|
||||
expect($stats['pageviews'])->toBe(12);
|
||||
Http::assertSentCount(3);
|
||||
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/api/websites/site-1/stats')
|
||||
&& (string) ($request->data()['startAt'] ?? '') === (string) $from->getTimestampMs()
|
||||
&& ($request->data()['path'] ?? null) === 'c.preise'
|
||||
&& $request->hasHeader('Authorization', 'Bearer tok-1'));
|
||||
});
|
||||
|
||||
test('an expired token is renewed once', function () {
|
||||
/** @var TestCase $this */
|
||||
connectUmami();
|
||||
Http::fake([
|
||||
'umami.test/api/auth/login' => Http::sequence()->push(['token' => 'alt'])->push(['token' => 'neu']),
|
||||
'umami.test/api/websites/*' => Http::sequence()->push([], 401)->push([['x' => 'google.com', 'y' => 4]]),
|
||||
]);
|
||||
|
||||
$rows = app(UmamiClient::class)->metrics('site-1', 'referrer', now()->subDay(), now());
|
||||
|
||||
expect($rows)->toBe([['x' => 'google.com', 'y' => 4]]);
|
||||
});
|
||||
|
||||
test('a rejected login is reported as unavailable, never as an empty result', function () {
|
||||
/** @var TestCase $this */
|
||||
connectUmami();
|
||||
Http::fake(['umami.test/api/auth/login' => Http::response(['error' => 'incorrect-username-password'], 401)]);
|
||||
|
||||
app(UmamiClient::class)->stats('site-1', now()->subDay(), now());
|
||||
})->throws(UmamiUnavailable::class, 'Umami-Anmeldung abgelehnt (401).');
|
||||
|
||||
// -------------------------------------------------------------- Bericht
|
||||
|
||||
test('block one counts companies from mail and claim, not organic ones and not before the start', function () {
|
||||
/** @var TestCase $this */
|
||||
[$a, $b, $c] = Company::factory()->count(3)->create();
|
||||
|
||||
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a);
|
||||
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a, '2026-10-02');
|
||||
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::ProfileClaim, $b);
|
||||
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::Organic, $c);
|
||||
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $c, '2026-09-01');
|
||||
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, null);
|
||||
growthEvent(ConversionEventType::FirstBooking, ConversionSource::MailPublish, $a);
|
||||
growthEvent(ConversionEventType::FirstBooking, ConversionSource::Organic, $b);
|
||||
growthEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailReject, $c);
|
||||
|
||||
$report = app(GrowthReport::class)->build(now: Carbon::parse('2026-10-13'));
|
||||
|
||||
$claims = growthCriterion($report, 'claims');
|
||||
expect($claims['actual'])->toBe(2)
|
||||
->and($claims['note'])->toContain('1 Einlösung ohne Firmenbezug')
|
||||
->and(growthCriterion($report, 'bookings')['actual'])->toBe(1)
|
||||
->and(growthCriterion($report, 'reject_clicks')['actual'])->toBe(1);
|
||||
});
|
||||
|
||||
test('cumulative conditions compare against the share of time that has passed', function () {
|
||||
/** @var TestCase $this */
|
||||
$companies = Company::factory()->count(16)->create();
|
||||
|
||||
foreach ($companies as $company) {
|
||||
growthEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $company);
|
||||
}
|
||||
|
||||
// Halbzeit (17.12.): Soll 15 von 30.
|
||||
$mid = app(GrowthReport::class)->build(now: Carbon::parse('2026-12-17'));
|
||||
expect(growthCriterion($mid, 'claims')['status'])->toBe('on_track')
|
||||
->and(growthCriterion($mid, 'bookings')['status'])->toBe('behind');
|
||||
|
||||
// Nach dem Ende ist „noch nicht“ ein „verfehlt“.
|
||||
$after = app(GrowthReport::class)->build(now: Carbon::parse('2027-03-20'));
|
||||
expect(growthCriterion($after, 'claims')['status'])->toBe('missed');
|
||||
});
|
||||
|
||||
test('block three sums the subscribers reader services report', function () {
|
||||
/** @var TestCase $this */
|
||||
FeedFetch::factory()->reader('feedly', 12)->create();
|
||||
FeedFetch::factory()->category('it')->reader('inoreader', 4)->create();
|
||||
FeedFetch::factory()->reader(null)->create();
|
||||
|
||||
expect(growthCriterion(app(GrowthReport::class)->build(), 'feed_subscribers')['actual'])->toBe(16);
|
||||
});
|
||||
|
||||
test('without umami the search condition says why instead of showing zero', function () {
|
||||
/** @var TestCase $this */
|
||||
$report = app(GrowthReport::class)->build();
|
||||
$search = growthCriterion($report, 'search_views');
|
||||
|
||||
expect($search['actual'])->toBeNull()
|
||||
->and($search['status'])->toBe('unknown')
|
||||
->and($report['umami']['error'])->toContain('nicht verbunden');
|
||||
});
|
||||
|
||||
test('block two counts only visits from search engines', function () {
|
||||
/** @var TestCase $this */
|
||||
connectUmami();
|
||||
config()->set('growth.block2.brands', ['businessportal24']);
|
||||
config()->set('growth.block2.pages', ['was-kostet-eine-pressemitteilung']);
|
||||
Http::fake([
|
||||
'umami.test/api/auth/login' => Http::response(['token' => 'tok']),
|
||||
'umami.test/api/websites/*/stats*' => Http::response(['pageviews' => 9]),
|
||||
'umami.test/api/websites/*/metrics*' => Http::response([
|
||||
['x' => 'google.com', 'y' => 3],
|
||||
['x' => 'duckduckgo.com', 'y' => 1],
|
||||
['x' => 'pressekonto.com', 'y' => 4],
|
||||
]),
|
||||
]);
|
||||
|
||||
$report = app(GrowthReport::class)->build();
|
||||
|
||||
expect(growthCriterion($report, 'search_views')['actual'])->toBe(4)
|
||||
->and($report['pages'][0])->toMatchArray(['views' => 9, 'search_views' => 4]);
|
||||
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'metrics') && ($request->data()['path'] ?? null) === 'c.was-kostet-eine-pressemitteilung');
|
||||
});
|
||||
|
||||
// --------------------------------------------------- Befehle und Seite
|
||||
|
||||
test('the growth command prints every block', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->artisan('growth:report')
|
||||
->expectsOutputToContain('Block 1 — Konversion aus dem Bestand')
|
||||
->expectsOutputToContain('Block 2 — Organische Sichtbarkeit')
|
||||
->expectsOutputToContain('Block 3 — Reichweite')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('the umami command refuses to run without access data', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->artisan('umami:report')
|
||||
->expectsOutputToContain('Umami-API nicht konfiguriert')
|
||||
->assertFailed();
|
||||
});
|
||||
|
||||
test('admins see the growth page', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.reports.growth'))
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Wachstum')
|
||||
->assertSeeText('Firmen mit eingelöstem Verwaltungslink')
|
||||
->assertSeeText('Gemeldete Feed-Abonnenten (30 Tage)')
|
||||
->assertSeeText('Umami-API nicht verbunden');
|
||||
});
|
||||
|
||||
test('customers do not see the growth page', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$this->actingAs($customer)->get(route('admin.reports.growth'))->assertForbidden();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue