10.April 2026
This commit is contained in:
parent
a00c42e770
commit
f58c709945
208 changed files with 19280 additions and 2914 deletions
|
|
@ -8,12 +8,19 @@ use App\Models\ShoppingPayment;
|
|||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboItem;
|
||||
use App\Models\UserAboItemHistory;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AboHelper
|
||||
{
|
||||
/**
|
||||
* Mindestabstand (Kalendertage) vom Bestell-/Referenzdatum bis zur ersten Abo-Ausführung.
|
||||
*/
|
||||
public const MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10;
|
||||
|
||||
public static $txaction_filter_text = [
|
||||
'paid' => 'paymend_paid',
|
||||
'appointed' => 'paymend_open',
|
||||
|
|
@ -50,9 +57,19 @@ class AboHelper
|
|||
public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false)
|
||||
{
|
||||
$user_abo = $shopping_order->getUserAbo();
|
||||
if ($user_abo && $user_abo->status < 2) { // status < 2 is not active
|
||||
$user_abo->update(['status' => $status]);
|
||||
if ($user_abo) {
|
||||
// Neuaktivierung nach erfolgreicher Zahlung (z. B. Payone paid): immer wieder auf abo_okay (2),
|
||||
// auch wenn das Abo vorher abo_hold (3) war (z. B. Cron-Zahlung fehlgeschlagen, spaeter bezahlt).
|
||||
if ($paid && (int) $status === 2) {
|
||||
$user_abo->update(['status' => 2]);
|
||||
} elseif ($user_abo->status < 2) {
|
||||
$user_abo->update(['status' => $status]);
|
||||
}
|
||||
}
|
||||
if (! $user_abo) {
|
||||
return;
|
||||
}
|
||||
|
||||
UserAboOrder::where('user_abo_id', $user_abo->id)->where('shopping_order_id', $shopping_order->id)->update(['status' => $status, 'paid' => $paid]);
|
||||
}
|
||||
|
||||
|
|
@ -153,47 +170,111 @@ class AboHelper
|
|||
|
||||
public static function getFirstAboDate($date, $abo_interval)
|
||||
{
|
||||
$nextDate = Carbon::parse($date)->firstOfMonth()->addMonth(1);
|
||||
$nextDate->addDays($abo_interval - 1);
|
||||
$reference = Carbon::parse($date)->startOfDay();
|
||||
$candidate = self::computeFirstAboCandidateWithoutMinDays($reference, (int) $abo_interval);
|
||||
|
||||
return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1);
|
||||
while ($reference->diffInDays($candidate->copy()->startOfDay(), true) < self::MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION) {
|
||||
$candidate = self::advanceAboCandidateOneMonth($candidate, (int) $abo_interval);
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kalendertage von $from bis $to (nur Datum, ohne Uhrzeit).
|
||||
* Verhindert Abweichungen, wenn {@see now()} eine Tageszeit hat und Carbon {@see diffInDays} in 24h-Schritten zählt.
|
||||
*/
|
||||
public static function calendarDaysUntil(Carbon|string $from, Carbon $to): int
|
||||
{
|
||||
$start = Carbon::parse($from)->startOfDay();
|
||||
$end = $to->copy()->startOfDay();
|
||||
|
||||
return (int) $start->diffInDays($end, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erste mögliche Ausführung (nächster Monat, gewählter Liefertag) ohne Mindestabstand-Regel.
|
||||
*/
|
||||
private static function computeFirstAboCandidateWithoutMinDays(Carbon $reference, int $aboDayOfMonth): Carbon
|
||||
{
|
||||
$nextDate = $reference->copy()->firstOfMonth()->addMonth(1);
|
||||
$nextDate->addDays($aboDayOfMonth - 1);
|
||||
|
||||
if (! $nextDate->gt($reference)) {
|
||||
$nextDate->addMonth(1);
|
||||
}
|
||||
|
||||
return $nextDate->copy()->startOfDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleicher Liefertag im Folgemonat (Monatsende beachten).
|
||||
*/
|
||||
private static function advanceAboCandidateOneMonth(Carbon $candidate, int $aboDayOfMonth): Carbon
|
||||
{
|
||||
$next = $candidate->copy()->addMonthNoOverflow();
|
||||
$dim = $next->daysInMonth;
|
||||
$day = min($aboDayOfMonth, $dim);
|
||||
|
||||
return $next->day($day)->startOfDay();
|
||||
}
|
||||
|
||||
public static function createNewAbo(ShoppingPayment $shopping_payment)
|
||||
{
|
||||
// is Abo - create init Abo from PP or else
|
||||
if ($shopping_payment->shopping_order->is_abo && $shopping_payment->shopping_order->abo_interval > 0) {
|
||||
$payment_transaction = $shopping_payment->payment_transactions->last();
|
||||
$order = $shopping_payment->shopping_order;
|
||||
if (! $order || ! $order->is_abo || (int) $order->abo_interval <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// next_date immer im nächsten Monat starten
|
||||
// is auth_user_id = Berater bestellung
|
||||
// is member_id = Kunden bestellung
|
||||
// is for = me = mich oder ot = kunde
|
||||
$user_abo = UserAbo::create([
|
||||
'user_id' => $shopping_payment->shopping_order->auth_user_id,
|
||||
'member_id' => $shopping_payment->shopping_order->member_id,
|
||||
'shopping_user_id' => $shopping_payment->shopping_order->shopping_user_id,
|
||||
'email' => $shopping_payment->shopping_order->shopping_user->billing_email,
|
||||
'is_for' => $shopping_payment->shopping_order->shopping_user->is_for,
|
||||
'payone_userid' => $payment_transaction->userid,
|
||||
'clearingtype' => $shopping_payment->clearingtype,
|
||||
'wallettype' => $shopping_payment->wallettype,
|
||||
'carddata' => $shopping_payment->carddata,
|
||||
'amount' => $shopping_payment->amount,
|
||||
// Bereits verknüpft (z. B. Checkout-Erfolgsseite vor Callback) oder wiederholter Aufruf
|
||||
if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$aboInterval = (int) ($shopping_payment->abo_interval ?? $order->abo_interval);
|
||||
if ($aboInterval <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payment_transaction = $shopping_payment->payment_transactions->last();
|
||||
$payoneUserId = $payment_transaction ? (int) $payment_transaction->userid : 0;
|
||||
|
||||
// next_date immer im nächsten Monat starten
|
||||
// is auth_user_id = Berater bestellung
|
||||
// is member_id = Kunden bestellung
|
||||
// is for = me = mich oder ot = kunde
|
||||
$user_abo = UserAbo::create([
|
||||
'user_id' => $order->auth_user_id,
|
||||
'member_id' => $order->member_id,
|
||||
'shopping_user_id' => $order->shopping_user_id,
|
||||
'email' => $order->shopping_user->billing_email,
|
||||
'is_for' => $order->shopping_user->is_for,
|
||||
'payone_userid' => $payoneUserId,
|
||||
'clearingtype' => $shopping_payment->clearingtype,
|
||||
'wallettype' => $shopping_payment->wallettype,
|
||||
'carddata' => $shopping_payment->carddata,
|
||||
'amount' => $shopping_payment->amount,
|
||||
'status' => 1,
|
||||
'abo_interval' => $aboInterval,
|
||||
'start_date' => now(),
|
||||
'last_date' => now(),
|
||||
'next_date' => self::getFirstAboDate(now(), $aboInterval),
|
||||
]);
|
||||
|
||||
if ($user_abo) {
|
||||
self::createAboItems($user_abo, $shopping_payment);
|
||||
UserAboOrder::create([
|
||||
'user_abo_id' => $user_abo->id,
|
||||
'shopping_order_id' => $shopping_payment->shopping_order_id,
|
||||
'status' => 1,
|
||||
'abo_interval' => $shopping_payment->abo_interval,
|
||||
'start_date' => now(),
|
||||
'last_date' => now(),
|
||||
'next_date' => self::getFirstAboDate(now(), $shopping_payment->abo_interval),
|
||||
]);
|
||||
|
||||
if ($user_abo) {
|
||||
self::createAboItems($user_abo, $shopping_payment);
|
||||
UserAboOrder::create([
|
||||
'user_abo_id' => $user_abo->id,
|
||||
'shopping_order_id' => $shopping_payment->shopping_order_id,
|
||||
'status' => 1,
|
||||
]);
|
||||
// Payone-Status-URL kann vor dem Checkout-Redirect laufen: dann existierte
|
||||
// noch kein UserAboOrder → Payment::paymentStatusPaidAction → trackAboActivated ohne Wirkung.
|
||||
// Nach Anlage hier erneut versuchen, wenn die Bestellung bereits als bezahlt gilt.
|
||||
$shopping_payment->shopping_order->refresh();
|
||||
if ($shopping_payment->shopping_order->paid) {
|
||||
IncentiveTracker::trackAboActivated($shopping_payment->shopping_order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -214,6 +295,57 @@ class AboHelper
|
|||
AboItemHistoryService::logInitialCreation($user_abo, 'system');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt Abo-Artikel aus der letzten Bestellung mit Positionen wieder her, wenn user_abo_items leer sind
|
||||
* (z. B. manuell angelegtes Abo ohne Checkout-AboItem-Anlage).
|
||||
*/
|
||||
public static function ensureUserAboItemsFromLatestOrder(UserAbo $userAbo): bool
|
||||
{
|
||||
if ($userAbo->user_abo_items()->exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$userAboOrders = $userAbo->user_abo_orders()
|
||||
->orderByDesc('id')
|
||||
->with(['shopping_order.shopping_order_items'])
|
||||
->get();
|
||||
|
||||
$order = null;
|
||||
foreach ($userAboOrders as $link) {
|
||||
$shoppingOrder = $link->shopping_order;
|
||||
if ($shoppingOrder && $shoppingOrder->shopping_order_items->isNotEmpty()) {
|
||||
$order = $shoppingOrder;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $order) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($order->shopping_order_items as $item) {
|
||||
UserAboItem::create([
|
||||
'user_abo_id' => $userAbo->id,
|
||||
'product_id' => $item->product_id,
|
||||
'comp' => $item->comp ?? 0,
|
||||
'qty' => $item->qty,
|
||||
'status' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$userAbo->unsetRelation('user_abo_items');
|
||||
|
||||
if (! UserAboItemHistory::query()
|
||||
->where('user_abo_id', $userAbo->id)
|
||||
->where('is_initial', true)
|
||||
->exists()) {
|
||||
$userAbo->load('user_abo_items');
|
||||
AboItemHistoryService::logInitialCreation($userAbo, 'system');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getTransStatusFilterText()
|
||||
{
|
||||
$ret = [];
|
||||
|
|
@ -287,4 +419,79 @@ class AboHelper
|
|||
|
||||
return array_values(array_unique($teamUserIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Anzahl aktiver Abos pro Monat für ein gegebenes Jahr.
|
||||
* Ein Abo gilt als aktiv in Monat M wenn:
|
||||
* - start_date <= letzter Tag von M
|
||||
* - cancel_date ist NULL oder >= erster Tag von M
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query Basis-Query (gefiltert nach User/Team etc.)
|
||||
* @param int $year Jahr für die Berechnung
|
||||
* @return int[] Array mit 12 Einträgen (Index 0 = Januar, 11 = Dezember)
|
||||
*/
|
||||
/**
|
||||
* Liefert die Abo-Zählung pro Monat für ein Jahr.
|
||||
*
|
||||
* Vergangene Monate → aus DB-Snapshot (eingefroren, unabhängig von Strukturänderungen).
|
||||
* Aktueller Monat → live berechnet.
|
||||
* Zukünftige Monate → null (kein Balken im Chart).
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $liveQuery Basis-Query für den aktuellen Monat
|
||||
* @param string $scope 'ot' | 'team_abos' | 'team_cust_abos'
|
||||
* @param int $userId Eingeloggter Berater
|
||||
* @return array<int, int|null> 12 Einträge (Index 0 = Jan), null = Zukunft
|
||||
*/
|
||||
public static function getMonthlyAboCounts(
|
||||
\Illuminate\Database\Eloquent\Builder $liveQuery,
|
||||
int $year,
|
||||
string $scope,
|
||||
int $userId
|
||||
): array {
|
||||
$data = [];
|
||||
$now = Carbon::now();
|
||||
$currentYear = (int) $now->year;
|
||||
$currentMonth = (int) $now->month;
|
||||
$lastCountableMonth = ($year === $currentYear) ? $currentMonth : 12;
|
||||
|
||||
// Alle vorhandenen Snapshots für diesen User/Scope/Jahr auf einmal laden
|
||||
$snapshots = \App\Models\AboChartSnapshot::where('user_id', $userId)
|
||||
->where('scope', $scope)
|
||||
->where('year', $year)
|
||||
->get()
|
||||
->keyBy('month');
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
if ($month > $lastCountableMonth) {
|
||||
$data[] = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$isPastMonth = $year < $currentYear || ($year === $currentYear && $month < $currentMonth);
|
||||
|
||||
if ($isPastMonth && $snapshots->has($month)) {
|
||||
// Eingefroren – aus DB
|
||||
$data[] = $snapshots->get($month)->count;
|
||||
} else {
|
||||
// Aktueller Monat oder noch kein Snapshot → live
|
||||
$startOfMonth = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
$terminalStatuses = [4, 5];
|
||||
|
||||
$data[] = (clone $liveQuery)
|
||||
->whereDate('start_date', '<=', $endOfMonth)
|
||||
->where(function ($q) use ($startOfMonth, $terminalStatuses) {
|
||||
$q->whereDate('cancel_date', '>=', $startOfMonth)
|
||||
->orWhere(function ($q2) use ($terminalStatuses) {
|
||||
$q2->whereNull('cancel_date')
|
||||
->whereNotIn('status', $terminalStatuses);
|
||||
});
|
||||
})
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue