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:
Kevin Adametz 2026-10-13 14:55:29 +00:00
parent e8cf684f02
commit b4870f98bb
12 changed files with 1098 additions and 0 deletions

View 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;
}
}

View 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;
}
}