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
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>