Wer Ende August registriert und am 1. September bezahlt hat, tauchte in keiner
Neupartner-Zaehlung auf: Der August-Snapshot wurde um 04:45 eingefroren, da
fehlte noch das Abrechnungskonto; im September lag active_date im falschen
Monat. Betroffen sind 2026 acht Partner, auch im Recruiting ihrer Sponsoren.
Kundenentscheidung 16.09.2026: der Zeitpunkt der Zahlung ist massgeblich.
Neue Spalte users.partner_paid_at, gesetzt bei der ersten Kontozahlung
(Payment::paymentStatusPaidAction) und bei manueller Freischaltung durch den
Admin — nur, solange sie leer ist; Verlaengerung und Wiederherstellung aendern
sie nicht. Bestehende Partner werden per Migration aus der ersten bezahlten
Registrierungsbestellung rueckgefuellt (Zeitstempel der Punktebuchung, ersatzweise
Bestelldatum, ohne Bestellung das Aktivierungsdatum). Bewusst nicht
payment_account minus ein Jahr: das ergaebe bei Verlaengerern den Jahrestag der
Erstzahlung.
User::scopeNewPartnerIn() und isNewPartnerIn() lesen jetzt partner_paid_at —
damit Kachel, Recruiter-Ranking, Top 3 und Neupartner-Liste gleichermassen.
Die Liste und der CSV-Export zeigen "Bezahlt am" neben "Registriert am".
Unveraendert: die Teamstruktur haengt weiter an active_date (auch im
Provisionsbaum). Wer ist neu dazugekommen und wer gehoert zur Linie sind zwei
verschiedene Fragen.
Die eingefrorenen Monate Januar bis August enthalten sieben dieser Partner
nicht; ob sie per --force neu gebaut werden, ist eine Entscheidung — siehe
dev/2026-07-22/neupartner-zahlungsmonat.md.
Mit drin: die handgebaute SQLite-Tabelle in BackofficeDashboardServiceTest
kannte status_reason aus d6efe56 noch nicht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
476 lines
19 KiB
PHP
476 lines
19 KiB
PHP
<?php
|
||
|
||
use App\Models\BackofficeStatisticsSnapshot;
|
||
use App\Models\UserAbo;
|
||
use App\Models\UserLevel;
|
||
use App\Models\UserSalesVolume;
|
||
use App\Services\Backoffice\BackofficeDashboardService;
|
||
use App\Services\Backoffice\BackofficeDrilldownService;
|
||
use App\User;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Tests\TestCase;
|
||
|
||
uses(TestCase::class);
|
||
|
||
beforeEach(function () {
|
||
Schema::dropIfExists('user_sales_volumes');
|
||
Schema::dropIfExists('backoffice_statistics_snapshots');
|
||
Schema::dropIfExists('user_businesses');
|
||
Schema::dropIfExists('user_business_structures');
|
||
Schema::dropIfExists('user_abo_orders');
|
||
Schema::dropIfExists('user_abo_items');
|
||
Schema::dropIfExists('products');
|
||
Schema::dropIfExists('user_abos');
|
||
Schema::dropIfExists('payment_transactions');
|
||
Schema::dropIfExists('shopping_payments');
|
||
Schema::dropIfExists('shopping_order_items');
|
||
Schema::dropIfExists('shopping_orders');
|
||
Schema::dropIfExists('users');
|
||
Schema::dropIfExists('user_levels');
|
||
Schema::dropIfExists('user_accounts');
|
||
|
||
Schema::create('user_accounts', function ($table) {
|
||
$table->increments('id');
|
||
$table->string('first_name')->nullable();
|
||
$table->string('last_name')->nullable();
|
||
});
|
||
|
||
Schema::create('user_levels', function ($table) {
|
||
$table->increments('id');
|
||
$table->string('name');
|
||
$table->unsignedInteger('pos')->nullable();
|
||
$table->boolean('active')->default(true);
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('users', function ($table) {
|
||
$table->increments('id');
|
||
$table->string('email')->unique();
|
||
$table->string('password');
|
||
$table->unsignedInteger('account_id')->nullable();
|
||
$table->unsignedInteger('m_level')->nullable();
|
||
$table->unsignedInteger('m_sponsor')->nullable();
|
||
$table->boolean('active')->default(false);
|
||
$table->timestamp('active_date')->nullable();
|
||
$table->timestamp('partner_paid_at')->nullable();
|
||
$table->unsignedTinyInteger('admin')->default(0);
|
||
$table->unsignedTinyInteger('wizard')->default(0);
|
||
$table->unsignedTinyInteger('blocked')->default(0);
|
||
$table->char('lang', 2)->default('de');
|
||
$table->timestamp('payment_account')->nullable();
|
||
$table->timestamps();
|
||
$table->softDeletes();
|
||
});
|
||
|
||
Schema::create('user_abos', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedInteger('user_id')->nullable();
|
||
$table->unsignedInteger('member_id')->nullable();
|
||
$table->char('is_for', 2)->nullable();
|
||
$table->boolean('active')->default(true);
|
||
$table->unsignedTinyInteger('status')->default(2);
|
||
$table->string('status_reason')->nullable();
|
||
$table->date('start_date')->nullable();
|
||
$table->date('next_date')->nullable();
|
||
$table->timestamps();
|
||
$table->softDeletes();
|
||
});
|
||
|
||
Schema::create('user_abo_items', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedInteger('user_abo_id');
|
||
$table->unsignedInteger('product_id')->nullable();
|
||
$table->unsignedTinyInteger('comp')->nullable();
|
||
$table->unsignedInteger('qty')->default(1);
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('products', function ($table) {
|
||
$table->increments('id');
|
||
$table->decimal('points', 8, 2)->default(0);
|
||
$table->timestamps();
|
||
$table->softDeletes();
|
||
});
|
||
|
||
Schema::create('user_abo_orders', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedInteger('user_abo_id');
|
||
$table->unsignedInteger('shopping_order_id');
|
||
$table->unsignedTinyInteger('status')->default(2);
|
||
$table->boolean('paid')->default(true);
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('shopping_orders', function ($table) {
|
||
$table->increments('id');
|
||
$table->boolean('is_abo')->default(false);
|
||
$table->timestamps();
|
||
$table->softDeletes();
|
||
});
|
||
|
||
// Positionsebene: nur fuer die Aufteilung der Abo-Punkte in wiederkehrende
|
||
// und einmalig dazubestellte (is_abo_addon) noetig.
|
||
Schema::create('shopping_order_items', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedInteger('shopping_order_id');
|
||
$table->unsignedInteger('product_id')->nullable();
|
||
$table->unsignedInteger('qty')->default(1);
|
||
$table->decimal('points', 10, 2)->nullable();
|
||
$table->boolean('is_abo_addon')->default(false);
|
||
$table->timestamps();
|
||
$table->softDeletes();
|
||
});
|
||
|
||
Schema::create('shopping_payments', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedInteger('shopping_order_id');
|
||
$table->string('clearingtype')->nullable();
|
||
$table->string('reference')->nullable();
|
||
$table->integer('amount')->nullable();
|
||
$table->string('currency')->nullable();
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('payment_transactions', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedInteger('shopping_payment_id');
|
||
$table->string('request')->nullable();
|
||
$table->unsignedInteger('errorcode')->nullable();
|
||
$table->string('errormessage')->nullable();
|
||
$table->string('customermessage')->nullable();
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('user_sales_volumes', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedInteger('user_id');
|
||
$table->unsignedInteger('shopping_order_id')->nullable();
|
||
$table->unsignedTinyInteger('month')->nullable();
|
||
$table->unsignedSmallInteger('year')->nullable();
|
||
$table->decimal('points', 13, 2)->nullable();
|
||
$table->decimal('total_net', 13, 2)->nullable();
|
||
$table->unsignedTinyInteger('status')->nullable();
|
||
$table->unsignedTinyInteger('status_turnover')->nullable();
|
||
// Ursprungsart einer Stornobuchung — gebraucht von den Eigen-/Shop-Praedikaten.
|
||
$table->unsignedTinyInteger('storno_origin_status')->nullable();
|
||
// Nur-KP-Kennzeichen (Neupartner-Einschreibungspunkte) — gebraucht von
|
||
// SalesVolumePoints::nonPaylinePoints().
|
||
$table->unsignedTinyInteger('status_points')->nullable();
|
||
$table->decimal('month_KP_points', 13, 2)->nullable();
|
||
$table->decimal('month_shop_points', 13, 2)->nullable();
|
||
$table->decimal('month_total_net', 13, 2)->nullable();
|
||
$table->decimal('month_shop_total_net', 13, 2)->nullable();
|
||
$table->timestamps();
|
||
});
|
||
|
||
// Gespeicherter Provisionslauf: fuer abgeschlossene Monate liest die
|
||
// Statistik die Struktur von dort (hier leer → Live-Baum).
|
||
Schema::create('user_business_structures', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedTinyInteger('month');
|
||
$table->unsignedSmallInteger('year');
|
||
$table->boolean('completed')->default(false);
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('user_businesses', function ($table) {
|
||
$table->increments('id');
|
||
$table->unsignedInteger('user_id');
|
||
$table->unsignedTinyInteger('month');
|
||
$table->unsignedSmallInteger('year');
|
||
$table->unsignedInteger('b_structure_id')->nullable();
|
||
$table->text('sponsor')->nullable();
|
||
$table->timestamps();
|
||
});
|
||
|
||
Schema::create('backoffice_statistics_snapshots', function ($table) {
|
||
$table->id();
|
||
$table->unsignedInteger('user_id');
|
||
$table->unsignedSmallInteger('year');
|
||
$table->unsignedTinyInteger('month');
|
||
$table->string('tree', 16)->default('commission');
|
||
$table->json('payload');
|
||
$table->timestamp('calculated_at')->nullable();
|
||
$table->timestamps();
|
||
});
|
||
});
|
||
|
||
it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function () {
|
||
UserLevel::forceCreate([
|
||
'id' => 1,
|
||
'name' => 'Partner',
|
||
'pos' => 1,
|
||
'active' => true,
|
||
]);
|
||
|
||
$root = User::forceCreate([
|
||
'email' => 'root@test.test',
|
||
'password' => 'secret',
|
||
'lang' => 'de',
|
||
'admin' => 1,
|
||
]);
|
||
|
||
$lineOne = User::forceCreate([
|
||
'email' => 'line-one@test.test',
|
||
'password' => 'secret',
|
||
'lang' => 'de',
|
||
'm_sponsor' => $root->id,
|
||
'm_level' => 1,
|
||
'active_date' => '2026-05-03 00:00:00',
|
||
'partner_paid_at' => '2026-05-03 00:00:00',
|
||
'payment_account' => '2030-01-01 00:00:00',
|
||
]);
|
||
|
||
$lineTwo = User::forceCreate([
|
||
'email' => 'line-two@test.test',
|
||
'password' => 'secret',
|
||
'lang' => 'de',
|
||
'm_sponsor' => $lineOne->id,
|
||
'm_level' => 1,
|
||
'active_date' => '2026-04-03 00:00:00',
|
||
'partner_paid_at' => '2026-04-03 00:00:00',
|
||
'payment_account' => '2030-01-01 00:00:00',
|
||
]);
|
||
|
||
User::forceCreate([
|
||
'email' => 'expired-line-one@test.test',
|
||
'password' => 'secret',
|
||
'lang' => 'de',
|
||
'm_sponsor' => $root->id,
|
||
'm_level' => 1,
|
||
'active_date' => '2026-01-03 00:00:00',
|
||
'partner_paid_at' => '2026-01-03 00:00:00',
|
||
'payment_account' => '2020-01-01 00:00:00',
|
||
]);
|
||
|
||
$sponsor = $lineTwo;
|
||
|
||
foreach (range(3, 9) as $lineNumber) {
|
||
$sponsor = User::forceCreate([
|
||
'email' => 'line-'.$lineNumber.'@test.test',
|
||
'password' => 'secret',
|
||
'lang' => 'de',
|
||
'm_sponsor' => $sponsor->id,
|
||
'm_level' => 1,
|
||
'active_date' => '2026-04-03 00:00:00',
|
||
'partner_paid_at' => '2026-04-03 00:00:00',
|
||
'payment_account' => '2030-01-01 00:00:00',
|
||
]);
|
||
}
|
||
|
||
$activePartnerAbo = UserAbo::forceCreate([
|
||
'user_id' => $lineOne->id,
|
||
'member_id' => $lineOne->id,
|
||
'is_for' => 'me',
|
||
'active' => true,
|
||
'status' => 2,
|
||
'start_date' => '2026-05-04',
|
||
'next_date' => '2026-06-01',
|
||
]);
|
||
|
||
UserAbo::forceCreate([
|
||
'user_id' => $lineTwo->id,
|
||
'member_id' => $lineOne->id,
|
||
'is_for' => 'ot',
|
||
'active' => true,
|
||
'status' => 2,
|
||
'start_date' => '2026-05-05',
|
||
'next_date' => '2026-06-01',
|
||
]);
|
||
|
||
$pausedPartnerAbo = UserAbo::forceCreate([
|
||
'user_id' => $lineOne->id,
|
||
'member_id' => $lineOne->id,
|
||
'is_for' => 'me',
|
||
'active' => true,
|
||
'status' => 3,
|
||
'start_date' => '2026-04-15',
|
||
'next_date' => '2026-06-02',
|
||
]);
|
||
|
||
$aboOrderId = DB::table('shopping_orders')->insertGetId([
|
||
'is_abo' => true,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
$singleOrderId = DB::table('shopping_orders')->insertGetId([
|
||
'is_abo' => false,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
$failedAboOrderId = DB::table('shopping_orders')->insertGetId([
|
||
'is_abo' => true,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
$failedAboPaymentId = DB::table('shopping_payments')->insertGetId([
|
||
'shopping_order_id' => $failedAboOrderId,
|
||
'clearingtype' => 'cc',
|
||
'reference' => 'abo-failed',
|
||
'amount' => 9900,
|
||
'currency' => 'EUR',
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
DB::table('payment_transactions')->insert([
|
||
'shopping_payment_id' => $failedAboPaymentId,
|
||
'request' => 'authorization',
|
||
'errorcode' => 902,
|
||
'errormessage' => 'Bank hat abgelehnt',
|
||
'customermessage' => 'Bitte Zahlungsmittel prüfen',
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
DB::table('user_abo_orders')->insert([
|
||
'user_abo_id' => $pausedPartnerAbo->id,
|
||
'shopping_order_id' => $failedAboOrderId,
|
||
'status' => 3,
|
||
'paid' => false,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
// Mehrere Zeilen je User/Monat: Die month_*-Spalten sind kumulative Laufstände
|
||
// (NICHT Einzelbeträge). Eine SUM() darüber zählt mehrfach – die Auswertung muss
|
||
// stattdessen je Zeile `points`/`total_net` nach `status` summieren.
|
||
$makeVolume = function (array $attributes): void {
|
||
UserSalesVolume::forceCreate(array_merge([
|
||
'month' => 5,
|
||
'year' => 2026,
|
||
'status_turnover' => null,
|
||
'month_KP_points' => 0,
|
||
'month_shop_points' => 0,
|
||
'month_total_net' => 0,
|
||
'month_shop_total_net' => 0,
|
||
], $attributes));
|
||
};
|
||
|
||
// lineOne: Eigenpunkte 150 + 250 = 400, Shop (Abo-Order) 300 + 400 = 700
|
||
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => $aboOrderId, 'status' => 1, 'points' => 150, 'total_net' => 40, 'month_KP_points' => 150, 'month_total_net' => 40]);
|
||
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => $aboOrderId, 'status' => 1, 'points' => 250, 'total_net' => 60, 'month_KP_points' => 400, 'month_total_net' => 100]);
|
||
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => $aboOrderId, 'status' => 2, 'points' => 300, 'total_net' => 120, 'month_shop_points' => 300, 'month_shop_total_net' => 120]);
|
||
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => $aboOrderId, 'status' => 2, 'points' => 400, 'total_net' => 80, 'month_shop_points' => 700, 'month_shop_total_net' => 200]);
|
||
// status 3 (pending) darf nirgends mitgezählt werden
|
||
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => null, 'status' => 3, 'points' => 999, 'total_net' => 999]);
|
||
|
||
// lineTwo: Eigenpunkte 200, Shop (Einzelbestellung) 300
|
||
$makeVolume(['user_id' => $lineTwo->id, 'shopping_order_id' => $singleOrderId, 'status' => 1, 'points' => 200, 'total_net' => 50, 'month_KP_points' => 200, 'month_total_net' => 50]);
|
||
$makeVolume(['user_id' => $lineTwo->id, 'shopping_order_id' => $singleOrderId, 'status' => 2, 'points' => 300, 'total_net' => 60, 'month_shop_points' => 300, 'month_shop_total_net' => 60]);
|
||
|
||
$overview = (new BackofficeDashboardService)->overview($root, 5, 2026);
|
||
|
||
expect($overview['lines'][1]['consultants'])->toBe(2);
|
||
expect($overview['lines'][1]['new_partners'])->toBe(1);
|
||
expect($overview['lines'][1]['team_partner_abos'])->toBe(2);
|
||
expect($overview['lines'][1]['team_partner_abos_new'])->toBe(1);
|
||
expect($overview['lines'][1]['team_customer_abos'])->toBe(1);
|
||
expect($overview['lines'][1]['team_customer_abos_new'])->toBe(1);
|
||
expect($overview['lines'][1]['own_points'])->toBe(400.0);
|
||
expect($overview['lines'][1]['external_points'])->toBe(700.0);
|
||
expect($overview['lines'][1]['customer_abo_points'])->toBe(700.0);
|
||
expect($overview['lines'][1]['customer_single_order_points'])->toBe(0.0);
|
||
expect($overview['lines'][1]['customer_other_points'])->toBe(0.0);
|
||
expect($overview['lines'][1]['total_points'])->toBe(1100.0);
|
||
expect($overview['lines'][1]['turnover_net'])->toBe(300.0);
|
||
expect($overview['lines'][1]['shop_1000'])->toBe(1);
|
||
expect($overview['lines'][2]['consultants'])->toBe(1);
|
||
expect($overview['lines'])->toHaveKey(9);
|
||
expect($overview['lines'])->not->toHaveKey(10);
|
||
expect($overview['lines'][9]['consultants'])->toBe(1);
|
||
expect($overview['totals']['total_points'])->toBe(1600.0);
|
||
expect($overview['totals']['turnover_net'])->toBe(410.0);
|
||
|
||
$details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'total_points', 5, 2026);
|
||
|
||
expect($details['line_label'])->toBe('Alle Linien');
|
||
expect($details['rows'])->toHaveCount(2);
|
||
expect($details['summary']['own_points'])->toBe(600.0);
|
||
expect($details['summary']['external_points'])->toBe(1000.0);
|
||
expect($details['summary']['customer_abo_points'])->toBe(700.0);
|
||
expect($details['summary']['customer_single_order_points'])->toBe(300.0);
|
||
expect($details['summary']['customer_other_points'])->toBe(0.0);
|
||
expect($details['summary']['total_points'])->toBe(1600.0);
|
||
|
||
$shop1000Details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'shop_1000', 5, 2026);
|
||
|
||
expect($shop1000Details['rows'])->toHaveCount(1);
|
||
expect($shop1000Details['rows'][0]['career_level'])->toBe('Partner');
|
||
expect($shop1000Details['rows'][0]['own_points'])->toBe(400.0);
|
||
expect($shop1000Details['rows'][0]['customer_abo_points'])->toBe(700.0);
|
||
|
||
$consultantDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'consultants', 5, 2026);
|
||
|
||
expect($consultantDetails['rows'])->toHaveCount(2);
|
||
expect($consultantDetails['summary']['count'])->toBe(2);
|
||
expect(collect($consultantDetails['rows'])->pluck('is_account_active')->all())->toBe([true, false]);
|
||
expect(collect($consultantDetails['rows'])->pluck('career_level')->all())->toBe(['Partner', 'Partner']);
|
||
|
||
$aboDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'team_partner_abos', 5, 2026);
|
||
|
||
expect($aboDetails['rows'][0]['start_date'])->toBe('04.05.2026');
|
||
expect($aboDetails['rows'][0]['is_new_this_month'])->toBeTrue();
|
||
expect($aboDetails['rows'])->toHaveCount(2);
|
||
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $activePartnerAbo->id)['status_reason'])->toBeNull();
|
||
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['status_reason'])->toBe('[902] Bank hat abgelehnt');
|
||
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['is_new_this_month'])->toBeFalse();
|
||
});
|
||
|
||
it('verwendet gespeicherte Snapshots fuer abgeschlossene Monate', function () {
|
||
$root = User::forceCreate([
|
||
'email' => 'snapshot-root@test.test',
|
||
'password' => 'secret',
|
||
'lang' => 'de',
|
||
'admin' => 1,
|
||
]);
|
||
|
||
$payload = [
|
||
'month' => 4,
|
||
'year' => 2026,
|
||
'metric_labels' => [],
|
||
'lines' => [
|
||
1 => [
|
||
'line' => 1,
|
||
'label' => 'Linie 1',
|
||
'user_ids' => [],
|
||
'consultants' => 99,
|
||
'new_partners' => 0,
|
||
'team_partner_abos' => 0,
|
||
'team_partner_abos_new' => 0,
|
||
'team_customer_abos' => 0,
|
||
'team_customer_abos_new' => 0,
|
||
'own_points' => 0,
|
||
'external_points' => 0,
|
||
'customer_abo_points' => 0,
|
||
'customer_single_order_points' => 0,
|
||
'customer_other_points' => 0,
|
||
'total_points' => 0,
|
||
'turnover_net' => 0,
|
||
'shop_1000' => 0,
|
||
],
|
||
],
|
||
'totals' => [
|
||
'label' => 'Summe',
|
||
'consultants' => 99,
|
||
],
|
||
];
|
||
|
||
BackofficeStatisticsSnapshot::create([
|
||
'user_id' => $root->id,
|
||
'year' => 2026,
|
||
'month' => 4,
|
||
'payload' => $payload,
|
||
'calculated_at' => now(),
|
||
]);
|
||
|
||
$overview = (new BackofficeDashboardService)->overview($root, 4, 2026);
|
||
|
||
expect($overview['lines'][1]['consultants'])->toBe(99);
|
||
expect($overview['_meta']['source'])->toBe('snapshot');
|
||
expect($overview['_meta']['source_label'])->toBe('Snapshot');
|
||
});
|