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>
251 lines
9 KiB
PHP
251 lines
9 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\User\AboController;
|
|
use App\Http\Controllers\User\BackofficeStatisticsController;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboOrder;
|
|
use App\Models\UserSalesVolume;
|
|
use App\Services\Backoffice\BackofficeDashboardService;
|
|
use App\Services\Backoffice\BackofficeDrilldownService;
|
|
use App\Services\Backoffice\PersonalStatisticsService;
|
|
use App\Services\Backoffice\SalesVolumePoints;
|
|
use App\Services\Team\AnnualFeeDueService;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function scUser(?int $sponsor = null): User
|
|
{
|
|
return User::forceCreate([
|
|
'email' => 'sc-'.uniqid('', true).'@example.test',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'm_level' => 1,
|
|
'm_sponsor' => $sponsor,
|
|
'active_date' => '2026-01-01',
|
|
'partner_paid_at' => '2026-01-01',
|
|
'payment_account' => '2027-01-01',
|
|
]);
|
|
}
|
|
|
|
function scVolume(User $user, float $points, int $status = 1, array $extra = []): UserSalesVolume
|
|
{
|
|
return UserSalesVolume::forceCreate(array_merge([
|
|
'user_id' => $user->id,
|
|
'month' => 9,
|
|
'year' => 2026,
|
|
'date' => '2026-09-05',
|
|
'points' => $points,
|
|
'total_net' => $points,
|
|
'status' => $status,
|
|
'status_points' => 1,
|
|
'status_turnover' => 1,
|
|
], $extra));
|
|
}
|
|
|
|
/**
|
|
* Ruft eine private Methode des Controllers auf — die Exportzuordnung ist bewusst
|
|
* nicht oeffentlich, muss aber gegen die Ueberschriften pruefbar bleiben.
|
|
*/
|
|
function scCsv(string $method, mixed ...$args): array
|
|
{
|
|
$controller = app(BackofficeStatisticsController::class);
|
|
|
|
return (new ReflectionMethod($controller, $method))->invoke($controller, ...$args);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- A09
|
|
|
|
it('haelt in jedem CSV-Export Kopfzeile und Summenzeile gleich breit', function (string $metric) {
|
|
$headers = scCsv('csvHeaders', $metric);
|
|
$summary = scCsv('csvSummaryRow', [
|
|
'metric' => $metric,
|
|
'summary' => [
|
|
'count' => 1,
|
|
'customer_points' => 100,
|
|
'total_points' => 300,
|
|
'own_points' => 10,
|
|
'external_points' => 20,
|
|
'customer_abo_points' => 30,
|
|
'customer_single_order_points' => 40,
|
|
'customer_other_points' => 50,
|
|
'abo_count' => 2,
|
|
'points' => 60,
|
|
'deliveries' => 3,
|
|
],
|
|
]);
|
|
|
|
expect($summary)->toHaveCount(count($headers));
|
|
})->with(['new_partners', 'team_customer_abos', 'team_partner_abos', 'total_points', 'active_partners']);
|
|
|
|
it('stellt die Neupartner-Summen unter die richtigen Ueberschriften', function () {
|
|
$headers = scCsv('csvHeaders', 'new_partners');
|
|
$summary = scCsv('csvSummaryRow', [
|
|
'metric' => 'new_partners',
|
|
'summary' => ['count' => 1, 'customer_points' => 100, 'total_points' => 300],
|
|
]);
|
|
|
|
$byHeader = array_combine($headers, $summary);
|
|
|
|
expect($byHeader['Punkte seiner Kunden'])->toBe(100)
|
|
->and($byHeader['Gesamt inkl. Eigenbestellung'])->toBe(300)
|
|
->and($byHeader['Sponsor'])->toBe('');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- A02
|
|
|
|
it('zaehlt in der persoenlichen Statistik nur freigegebene Buchungen', function () {
|
|
$user = scUser();
|
|
scVolume($user, 100, 1);
|
|
scVolume($user, 50, 3); // pending
|
|
scVolume($user, 25, 0); // nicht zugeordnet
|
|
|
|
$personal = app(PersonalStatisticsService::class)->monthly($user, 9, 2026);
|
|
$canonical = SalesVolumePoints::totalPointsByUser([$user->id], 9, 2026)->get($user->id);
|
|
|
|
expect($personal['totals']['total_points'])->toBe(100.0)
|
|
->and($personal['totals']['total_points'])->toBe($canonical);
|
|
});
|
|
|
|
it('bucht einen Storno im persoenlichen Segment seiner Ursprungsbuchung', function () {
|
|
$user = scUser();
|
|
scVolume($user, 100, 1);
|
|
scVolume($user, -100, 6, ['storno_origin_status' => 1]);
|
|
|
|
$groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups'];
|
|
|
|
expect($groups['own']['points'])->toBe(0.0)
|
|
->and($groups['other']['points'])->toBe(0.0);
|
|
});
|
|
|
|
it('laesst Altstornos ohne Ursprungsart unter Sonstige', function () {
|
|
$user = scUser();
|
|
scVolume($user, 100, 1);
|
|
scVolume($user, -100, 6);
|
|
|
|
$groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups'];
|
|
|
|
expect($groups['own']['points'])->toBe(100.0)
|
|
->and($groups['other']['points'])->toBe(-100.0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- A03
|
|
|
|
it('zeigt negative Monatssalden im Drilldown und bleibt mit der Uebersicht gleich', function () {
|
|
$root = scUser();
|
|
$plus = scUser($root->id);
|
|
$minus = scUser($root->id);
|
|
|
|
scVolume($plus, 100, 1);
|
|
scVolume($minus, -40, 1);
|
|
|
|
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
|
|
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026);
|
|
|
|
// Knotenregel des Provisionsbaums (K11): ein negativer Monatssaldo zaehlt 0,
|
|
// die Kachel zeigt also 100 und weist die 40 gesondert als nicht abgezogen aus.
|
|
// Der Berater bleibt in der Liste (Eigenpunkte -40, Gesamt 0).
|
|
expect((float) $overview['totals']['total_points'])->toBe(100.0)
|
|
->and((float) $overview['storno_floor_points'])->toBe(40.0)
|
|
->and($details['summary']['total_points'])->toBe(100.0)
|
|
->and($details['summary']['storno_floor_points'])->toBe(40.0)
|
|
->and($details['rows'])->toHaveCount(2)
|
|
->and(collect($details['rows'])->firstWhere('user_id', $minus->id)['own_points'])->toBe(-40.0)
|
|
->and(collect($details['rows'])->firstWhere('user_id', $minus->id)['total_points'])->toBe(0.0);
|
|
});
|
|
|
|
it('behaelt die Schwelle der 1000-Punkte-Liste', function () {
|
|
$root = scUser();
|
|
$stark = scUser($root->id);
|
|
$schwach = scUser($root->id);
|
|
|
|
scVolume($stark, 1200, 1);
|
|
scVolume($schwach, -40, 1);
|
|
|
|
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'shop_1000', 9, 2026);
|
|
|
|
expect($details['rows'])->toHaveCount(1)
|
|
->and($details['rows'][0]['user_id'])->toBe($stark->id);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- A18
|
|
|
|
it('kennzeichnet Formelinhalte im Export als Text', function (string $value) {
|
|
$safe = (new ReflectionMethod(BackofficeStatisticsController::class, 'csvSafeRow'))
|
|
->invoke(null, [$value]);
|
|
|
|
expect($safe[0])->toBe("'".$value);
|
|
})->with(['=1+1', '+49 170 1234567', '-SUM(A1)', '@import']);
|
|
|
|
it('laesst unverfaengliche Werte im Export unveraendert', function () {
|
|
$safe = (new ReflectionMethod(BackofficeStatisticsController::class, 'csvSafeRow'))
|
|
->invoke(null, ['Anna Muster', 'anna@example.test', 1234.5, 0, '', null]);
|
|
|
|
expect($safe)->toBe(['Anna Muster', 'anna@example.test', 1234.5, 0, '', null]);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- A10
|
|
|
|
it('laedt die Lieferungszahl der Kundenabo-Liste vor', function () {
|
|
$user = scUser();
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$abo = UserAbo::create([
|
|
'user_id' => $user->id,
|
|
'member_id' => $user->id,
|
|
'shopping_user_id' => 0,
|
|
'email' => 'a-'.uniqid('', true).'@example.test',
|
|
'is_for' => 'ot',
|
|
'payone_userid' => 0,
|
|
'clearingtype' => 'cc',
|
|
'amount' => 5000,
|
|
'active' => true,
|
|
'status' => 2,
|
|
'abo_interval' => 5,
|
|
'start_date' => '2026-08-01',
|
|
'next_date' => '2026-10-01',
|
|
]);
|
|
|
|
UserAboOrder::create([
|
|
'user_abo_id' => $abo->id,
|
|
'shopping_order_id' => 1,
|
|
'status' => 2,
|
|
'paid' => true,
|
|
]);
|
|
}
|
|
|
|
$relations = (new ReflectionClass(AboController::class))->getConstant('CUSTOMER_LIST_RELATIONS');
|
|
|
|
$abos = UserAbo::with($relations)->where('member_id', $user->id)->where('is_for', 'ot')->get();
|
|
|
|
DB::enableQueryLog();
|
|
DB::flushQueryLog();
|
|
|
|
foreach ($abos as $abo) {
|
|
$abo->getCountPaidOrders();
|
|
}
|
|
|
|
$queries = count(DB::getQueryLog());
|
|
DB::disableQueryLog();
|
|
|
|
expect($abos)->toHaveCount(5)
|
|
->and($queries)->toBe(0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- A17
|
|
|
|
it('zaehlt am Monatsende nicht in die falsche Faelligkeitsgruppe', function () {
|
|
$service = app(AnnualFeeDueService::class);
|
|
$key = fn (string $due, string $now) => (new ReflectionMethod($service, 'groupKeyFor'))
|
|
->invoke($service, Carbon::parse($due), Carbon::parse($now));
|
|
|
|
// Vom 31.01. aus endet "innerhalb eines Monats" am 28.02., nicht am 03.03.
|
|
expect($key('2026-03-02', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_2_MONTHS)
|
|
->and($key('2026-02-28', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_1_MONTH)
|
|
->and($key('2026-03-31', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_2_MONTHS)
|
|
->and($key('2026-04-01', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_3_MONTHS);
|
|
});
|