Audit 18.09.: Zahlungsdatum nur bei Zahlung, Neupartner-IDs im Snapshot, Pausenende folgt dem Termin
F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der Backfill nimmt das Aktivierungsdatum nur noch ohne jede Registrierungsbestellung (offene Rechnung bleibt leer). F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel (Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig. F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit; eine Ausfuehrung loescht auch den paused_by_*-Grund. F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits committeten Test nachgezogen. F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false). Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal. Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b7e8bbd951
commit
03416b0afe
27 changed files with 560 additions and 21 deletions
|
|
@ -11,8 +11,9 @@ use Illuminate\Console\Command;
|
|||
* Einmaliger Nachtrag fuer Bestandsdaten (Ticket A3): fuellt `status_reason`
|
||||
* und `paused_until` aus Status, Pausenfeldern und dem Statusprotokoll
|
||||
* (`user_abo_status_histories`). Laesst bereits gefuellte Werte unberuehrt und
|
||||
* schreibt ueber Query-Builder, damit weder Model-Events noch `updated_at`
|
||||
* angefasst werden.
|
||||
* schreibt ueber den reinen Query-Builder (`toBase()`), damit weder Model-Events
|
||||
* noch `updated_at` angefasst werden — der Eloquent-Builder wuerde den
|
||||
* Zeitstempel mitschreiben (Audit 18.09., F5).
|
||||
*/
|
||||
class AboBackfillStatusReasons extends Command
|
||||
{
|
||||
|
|
@ -40,7 +41,7 @@ class AboBackfillStatusReasons extends Command
|
|||
$counts[$bucket]++;
|
||||
|
||||
if (! $dryRun) {
|
||||
UserAbo::withTrashed()->whereKey($abo->id)->update(['status_reason' => $reason]);
|
||||
UserAbo::withTrashed()->whereKey($abo->id)->toBase()->update(['status_reason' => $reason]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -63,7 +64,7 @@ class AboBackfillStatusReasons extends Command
|
|||
$counts['paused']++;
|
||||
|
||||
if (! $dryRun) {
|
||||
UserAbo::whereKey($abo->id)->update([
|
||||
UserAbo::whereKey($abo->id)->toBase()->update([
|
||||
'paused_until' => $until->toDateString(),
|
||||
'status_reason' => $abo->status_reason ?: UserAbo::REASON_PREFIX_PAUSED.$this->actorFromHistory($abo, UserAboStatusHistory::ACTION_PAUSED),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -758,6 +758,13 @@ class TeamController extends Controller
|
|||
'activeCount' => $firstlines
|
||||
->filter(fn (User $member) => $dashboardService->isActiveConsultant($member))
|
||||
->count(),
|
||||
// Dritte Zahl, weil die Zeilenmarkierung eine ANDERE Frage beantwortet
|
||||
// als die Kopfzahl: "Berater" heisst m_level + Abrechnungskonto gesetzt,
|
||||
// die Markierung zeigt die abgelaufene Jahresgebuehr. Ohne diese Zahl
|
||||
// ergaebe das Abzaehlen der unmarkierten Zeilen nie die Kopfzahl.
|
||||
'expiredCount' => $firstlines
|
||||
->reject(fn (User $member) => $member->isActiveAccount())
|
||||
->count(),
|
||||
'selectedMonth' => $month,
|
||||
'selectedYear' => $year,
|
||||
'filterMonths' => HTMLHelper::getTransMonths(),
|
||||
|
|
|
|||
|
|
@ -130,7 +130,8 @@ class UserAbo extends Model
|
|||
* Kanal des laufenden Formulars ({@see withStatusLogView()}), sonst `system`.
|
||||
* - Zurueck in den Lauf (2, 7, 8) => Grund geloescht.
|
||||
* - `last_date` aendert sich nur durch eine Ausfuehrung (Erfolg oder Fehler):
|
||||
* damit ist eine Pause vorbei, `paused_until` wird geloescht.
|
||||
* damit ist eine Pause vorbei, `paused_until` und ein `paused_by_*`-Grund
|
||||
* werden geloescht.
|
||||
*/
|
||||
protected function applyStatusReasonDefaults(): void
|
||||
{
|
||||
|
|
@ -146,6 +147,10 @@ class UserAbo extends Model
|
|||
|
||||
if ($this->isDirty('last_date') && ! $this->isDirty('paused_until')) {
|
||||
$this->paused_until = null;
|
||||
|
||||
if (! $this->isDirty('status_reason') && str_starts_with((string) $this->status_reason, self::REASON_PREFIX_PAUSED)) {
|
||||
$this->status_reason = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,11 @@ class AboRepository extends BaseRepository
|
|||
? $this->calculateAdminNextDate($data)
|
||||
: $this->calculateNewNextDate((int) $data['abo_interval']);
|
||||
$this->model->next_date = $nextDate;
|
||||
// Laufende Pause: ihr Ende ist der naechste Liefertermin — wandert
|
||||
// der, wandert das Pausenende mit (Audit 18.09., F3).
|
||||
if ($this->model->getRawOriginal('paused_until')) {
|
||||
$this->model->paused_until = $nextDate->copy()->startOfDay();
|
||||
}
|
||||
$this->model->save();
|
||||
|
||||
$daysUntilNext = AboHelper::calendarDaysUntil(now(), $nextDate);
|
||||
|
|
@ -148,6 +153,14 @@ class AboRepository extends BaseRepository
|
|||
return;
|
||||
}
|
||||
|
||||
// Die Folgen der Kuendigung (Ticket A4) muessen bestaetigt sein — auch
|
||||
// serverseitig, nicht nur ueber den deaktivierten Button (Audit 18.09.).
|
||||
if (! $isAdminUpdate && ! filter_var($data['abo_cancel_confirm'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
|
||||
\Session()->flash('alert-error', __('abo.cancel_confirm_required'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Status 4 = abo_cancel (storniert/gekündigt)
|
||||
$this->model->status = 4;
|
||||
$this->model->active = false;
|
||||
|
|
|
|||
|
|
@ -196,9 +196,32 @@ class AboStatusExplainer
|
|||
return $this->latestHistory($abo, $action)?->created_at?->copy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzter Protokolleintrag je Aktion — pro Abo und Aktion nur einmal geladen,
|
||||
* aus der vorgeladenen Relation, wenn vorhanden (Listen: `with('status_histories')`).
|
||||
*
|
||||
* @var array<string, UserAboStatusHistory|null>
|
||||
*/
|
||||
private array $historyMemo = [];
|
||||
|
||||
private function latestHistory(UserAbo $abo, string $action): ?UserAboStatusHistory
|
||||
{
|
||||
return $abo->status_histories()->where('action', $action)->orderByDesc('id')->first();
|
||||
$key = $abo->id.':'.$action;
|
||||
|
||||
if (array_key_exists($key, $this->historyMemo)) {
|
||||
return $this->historyMemo[$key];
|
||||
}
|
||||
|
||||
if ($abo->relationLoaded('status_histories')) {
|
||||
$entry = $abo->status_histories
|
||||
->filter(fn (UserAboStatusHistory $history) => $history->action === $action)
|
||||
->sortByDesc('id')
|
||||
->first();
|
||||
} else {
|
||||
$entry = $abo->status_histories()->where('action', $action)->orderByDesc('id')->first();
|
||||
}
|
||||
|
||||
return $this->historyMemo[$key] = $entry;
|
||||
}
|
||||
|
||||
private function actorLabel(?string $actor): string
|
||||
|
|
|
|||
|
|
@ -611,13 +611,18 @@ class BackofficeDashboardService
|
|||
$customerAboIds = $this->aboIds($userIds, 'member_id', 'ot');
|
||||
$partnerAboIdsNew = $this->aboIds($userIds, 'user_id', 'me', $month, $year);
|
||||
$customerAboIdsNew = $this->aboIds($userIds, 'member_id', 'ot', $month, $year);
|
||||
// Neupartner ebenfalls als IDs festhalten: die Regel (Zahlungsmonat seit
|
||||
// 16.09.2026) kann sich aendern, die Liste hinter der eingefrorenen Kachel
|
||||
// muss trotzdem genau die damals gezaehlten Partner zeigen (Audit 18.09., F2).
|
||||
$newPartnerIds = $this->newPartnerIds($users, $month, $year);
|
||||
|
||||
return [
|
||||
'line' => $line,
|
||||
'label' => 'Linie '.$line,
|
||||
'user_ids' => $userIds,
|
||||
'consultants' => $this->activeConsultants($users),
|
||||
'new_partners' => $this->newPartners($users, $month, $year),
|
||||
'new_partners' => count($newPartnerIds),
|
||||
'new_partner_ids' => $newPartnerIds,
|
||||
'team_partner_abos' => count($partnerAboIds),
|
||||
'team_partner_abos_new' => count($partnerAboIdsNew),
|
||||
'team_customer_abos' => count($customerAboIds),
|
||||
|
|
@ -701,10 +706,22 @@ class BackofficeDashboardService
|
|||
* @param Collection<int, User> $users
|
||||
*/
|
||||
private function newPartners(Collection $users, int $month, int $year): int
|
||||
{
|
||||
return count($this->newPartnerIds($users, $month, $year));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, User> $users
|
||||
* @return int[]
|
||||
*/
|
||||
private function newPartnerIds(Collection $users, int $month, int $year): array
|
||||
{
|
||||
return $users
|
||||
->filter(fn (User $user) => $user->isNewPartnerIn($month, $year))
|
||||
->count();
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function activeAboQuery(): Builder
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class BackofficeDrilldownService
|
|||
|
||||
$rows = match ($metric) {
|
||||
'consultants' => $this->consultantRows($userIds),
|
||||
'new_partners' => $this->newPartnerRows($userIds, $month, $year),
|
||||
'new_partners' => $this->newPartnerRows($userIds, $month, $year, $frozen !== null ? $this->frozenNewPartnerIds($frozen, $line) : null, $frozen !== null),
|
||||
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
|
||||
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
|
||||
'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric),
|
||||
|
|
@ -149,13 +149,19 @@ class BackofficeDrilldownService
|
|||
* @param int[] $userIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function newPartnerRows(array $userIds, int $month, int $year): array
|
||||
private function newPartnerRows(array $userIds, int $month, int $year, ?array $frozenIds = null, bool $frozen = false): array
|
||||
{
|
||||
// Eingefrorener Monat: exakt die im Snapshot gezaehlten Partner. Aeltere
|
||||
// Snapshots ohne gespeicherte IDs wurden mit der Regel "Registrierungsmonat"
|
||||
// gebaut — die Liste rechnet dann mit derselben Regel, sonst zeigt die
|
||||
// Kachel 1 und die Liste 0 (Audit 18.09., F2).
|
||||
$partners = User::query()
|
||||
->with(['account', 'user_level'])
|
||||
->whereIn('id', $userIds)
|
||||
->newPartnerIn($month, $year)
|
||||
->whereIn('id', $frozenIds ?? $userIds)
|
||||
->when($frozenIds === null && $frozen, fn (Builder $query) => $query->newPartnerInByActiveDate($month, $year))
|
||||
->when($frozenIds === null && ! $frozen, fn (Builder $query) => $query->newPartnerIn($month, $year))
|
||||
->orderBy('partner_paid_at')
|
||||
->orderBy('active_date')
|
||||
->get();
|
||||
|
||||
$sponsors = User::query()
|
||||
|
|
@ -274,6 +280,34 @@ class BackofficeDrilldownService
|
|||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Neupartner einer Linie zum Monatsabschluss; null, wenn der Snapshot die IDs
|
||||
* noch nicht kennt (gebaut vor dem 18.09.2026) — dann gilt die damalige Regel.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $frozen
|
||||
* @return int[]|null
|
||||
*/
|
||||
private function frozenNewPartnerIds(array $frozen, int $line): ?array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
foreach ($frozen as $index => $row) {
|
||||
if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists('new_partner_ids', $row)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($row['new_partner_ids'] as $id) {
|
||||
$ids[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Abos einer Linie zum Monatsabschluss; Linie 0 = das ganze Team. Gibt es zu der
|
||||
* Kennzahl keine eingefrorene Liste, wird null zurueckgegeben (Live-Auswertung).
|
||||
|
|
|
|||
|
|
@ -227,7 +227,13 @@ class Payment
|
|||
$user->wizard = 100;
|
||||
// Erste Kontozahlung = Zeitpunkt, ab dem der Partner in der
|
||||
// Neupartner-Statistik zaehlt. Verlaengerungen aendern ihn nicht.
|
||||
UserUtil::markPartnerPaid($user);
|
||||
// Nur bei bestaetigter Zahlung (Audit 18.09., F1): der Rechnungskauf
|
||||
// ruft diesen Pfad mit $paid = false auf und aktiviert das Konto
|
||||
// vorab — bezahlt ist er erst, wenn die Rechnung beglichen wird
|
||||
// (ManualPaymentService, dann $paid = true).
|
||||
if ($paid) {
|
||||
UserUtil::markPartnerPaid($user);
|
||||
}
|
||||
// only date is > now and acount is deactive.
|
||||
if ($date > \Carbon::now()) {
|
||||
if ($user->active === 0) {
|
||||
|
|
|
|||
|
|
@ -301,8 +301,13 @@ class UserUtil
|
|||
* 1. die Punktebuchung der ersten bezahlten Registrierungsbestellung
|
||||
* (`payment_for = 1`) — sie entsteht im Moment der Zahlung;
|
||||
* 2. ersatzweise das Aenderungsdatum dieser Bestellung;
|
||||
* 3. ohne Registrierungsbestellung (Konto vom Admin freigeschaltet) das
|
||||
* Aktivierungsdatum — einen Zahlungszeitpunkt gibt es dort nicht.
|
||||
* 3. ohne jede Registrierungsbestellung (Konto vom Admin freigeschaltet) das
|
||||
* Aktivierungsdatum — einen Zahlungszeitpunkt gibt es dort nicht. Gibt es
|
||||
* eine Registrierungsbestellung, die nur noch nicht bezahlt ist (offene
|
||||
* Rechnung), bleibt der Wert leer: er wird bei der Zahlung gesetzt
|
||||
* (Audit 18.09., F1).
|
||||
*
|
||||
* Schreibt ohne Events und ohne `updated_at` anzufassen (Audit 18.09., F5).
|
||||
*
|
||||
* Absichtlich NICHT `payment_account` minus ein Jahr: bei Verlaengerungen
|
||||
* wird das Konto vom alten Ablaufdatum aus verlaengert, das Ergebnis waere der
|
||||
|
|
@ -320,15 +325,20 @@ class UserUtil
|
|||
->orderBy('id')
|
||||
->chunkById(200, function ($users) use (&$set): void {
|
||||
foreach ($users as $user) {
|
||||
$paidAt = self::firstRegistrationPaidAt((int) $user->id)
|
||||
?? ($user->active_date ? Carbon::parse($user->active_date) : null);
|
||||
$paidAt = self::firstRegistrationPaidAt((int) $user->id);
|
||||
|
||||
if ($paidAt === null && ! self::hasRegistrationOrder((int) $user->id)) {
|
||||
$paidAt = $user->active_date ? Carbon::parse($user->active_date) : null;
|
||||
}
|
||||
|
||||
if ($paidAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user->partner_paid_at = $paidAt;
|
||||
$user->timestamps = false;
|
||||
$user->saveQuietly();
|
||||
$user->timestamps = true;
|
||||
$set++;
|
||||
}
|
||||
});
|
||||
|
|
@ -336,6 +346,14 @@ class UserUtil
|
|||
return $set;
|
||||
}
|
||||
|
||||
private static function hasRegistrationOrder(int $userId): bool
|
||||
{
|
||||
return ShoppingOrder::query()
|
||||
->where('auth_user_id', $userId)
|
||||
->where('payment_for', 1)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private static function firstRegistrationPaidAt(int $userId): ?Carbon
|
||||
{
|
||||
$order = ShoppingOrder::query()
|
||||
|
|
|
|||
16
app/User.php
16
app/User.php
|
|
@ -274,6 +274,22 @@ class User extends Authenticatable
|
|||
->whereBetween('partner_paid_at', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neupartner-Regel bis 16.09.2026 (Registrierungsdatum statt Zahlungsdatum).
|
||||
* Nur noch fuer eingefrorene Snapshots ohne gespeicherte Neupartner-IDs, damit
|
||||
* die verlinkte Liste dieselben Partner zeigt wie die damals gezaehlte Kachel
|
||||
* (Audit 18.09., F2).
|
||||
*/
|
||||
public function scopeNewPartnerInByActiveDate($query, int $month, int $year)
|
||||
{
|
||||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
|
||||
return $query->whereNotNull('m_level')
|
||||
->whereNotNull('payment_account')
|
||||
->where('payment_account', '>', self::newPartnerReferenceDate($month, $year))
|
||||
->whereBetween('active_date', [$startDate, $startDate->copy()->endOfMonth()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP-seitiges Gegenstück zu {@see scopeNewPartnerIn()} für bereits geladene
|
||||
* User-Collections (Linien-Auswertung, Recruiter-Ranking) — damit die
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue