Gruppen & Aggregate

Gruppen unterteilen die sortierten Datensätze in Abschnitte mit eigenem Kopf und Fuss. Aggregate verdichten die Werte einer Gruppe oder des ganzen Reports zu Summen, Durchschnitten und Zählern.

Gruppen definieren

Eine Gruppe ist ein oder mehrere Schlüsselfelder plus ein Callback für Kopf, Fuss und Aggregate. Die Reihenfolge der Definition bestimmt die Verschachtelungstiefe.

group(string|array $key, ?Closure $callback = null) Legt eine Gruppe an und gibt einen GroupBuilder zurück. Der Callback wird sofort ausgeführt.
string|array $key Ein Feldname oder eine Liste von Feldern. Mehrere Felder werden zu einem zusammengesetzten Gruppenschlüssel verbunden.
$group->group($key, $callback) Verschachtelt eine Untergruppe. Die Engine flacht die Gruppen in Präorder-Reihenfolge ab; die Position ergibt das Level.
Level 1 … n Die äusserste Gruppe ist Level 1. Jede Ebene kann eigenen Kopf, Fuss und eigene Aggregate haben.
$r->group('category', function ($category): void {
    $category->header(view: 'category-head');
    $category->footer(view: 'category-foot');
    $category->aggSum('price', as: 'category_total');

    // Second level: a group inside a group.
    $category->group('supplier', function ($supplier): void {
        $supplier->header(view: 'supplier-head');
        $supplier->aggSum('price', as: 'supplier_total');
    });
});

// Composite key: one group per category + currency combination.
$r->group(['category', 'currency'], function ($group): void {
    $group->header(view: 'group-head');
});
<?php

declare(strict_types=1);

namespace App\Reports\PriceList;

use App\Reports\Layout\CompanyLayout;
use Guggach\Reports\Definition\ReportBuilder;
use Guggach\Reports\Filters\FilterBuilder;
use Guggach\Reports\Report as BaseReport;
use Guggach\Reports\Sources\ReportSource;

final class Report extends BaseReport
{
    public function key(): string
    {
        return 'price_list';
    }

    public function name(): string
    {
        return 'Preisliste';
    }

    public function layout(): string
    {
        return CompanyLayout::class;
    }

    public function source(): ReportSource
    {
        return new Source;
    }

    /**
     * Demonstrate the fluent control description: operator listbox for the
     * number, multiselect for the enum and a searchable dual-list for the
     * relation (id/FK) field.
     */
    public function filterControls(): array
    {
        return FilterBuilder::for($this->source())
            ->field('price')->operators(['=', '>=', '<=', 'between', 'in'])
            ->field('category')->multiselect()
            ->field('supplier')->picker(valueField: 'id', labelField: 'label')
            ->build();
    }

    public function define(ReportBuilder $r): void
    {
        // Module-relative view names resolve to app/Reports/PriceList/views/*.
        // Report start/end run once per report; the grid header introduces the
        // table and the detail band renders one row per record.
        $r->reportStart(view: 'report-start');
        $r->gridHeader(view: 'grid-head');
        $r->detail(view: 'row');
        $r->reportEnd(view: 'report-end');

        // Grouped by category (data is sorted by category) with a subtotal.
        $r->group('category', function ($g): void {
            $g->header(view: 'category-head');
            $g->footer(view: 'category-foot');
            $g->aggSum('price', as: 'category_total');
        });
    }
}

Eine flache Gruppe aus der Preisliste und ein Beispiel mit zwei Ebenen und zusammengesetztem Schlüssel.

Gruppenkopf und Gruppenfuss

Ein Gruppenkopf läuft, wenn ein neuer Schlüssel beginnt; der Fuss läuft, wenn die Gruppe endet. Beide Bänder sind volle Breite und erhalten den RenderContext.

header($view = null, ?Closure $closure = null) Gruppenkopf. Läuft einmal pro Gruppe, bevor der erste Datensatz der Gruppe erscheint.
footer($view = null, ?Closure $closure = null) Gruppenfuss. Läuft einmal pro Gruppe, nachdem der letzte Datensatz verarbeitet wurde. Hier stehen die Gruppen-Aggregate bereit.
height() · visibility · pages … Kopf und Fuss sind normale Bänder und unterstützen dieselben Optionen wie Seite, Report und Detail.
<tr>
    <td colspan="3" style="padding-top:10px; font-weight:700; border-bottom:1px solid #0f766e; color:#0f766e">
        {{ $ctx->currentGroup()['key'] }}
    </td>
</tr>
<tr style="font-weight:600; background:#f3f4f6">
    <td colspan="2">Summe {{ $ctx->currentGroup()['key'] }}</td>
    <td style="text-align:right">{{ number_format((float) $ctx->aggregates['category_total'], 2, '.', "'") }}</td>
</tr>

Kopf und Fuss der Kategorie-Gruppe aus der Preisliste. Der Fuss liest das Gruppensummen-Aggregat.

Aggregate

