Alle Beispiele

Gruppierter Umsatz mit Enrichment · Gruppendaten aus einem Eloquent-Model

Die Detailzeilen kommen aus demo_orders. Jeder Gruppenkopf lädt Kategoriename und Ziel über enrichWith() aus dem Eloquent-Model demo_categories; der Name wird pro Sprache über einen Accessor aufgelöst.

Flow Gruppen Sprache Aggregate Enrichment /reports/group_enrichment/run
PDF

Was hier typisch ist

  • enrichWith() läuft genau einmal pro Gruppe, nicht pro Detailzeile
  • Die Gruppenschlüssel werden als Filter an die Enrichment-Source übergeben
  • Eloquent liefert das übersetzte Attribut, weil die Engine app()->getLocale() gesetzt hat
  • Die Gruppendaten stehen als $ctx->groupData() im Kopf- und Fuss-Band bereit
  • Gruppen-Aggregat über $r->aggregate(..., scope: "group:1")
  • Sprachwechsel im Frame-Header wirkt auf Kategorienamen und Zahlenformat

Modul-Struktur

app/Reports/GroupEnrichment/
├── Report.php              # grouped sales report
├── CategoryInfo.php        # Eloquent enrichment source
├── lang/
│   ├── de/report.php
│   └── en/report.php
└── views/
    ├── report-start.blade.php
    ├── grid-head.blade.php
    ├── category-head.blade.php
    ├── category-foot.blade.php
    ├── row.blade.php
    └── report-end.blade.php

Dateien

<?php

declare(strict_types=1);

namespace App\Reports\GroupEnrichment;

use App\Models\DemoOrder;
use Guggach\Reports\Aggregates\Sum;
use Guggach\Reports\Definition\ReportBuilder;
use Guggach\Reports\Report as BaseReport;
use Guggach\Reports\Sources\EloquentSource;
use Guggach\Reports\Sources\ReportField;
use Guggach\Reports\Sources\ReportSource;

/**
 * Grouped sales report. The detail rows come from demo_orders; each category
 * header loads its translated name and target from demo_categories through an
 * Eloquent enrichment source. The switch in the preview header changes both
 * the report language and the accessor result.
 */
final class Report extends BaseReport
{
    public function key(): string
    {
        return 'group_enrichment';
    }

    public function name(): string
    {
        return __('group_enrichment::report.name');
    }

    public function currency(): string
    {
        return 'CHF';
    }

    public function source(): ReportSource
    {
        return new EloquentSource(
            DemoOrder::query()->orderBy('category')->orderBy('ordered_at'),
            $this->fields(),
        );
    }

    public function define(ReportBuilder $r): void
    {
        $r->reportStart(view: 'report-start');
        $r->gridHeader(view: 'grid-head');
        $r->detail(view: 'row');
        $r->reportEnd(view: 'report-end');

        $r->group('category', function ($g): void {
            $g->header(view: 'category-head');
            $g->footer(view: 'category-foot');
            $g->enrichWith(CategoryInfo::class);
        });

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

    /**
     * @return list<ReportField>
     */
    private function fields(): array
    {
        return [
            new ReportField('order_number', __('group_enrichment::report.columns.order'), filterable: false),
            new ReportField('customer', __('group_enrichment::report.columns.customer'), filterable: false),
            new ReportField('category', __('group_enrichment::report.columns.category'), type: 'enum', filterable: false),
            new ReportField('amount', __('group_enrichment::report.columns.amount'), type: 'number', filterable: false, aggregatable: true, format: 'currency'),
            new ReportField('ordered_at', __('group_enrichment::report.columns.ordered_at'), type: 'date', filterable: false, format: 'date'),
            new ReportField('status', __('group_enrichment::report.columns.status'), type: 'enum', filterable: false),
        ];
    }
}
<?php

declare(strict_types=1);

namespace App\Reports\GroupEnrichment;

use App\Models\DemoCategory;
use Guggach\Reports\Sources\EloquentSource;
use Guggach\Reports\Sources\ReportField;
use Guggach\Reports\Sources\ReportSource;

/**
 * Enrichment source: one Eloquent query per group. The engine passes the group
 * key as a filter (category = …), so the model's translated name accessor
 * runs in the active report locale.
 */
final class CategoryInfo extends ReportSource
{
    public function records(): iterable
    {
        return (new EloquentSource(
            DemoCategory::class,
            [
                // "enum" makes the group key an exact match (operator "in").
                new ReportField('category', 'Kategorie', type: 'enum'),
            ],
        ))
            ->withFilters($this->filters() ?? [])
            ->withLocale($this->locale())
            ->records();
    }
}
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * Demo category with translated names. This mimics the lazy translated
 * attribute of spatie/laravel-translatable: the accessor reads the ambient
 * app locale, which the reports engine sets during a run.
 */
final class DemoCategory extends Model
{
    protected $table = 'demo_categories';

    public $timestamps = false;

    protected $guarded = [];

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'translations' => 'array',
            'target' => 'decimal:2',
        ];
    }

    public function getNameAttribute(): string
    {
        $translations = $this->translations;
        $locale = app()->getLocale();

        return $translations[$locale] ?? $translations['en'] ?? '';
    }
}
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('demo_categories', function (Blueprint $table): void {
            $table->id();
            $table->string('category')->unique();
            $table->json('translations');
            $table->decimal('target', 10, 2);
        });

        DB::table('demo_categories')->insert([
            [
                'category' => 'Miete',
                'translations' => json_encode(['de' => 'Miete', 'en' => 'Rent'], JSON_THROW_ON_ERROR),
                'target' => 15000.00,
            ],
            [
                'category' => 'Service',
                'translations' => json_encode(['de' => 'Service', 'en' => 'Service'], JSON_THROW_ON_ERROR),
                'target' => 6000.00,
            ],
            [
                'category' => 'Verkauf',
                'translations' => json_encode(['de' => 'Verkauf', 'en' => 'Sale'], JSON_THROW_ON_ERROR),
                'target' => 30000.00,
            ],
        ]);
    }

    public function down(): void
    {
        Schema::dropIfExists('demo_categories');
    }
};
<?php

declare(strict_types=1);

return [
    'name' => 'Gruppierter Umsatz mit Enrichment',
    'title' => 'Umsatz nach Kategorie',
    'intro' => 'Die Detailzeilen kommen aus demo_orders. Jeder Gruppenkopf lädt Kategoriename und Ziel über enrichWith() aus dem Eloquent-Model demo_categories.',
    'language_label' => 'Sprache',
    'columns' => [
        'order' => 'Auftrag',
        'customer' => 'Kunde',
        'category' => 'Kategorie',
        'amount' => 'Betrag',
        'ordered_at' => 'Bestellt am',
        'status' => 'Status',
    ],
    'summary' => [
        'subtotal' => 'Zwischensumme',
        'total' => 'Gesamttotal',
        'target' => 'Ziel',
    ],
    'status' => [
        'bezahlt' => 'Bezahlt',
        'offen' => 'Offen',
    ],
];
<?php

declare(strict_types=1);