Ein Aggregat sammelt Werte, während die Datensätze durchlaufen, und liefert am Ende einen Wert. Gruppen-Aggregate gelten pro Gruppe, Report-Aggregate über den ganzen Report.

Aggregate pro Gruppe

Für Summen gibt es Kurzformen; alle anderen Typen laufen über aggregate() mit einem Spec-Array.

aggSum(string $field, ?string $as = null) Summiert ein Feld. Ohne Namen heisst das Aggregat <field>_sum, z. B. price_sum.
sum(string $field, ?string $as = null) Alias für aggSum().
aggregate(array $aggregate) Generisches Gruppen-Aggregat mit type, field und as. Beispiele: ['type' => 'avg', 'field' => 'price', 'as' => 'avg_price'].

Unterstützte Aggregat-Typen

Der Typ entscheidet, wie die Werte gesammelt werden. Nicht-numerische Werte werden je nach Typ ignoriert.

sum Summe aller numerischen Werte. Startwert 0.
avg Durchschnitt der numerischen Werte. Null, wenn kein Wert gezählt wurde.
count Zählt alle nicht-null Werte. Ohne field zählt jeder Datensatz.
countDistinct · count_distinct Zählt unterschiedliche skalare Werte.
min · max Kleinster bzw. grösster Wert. Null-Werte werden übersprungen.

Aggregate pro Report

Report-Aggregate werden im Builder mit Name und Aggregat-Klasse deklariert. Sie sind im Report-Ende und während der Detailzeilen verfügbar.

aggregate(string $name, string $class, ?string $field = null, string $scope = 'report') Deklariert ein Report-Aggregat. Der Name ist der Key in $ctx->aggregates.
Sum::class · Avg::class · Count::class · CountDistinct::class · Min::class · Max::class Die Klassen liegen unter Guggach\Reports\Aggregates. Ohne field zählt Count die Datensätze.
string $scope = 'report' Aggregat-Scope: report für den ganzen Report oder group:1 … group:n für ein Gruppenlevel. Ungültige Scopes und fehlende Level werfen beim build().
use Guggach\Reports\Aggregates\Avg;
use Guggach\Reports\Aggregates\Count;
use Guggach\Reports\Aggregates\Max;
use Guggach\Reports\Aggregates\Sum;

public function define(ReportBuilder $r): void
{
    // ...

    // Report-wide: available in the report end band (and cumulatively
    // while the detail rows are rendered).
    $r->aggregate('grand_total', Sum::class, field: 'price');
    $r->aggregate('average_price', Avg::class, field: 'price');
    $r->aggregate('position_count', Count::class);
    $r->aggregate('highest_price', Max::class, field: 'price');

    $r->group('category', function ($g): void {
        // Group-wide: available in the group footer.
        $g->aggSum('price', as: 'category_total');
        $g->aggregate(['type' => 'count', 'field' => 'sku', 'as' => 'category_count']);
    });
}
{{-- Group footer: group + report aggregates --}}
<tr>
    <td colspan="2">Summe {{ $ctx->currentGroup()['key'] }}</td>
    <td style="text-align:right">
        {{ $ctx->currency($ctx->aggregates['category_total']) }}
    </td>
</tr>

{{-- Report end: report aggregates --}}
<p>{{ $ctx->total }} Positionen,
   Gesamt {{ $ctx->currency($ctx->aggregates['grand_total']) }}</p>

Report-Aggregate im Builder und der Zugriff in den Blade-Views.

Aggregat-Scope: Report oder Gruppe

Der Scope entscheidet, welcher Resolver das Aggregat sammelt. Report-Aggregate laufen über die ganze Datenmenge; Gruppen-Aggregate werden beim Betreten der Gruppe zurückgesetzt und im Gruppenfuss vollständig ausgeliefert.

scope: 'report' Reportweit. Verfügbar im Report-Ende und während der Detailzeilen; Startband und Gruppenkopf sehen nur den bisherigen Stand.
scope: 'group:1' … 'group:n' Gehört zum Gruppenlevel n. Im Gruppenfuss vollständig, im Gruppenkopf noch leer. Entspricht $g->aggSum() für einfache Summen.
$r->aggregate(... group:1) ≡ $g->aggSum(...) Beide Wege hängen das Aggregat an dieselbe GroupDefinition. Der Builder erkennt das Level aus dem Scope.
build()-Validierung Unbekannte Scopes, ein group:N ohne passendes Level und doppelte Namen im selben Level werfen eine InvalidArgumentException — kein stilles Umdeuten zum Report-Aggregat.
$r->group('category', function ($g): void {
    $g->header(view: 'category-head');
    $g->footer(view: 'category-foot');
});

// Group-scoped aggregate declared on the report builder.
// Equivalent to $g->aggSum('amount', as: 'category_total').
$r->aggregate(
    'category_total',
    Sum::class,
    field: 'amount',
    scope: 'group:1',
);

// Report-scoped aggregate.
$r->aggregate('grand_total', Sum::class, field: 'amount', scope: 'report');