return [
    'name' => 'Grouped revenue with enrichment',
    'title' => 'Revenue by category',
    'intro' => 'The detail rows come from demo_orders. Every group header loads its translated category name and target through enrichWith() from the demo_categories Eloquent model.',
    'language_label' => 'Language',
    'columns' => [
        'order' => 'Order',
        'customer' => 'Customer',
        'category' => 'Category',
        'amount' => 'Amount',
        'ordered_at' => 'Ordered on',
        'status' => 'Status',
    ],
    'summary' => [
        'subtotal' => 'Subtotal',
        'total' => 'Grand total',
        'target' => 'Target',
    ],
    'status' => [
        'bezahlt' => 'Paid',
        'offen' => 'Open',
    ],
];
<style>
    .ge-head {
        display: flex;
        justify-content: space-between;
        align-items: flex-start;
        gap: 8mm;
        border-bottom: 3px solid #0f766e;
        padding-bottom: 4mm;
        margin-bottom: 4mm;
    }
    .ge-head h1 { margin: 0 0 1mm; font-size: 22px; color: #0f172a; }
    .ge-head p { margin: 0; max-width: 125mm; font-size: 10px; color: #64748b; }
    .ge-locale {
        padding: 2mm 3mm;
        border-radius: 2mm;
        background: #ccfbf1;
        color: #115e59;
        font-size: 9px;
        white-space: nowrap;
    }
    .ge-locale strong { display: block; font-size: 11px; }
    .ge-group {
        display: flex;
        justify-content: space-between;
        align-items: baseline;
        gap: 6mm;
        margin-top: 3mm;
        padding: 2.5mm 3mm;
        border-radius: 1.5mm;
        background: #f1f5f9;
        border-left: 3px solid #0f766e;
    }
    .ge-group__name { font-size: 12px; font-weight: 700; color: #0f172a; }
    .ge-group__target { font-size: 9px; color: #475569; font-variant-numeric: tabular-nums; }
    .ge-grid-head,
    .ge-row,
    .ge-subtotal,
    .ge-total {
        display: flex;
        align-items: center;
        gap: 3mm;
        box-sizing: border-box;
    }
    .ge-grid-head {
        height: 8mm;
        margin-top: 2mm;
        border-bottom: 2px solid #0f766e;
        font-size: 8px;
        font-weight: 700;
        color: #0f172a;
    }
    .ge-row {
        min-height: 8mm;
        border-bottom: 1px solid #e2e8f0;
        font-size: 9px;
        color: #334155;
    }
    .ge-col-order { flex: 0 0 30mm; font-weight: 600; color: #0f172a; }
    .ge-col-customer { flex: 1 1 auto; }
    .ge-col-date { flex: 0 0 28mm; color: #64748b; }
    .ge-col-status { flex: 0 0 22mm; }
    .ge-col-amount { flex: 0 0 28mm; text-align: right; font-variant-numeric: tabular-nums; }
    .ge-status {
        display: inline-block;
        padding: 0.8mm 1.6mm;
        border-radius: 1mm;
        font-size: 8px;
        font-weight: 600;
    }
    .ge-status--bezahlt { background: #dcfce7; color: #166534; }
    .ge-status--offen { background: #fef3c7; color: #92400e; }
    .ge-subtotal {
        justify-content: flex-end;
        gap: 6mm;
        padding: 2.5mm 3mm;
        border-bottom: 1px solid #cbd5e1;
        font-size: 10px;
        font-weight: 700;
        color: #0f172a;
    }
    .ge-total {
        justify-content: flex-end;
        gap: 6mm;
        margin-top: 4mm;
        padding-top: 3mm;
        border-top: 2px solid #0f172a;
        font-size: 12px;
        font-weight: 700;
    }
</style>

<div class="ge-head">
    <div>
        <h1>{{ __('group_enrichment::report.title') }}</h1>
        <p>{{ __('group_enrichment::report.intro') }}</p>
    </div>
    <div class="ge-locale">
        {{ __('group_enrichment::report.language_label') }}
        <strong>{{ $ctx->locale }}</strong>
        {{ $ctx->formatLocale }}
    </div>
</div>
<div class="ge-grid-head">
    <span class="ge-col-order">{{ __('group_enrichment::report.columns.order') }}</span>
    <span class="ge-col-customer">{{ __('group_enrichment::report.columns.customer') }}</span>
    <span class="ge-col-date">{{ __('group_enrichment::report.columns.ordered_at') }}</span>
    <span class="ge-col-status">{{ __('group_enrichment::report.columns.status') }}</span>
    <span class="ge-col-amount">{{ __('group_enrichment::report.columns.amount') }}</span>
</div>
@php($info = $ctx->groupData())

<div class="ge-group">
    <span class="ge-group__name">
        {{ $info?->name ?? $ctx->currentGroup()['key'] }}
    </span>
    @if ($info)
        <span class="ge-group__target">
            {{ __('group_enrichment::report.summary.target') }}
            {{ $ctx->currency($info->target) }}
        </span>
    @endif
</div>
<div class="ge-subtotal">
    <span>{{ __('group_enrichment::report.summary.subtotal') }}</span>
    <span>{{ $ctx->currency($ctx->aggregates['category_total']) }}</span>
</div>
<div class="ge-row">
    <span class="ge-col-order">{{ $ctx->order_number }}</span>
    <span class="ge-col-customer">{{ $ctx->customer }}</span>
    <span class="ge-col-date">{{ $ctx->date($ctx->ordered_at) }}</span>
    <span class="ge-col-status">
        <span class="ge-status ge-status--{{ $ctx->status }}">
            {{ __('group_enrichment::report.status.'.$ctx->status) }}
        </span>
    </span>
    <span class="ge-col-amount">{{ $ctx->currency($ctx->amount) }}</span>
</div>
<div class="ge-total">
    <span>{{ __('group_enrichment::report.summary.total') }}</span>
    <span>{{ $ctx->currency($ctx->aggregates['grand_total']) }}</span>
</div>