Dasselbe Gruppen-Aggregat einmal über den GroupBuilder und einmal über den Report-Builder mit scope: group:1.

Wo welche Aggregate verfügbar sind

Nicht jedes Band sieht jeden Wert. Die Tabelle zeigt, was $ctx->aggregates pro Band enthält.

Gruppenkopf Report-Aggregate bis zum vorherigen Datensatz; die Aggregate dieser Gruppe starten bei null.
Detailband Report-Aggregate inklusive des aktuellen Datensatzes plus die Aggregate der tiefsten offenen Gruppe.
Gruppenfuss Report-Aggregate plus die vollständigen Aggregate dieser Gruppe.
Report-Start Noch keine Aggregate — der erste Datensatz wurde noch nicht verarbeitet.
Report-Ende Alle Report-Aggregate über die gesamte Datenmenge.

Sortierung und Gruppenwechsel

Die Engine gruppiert run-basiert: Sie vergleicht den Schlüssel des aktuellen mit dem des vorherigen Datensatzes. Ein Gruppenwechsel ist deshalb ein Wechsel in der sortierten Reihenfolge.

Quelle nach Gruppenschlüsseln sortieren

Liefere die Datensätze bereits nach den Gruppenschlüsseln sortiert — etwa per orderBy() in der Query oder applySorts() in der Source. Bei unsortierten Daten erscheint dieselbe Gruppe mehrfach mit neuem Kopf und Fuss.

Gruppen-Enrichment

enrichWith() holt Zusatzdaten genau einmal pro Gruppe — etwa Stammdaten, Adressen oder Planwerte, die sonst per Join in jeder Detailzeile dupliziert würden. Die Engine übergibt die Gruppenschlüssel als Filter und führt die Source in der Run-Locale aus.

enrichWith(ReportSource|string $source) Registriert eine Enrichment-Source pro Gruppenlevel. Akzeptiert eine Instanz oder einen Klassen-/Container-Namen.
einmal pro Gruppe Die Source läuft beim Öffnen der Gruppe, nicht pro Detailzeile. Bei einem erneuten Run derselben Gruppe (unsortierte Daten) läuft sie erneut.
$this->filters() Die Gruppenschlüssel kommen als FilterBag an, z. B. ['category' => 'Miete']. Die Source entscheidet, ob sie die Filter pusht oder selbst liest.
$ctx->groupData() · currentGroup()['data'] Der erste Rückgabedatensatz der Source steht im Gruppenkopf und -fuss bereit; ohne Treffer ist der Wert null.
EloquentSource + spatie/laravel-translatable Weil die Source im LocaleScope läuft, liefert das Model die Attribute in der Run-Sprache. Eine eigene Gruppen-Locale braucht es nicht.
ReportField(type: 'enum'|'id') Für einen exakten Gruppenschlüssel das Feld als enum oder id deklarieren (Operator in). type: string würde like verwenden.
use App\Reports\GroupEnrichment\CategoryInfo;

$r->group('category', function ($g): void {
    $g->header(view: 'category-head');
    $g->footer(view: 'category-foot');
    $g->enrichWith(CategoryInfo::class);
});
use App\Models\DemoCategory;
use Guggach\Reports\Sources\EloquentSource;
use Guggach\Reports\Sources\ReportField;
use Guggach\Reports\Sources\ReportSource;

final class CategoryInfo extends ReportSource
{
    public function records(): iterable
    {
        return (new EloquentSource(
            DemoCategory::class,
            [new ReportField('category', 'Kategorie', type: 'enum')],
        ))
            ->withFilters($this->filters() ?? [])
            ->withLocale($this->locale())
            ->records();
    }
}
@php($info = $ctx->groupData())

<div class="group-head">
    <strong>{{ $info?->name ?? $ctx->currentGroup()['key'] }}</strong>
    <span>Ziel {{ $ctx->currency($info->target) }}</span>
</div>

Der gruppierte Umsatz-Report lädt Kategoriename und Ziel aus einem Eloquent-Model; das View liest die Daten über $ctx->groupData().

Gruppen bleiben in der Run-Locale

Es gibt bewusst keine Sprache pro Gruppe. Übersetzte Gruppendaten kommen aus der Enrichment-Source: Sie läuft innerhalb des LocaleScope, deshalb liefert Eloquent mit Spatie Translatable den Namen in der gewählten Report-Sprache. Eine halbfertige localeUsing()-API am GroupBuilder wurde entfernt.

Noch nicht umgesetzt: Top-Down

drivesWith() und link() sind als API vorbereitet, aber Top-Down-Gruppenquellen folgen erst in v2. Der Builder lehnt drivesWith() beim build() mit einer klaren Exception ab, statt die Definition still zu ignorieren.

drivesWith(string $source) Eine Source treibt die Iteration (Top-Down) statt der Detail-Source. In v1 nicht implementiert und beim build() abgelehnt.
link(array $map) Mapping zwischen Gruppen-Schlüsseln und Feldern der Gruppen-Source. Wird derzeit nur für die spätere Top-Down-Variante gespeichert.