Abo Einmalprodukte und Bestätigung abschließen

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin 2026-06-05 15:28:08 +00:00
parent 2bdc9ada3c
commit 2269ce031f
57 changed files with 3647 additions and 371 deletions

View file

@ -21,6 +21,12 @@ class AboHelper
*/
public const MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10;
/**
* Standard-Zeitfenster (Kalendertage) vor der Ausführung, in dem einmalige
* Produkte aus dem normalen Sortiment hinzugefügt werden dürfen.
*/
public const DEFAULT_ONETIME_WINDOW_DAYS = 4;
public static $txaction_filter_text = [
'paid' => 'paymend_paid',
'appointed' => 'paymend_open',
@ -168,6 +174,41 @@ class AboHelper
return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1);
}
/**
* Konfiguriertes Zeitfenster (Kalendertage) für einmalige Produkte vor der Ausführung.
*/
public static function getOneTimeWindowDays(): int
{
$days = (int) \App\Models\Setting::getContentBySlug('abo-onetime-window-days');
return $days > 0 ? $days : self::DEFAULT_ONETIME_WINDOW_DAYS;
}
/**
* Prüft, ob für dieses Abo aktuell einmalig Produkte aus dem normalen
* Bestellsortiment hinzugefügt werden dürfen (Zeitfenster vor der Ausführung).
*
* Nur die zeitliche Bedingung wird geprüft. Aufrufer sollten zusätzlich den
* Abo-Zustand (active/status) berücksichtigen, falls relevant.
*/
public static function isOneTimeWindowOpen(UserAbo $userAbo): bool
{
if (! $userAbo->next_date) {
return false;
}
$today = Carbon::today();
$nextDate = Carbon::parse($userAbo->next_date)->startOfDay();
if ($nextDate->lt($today)) {
return false;
}
$daysUntilExecution = $today->diffInDays($nextDate);
return $daysUntilExecution <= self::getOneTimeWindowDays();
}
public static function getFirstAboDate($date, $abo_interval)
{
$reference = Carbon::parse($date)->startOfDay();

View file

@ -0,0 +1,181 @@
<?php
namespace App\Services;
use App\Models\Product;
use App\Models\UserAbo;
use App\Models\UserAboOneTimeItem;
class AboOneTimeService
{
/**
* Verarbeitet eine Einmal-Artikel-Aktion (add/update/remove) für ein Abo.
*
* Erwartet einen bereits initialisierten Yard (für die Snapshot-Berechnung beim
* Hinzufügen). Gibt eine Fehlermeldung zurück oder null bei Erfolg.
*
* @param array<string, mixed> $data
*/
public function handleAction(UserAbo $userAbo, array $data): ?string
{
$action = $data['action'] ?? null;
return match ($action) {
'add' => $this->add($userAbo, (int) ($data['product_id'] ?? 0)),
'update' => $this->update($userAbo, (int) ($data['one_time_item_id'] ?? 0), (int) ($data['qty'] ?? 1)),
'remove' => $this->remove($userAbo, (int) ($data['one_time_item_id'] ?? 0)),
'confirm' => $this->confirm($userAbo),
'discard' => $this->discard($userAbo),
default => __('abo.onetime_action_invalid'),
};
}
private function add(UserAbo $userAbo, int $productId): ?string
{
$product = Product::find($productId);
$allowedIds = ProductOrderContext::allowedShowOnIds(false, $userAbo->is_for === 'me' ? 'me' : 'ot-customer');
if (! $product || ! $product->active || ! ProductOrderContext::productMatchesShowOn($product, $allowedIds)) {
return __('abo.onetime_product_not_allowed');
}
if (AboOrderCart::exceedsMaxWeight($userAbo, (int) $product->weight)) {
return __('msg.cart_max_weight_reached');
}
$existing = UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->where('product_id', $product->id)
->first();
if ($existing) {
if ($existing->trashed()) {
$existing->restore();
$existing->qty = $existing->confirmed_at ? max(1, (int) $existing->confirmed_qty) + 1 : 1;
} else {
$existing->qty = min(100, $existing->qty + 1);
}
$existing->qty = min(100, $existing->qty);
$existing->status = 0;
$existing->save();
return null;
}
$snapshot = AboOrderCart::buildOneTimeSnapshot($product, $userAbo);
UserAboOneTimeItem::create(array_merge($snapshot, [
'user_abo_id' => $userAbo->id,
'product_id' => $product->id,
'comp' => 0,
'qty' => 1,
'status' => 0,
]));
return null;
}
private function update(UserAbo $userAbo, int $itemId, int $qty): ?string
{
$item = UserAboOneTimeItem::where('user_abo_id', $userAbo->id)->find($itemId);
if (! $item) {
return __('abo.abo_item_not_found');
}
$targetQty = max(1, min(100, $qty));
$product = Product::find($item->product_id);
if ($product) {
$additionalWeight = (int) $product->weight * ($targetQty - (int) $item->qty);
if ($additionalWeight > 0 && AboOrderCart::exceedsMaxWeight($userAbo, $additionalWeight)) {
return __('msg.cart_max_weight_reached');
}
}
$item->qty = $targetQty;
$item->status = $item->isConfirmed() ? 1 : 0;
$item->save();
return null;
}
private function remove(UserAbo $userAbo, int $itemId): ?string
{
$item = UserAboOneTimeItem::where('user_abo_id', $userAbo->id)->find($itemId);
if (! $item) {
return __('abo.abo_item_not_found');
}
if ($item->confirmed_at) {
$item->delete();
} else {
$item->forceDelete();
}
return null;
}
private function confirm(UserAbo $userAbo): ?string
{
UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->get()
->each(function (UserAboOneTimeItem $item): void {
if ($item->trashed()) {
$item->forceDelete();
return;
}
$item->confirmed_qty = $item->qty;
$item->confirmed_at = now();
$item->status = 1;
$item->save();
});
return null;
}
private function discard(UserAbo $userAbo): ?string
{
UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->get()
->each(function (UserAboOneTimeItem $item): void {
if (! $item->confirmed_at) {
$item->forceDelete();
return;
}
if ($item->trashed()) {
$item->restore();
}
$item->qty = max(1, (int) $item->confirmed_qty);
$item->status = 1;
$item->save();
});
return null;
}
public static function hasUnconfirmedChanges(UserAbo $userAbo): bool
{
return UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->get()
->contains(function (UserAboOneTimeItem $item): bool {
if ($item->trashed()) {
return $item->confirmed_at !== null;
}
return ! $item->isConfirmed();
});
}
public static function hasConfirmedItems(UserAbo $userAbo): bool
{
return UserAboOneTimeItem::where('user_abo_id', $userAbo->id)
->whereNotNull('confirmed_at')
->exists();
}
}

View file

@ -5,18 +5,66 @@ namespace App\Services;
use App\Models\Product;
use App\Models\ShippingCountry;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\User;
use Yard;
class AboOrderCart
{
/**
* Eigene Warenkorb-Instanz für den Abo-Detail-/Bearbeitungs-Flow.
* Bewusst getrennt von der regulären Bestell-Instanz ('shopping') und der
* Abo-Anlage-Instanz ('subscription'), damit sich die Warenkörbe über
* mehrere Browser-Fenster/Tabs hinweg nicht vermischen.
*/
public const INSTANCE = 'abo';
private static $user_abo;
private static $is_for;
private static $customer_detail;
/**
* Gesamtgewicht (in Gramm) aller regulären Abo-Artikel und Einmal-Artikel des Abos.
* Comp-Produkte zählen nicht (Gewicht 0).
*/
public static function combinedWeight(UserAbo $user_abo): int
{
$weight = 0;
foreach ($user_abo->user_abo_items as $item) {
if ($item->comp) {
continue;
}
if ($item->product) {
$weight += (int) $item->product->weight * (int) $item->qty;
}
}
foreach ($user_abo->one_time_items()->get() as $item) {
$product = Product::find($item->product_id);
if ($product) {
$weight += (int) $product->weight * (int) $item->qty;
}
}
return $weight;
}
/**
* Prüft, ob das kombinierte Abo-Gewicht zzgl. eines Zusatzgewichts das Maximalgewicht
* des Versandlandes überschreiten würde. Setzt einen über initYard() initialisierten Yard voraus.
*/
public static function exceedsMaxWeight(UserAbo $user_abo, int $additionalWeight = 0): bool
{
$maxWeight = Yard::instance(self::INSTANCE)->getMaxWeight();
if ($maxWeight <= 0) {
return false;
}
return (self::combinedWeight($user_abo) + $additionalWeight) > $maxWeight;
}
public static function initYard($user_abo)
{
@ -26,8 +74,11 @@ class AboOrderCart
self::$is_for = null;
self::$customer_detail = null;
// Sicherstellen, dass UserService den Abo-Yard initialisiert (nicht 'shopping')
UserService::setInstance(self::INSTANCE);
// Yard komplett leeren - wichtig für Batch-Verarbeitung mehrerer Abos
$yard = Yard::instance('shopping');
$yard = Yard::instance(self::INSTANCE);
$itemsBeforeDestroy = $yard->content()->count();
$yard->destroy();
@ -81,7 +132,7 @@ class AboOrderCart
// WICHTIG: Yard IMMER leeren, um sicherzustellen, dass keine Produkte aus vorherigen Aufrufen vorhanden sind
// Dies ist besonders wichtig bei wiederholten Aufrufen der detail-Funktion (z.B. durch AJAX-Requests)
$yard = Yard::instance('shopping');
$yard = Yard::instance(self::INSTANCE);
$itemsBefore = $yard->content()->count();
$yard->destroy();
@ -113,9 +164,9 @@ class AboOrderCart
foreach ($abo_items as $abo_item) {
self::addProductToCart($abo_item);
}
Yard::instance('shopping')->reCalculateShippingPrice();
Yard::instance(self::INSTANCE)->reCalculateShippingPrice();
$user_abo->amount = Yard::instance('shopping')->totalWithShipping(2, '.', '') * 100;
$user_abo->amount = Yard::instance(self::INSTANCE)->totalWithShipping(2, '.', '') * 100;
$user_abo->save();
}
@ -123,12 +174,12 @@ class AboOrderCart
{
$product = Product::find($item->product_id);
$tax_free = Yard::instance('shopping')->getUserTaxFree();
$user_country = Yard::instance('shopping')->getUserCountry();
$tax_free = Yard::instance(self::INSTANCE)->getUserTaxFree();
$user_country = Yard::instance(self::INSTANCE)->getUserCountry();
if ($product) {
if ($item->comp) {
$cartItem = Yard::instance('shopping')->add(
$cartItem = Yard::instance(self::INSTANCE)->add(
$product->id,
$product->getLang('name'),
1,
@ -149,7 +200,7 @@ class AboOrderCart
return true;
}
if (self::$is_for === 'ot-customer' || self::$is_for === 'abo-ot-customer') {
$cartItem = Yard::instance('shopping')
$cartItem = Yard::instance(self::INSTANCE)
->add(
$product->id,
$product->getLang('name'),
@ -160,7 +211,7 @@ class AboOrderCart
['image' => '', 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on]
);
} else {
$cartItem = Yard::instance('shopping')
$cartItem = Yard::instance(self::INSTANCE)
->add(
$product->id,
$product->getLang('name'),
@ -179,44 +230,195 @@ class AboOrderCart
}
}
/**
* Lädt die einmalig hinzugefügten Produkte (normales Bestellsortiment) zusätzlich
* zu den Abo-Produkten in denselben Yard mit dem eingefrorenen Snapshot-Preis und
* der Cart-Option `abo_addon = true`. Anschließend wird der Versand über das
* Gesamtgewicht neu berechnet.
*
* Wichtig: Diese Methode verändert NICHT `user_abos.amount` (reiner Abo-Betrag).
* Sie muss nach {@see self::makeOrderYard()} aufgerufen werden.
*/
public static function addOneTimeItemsToYard(UserAbo $user_abo): void
{
self::$user_abo = $user_abo;
self::$is_for = $user_abo->is_for === 'me' ? 'abo-me' : 'abo-ot-customer';
$yard = Yard::instance(self::INSTANCE);
foreach ($user_abo->one_time_items()->get() as $item) {
$product = Product::find($item->product_id);
if (! $product) {
continue;
}
$cartItem = $yard->add(
$product->id,
$product->getLang('name'),
$item->qty,
(float) $item->price,
false,
false,
[
'image' => '',
'slug' => $product->slug,
'weight' => $product->weight,
'points' => $item->points,
'no_commission' => $product->no_commission,
'no_free_shipping' => $product->no_free_shipping,
'show_on' => $product->show_on,
'comp' => 0,
'abo_addon' => true,
'one_time_item_id' => $item->id,
'product_id' => $product->id,
]
);
Yard::setTax($cartItem->rowId, (float) $item->tax_rate);
}
$yard->reCalculateShippingPrice();
}
/**
* Erstellt einen Preis-Snapshot für ein einmalig hinzugefügtes Produkt, konsistent
* zur Cart-Preisberechnung in {@see self::addProductToCart()}.
*
* @return array{price: float, price_net: float, tax_rate: float, tax: float, price_vk_net: float, discount: float, points: int}
*/
public static function buildOneTimeSnapshot(Product $product, UserAbo $user_abo): array
{
$yard = Yard::instance(self::INSTANCE);
$tax_free = $yard->getUserTaxFree();
$country = $yard->getUserCountry();
$is_me = $user_abo->is_for === 'me';
if ($is_me) {
$price = (float) $product->getPriceWith($tax_free, true, $country, false, $user_abo->user);
} else {
$price = round((float) $product->getPriceWith($tax_free, false, $country, false, $user_abo->user), 1);
}
$tax_rate = $tax_free ? 0.0 : (float) $product->getTaxWith($country);
$price_net = $tax_rate > 0 ? round($price / ((100 + $tax_rate) / 100), 3) : round($price, 3);
$tax = round($price - $price_net, 3);
$price_vk_net = round((float) $product->getPriceWith(true, false, $country), 3);
$discount = 0.0;
if ($is_me && ! $product->no_commission && $user_abo->user && $user_abo->user->user_level) {
$discount = (float) $user_abo->user->user_level->getFormattedMargin();
}
return [
'price' => round($price, 2),
'price_net' => $price_net,
'tax_rate' => $tax_rate,
'tax' => $tax,
'price_vk_net' => $price_vk_net,
'discount' => $discount,
'points' => (int) $product->points,
];
}
/**
* Liefert getrennte Zwischensummen für Abo-Produkte und einmalig hinzugefügte
* Artikel sowie die kombinierten Gesamtwerte (Versand nach Gesamtgewicht).
*
* @return array{
* abo: array{net: float, gross: float, tax: float},
* one_time: array{net: float, gross: float, tax: float},
* shipping_net: float,
* shipping_gross: float,
* total_with_shipping: float,
* has_one_time: bool
* }
*/
public static function getSplitSummary(): array
{
$yard = Yard::instance(self::INSTANCE);
$abo = ['net' => 0.0, 'gross' => 0.0, 'tax' => 0.0];
$oneTime = ['net' => 0.0, 'gross' => 0.0, 'tax' => 0.0];
foreach ($yard->content() as $row) {
$lineGross = (float) $row->price * $row->qty;
$lineNet = $row->taxRate > 0 ? $lineGross / ((100 + $row->taxRate) / 100) : $lineGross;
$lineTax = $lineGross - $lineNet;
$bucket = ($row->options->abo_addon ?? false) ? 'oneTime' : 'abo';
if ($bucket === 'oneTime') {
$oneTime['net'] += $lineNet;
$oneTime['gross'] += $lineGross;
$oneTime['tax'] += $lineTax;
} else {
$abo['net'] += $lineNet;
$abo['gross'] += $lineGross;
$abo['tax'] += $lineTax;
}
}
return [
'abo' => [
'net' => round($abo['net'], 2),
'gross' => round($abo['gross'], 2),
'tax' => round($abo['tax'], 2),
],
'one_time' => [
'net' => round($oneTime['net'], 2),
'gross' => round($oneTime['gross'], 2),
'tax' => round($oneTime['tax'], 2),
],
'shipping_net' => (float) $yard->shippingNet(2, '.', ''),
'shipping_gross' => (float) $yard->shipping(2, '.', ''),
'total_with_shipping' => (float) $yard->totalWithShipping(2, '.', ''),
'has_one_time' => $oneTime['gross'] > 0,
];
}
public static function checkNumOfCompProducts($user_abo)
{
if ($user_abo->is_for === 'me') {
$needNumComp = Yard::instance('shopping')->getNumComp();
if ($needNumComp > 0) {
$UserAboItems = UserAboItem::where('user_abo_id', $user_abo->id)->where('comp', '>', 0)->get();
if (count($UserAboItems) === $needNumComp) {
return true;
$needNumComp = Yard::instance(self::INSTANCE)->getNumComp();
$UserAboItems = UserAboItem::where('user_abo_id', $user_abo->id)
->where('comp', '>', 0)
->orderBy('comp')
->get();
if ($UserAboItems->count() < $needNumComp) {
$product = Product::whereActive(true)
->where('shipping_addon', true)
->whereJsonContains('show_on', '12')
->orderBy('pos', 'DESC')
->first();
if (! $product) {
return false;
}
// need to add
if (count($UserAboItems) < $needNumComp) {
$product = Product::whereActive(true)->where('shipping_addon', true)->whereJsonContains('show_on', '12')->orderBy('pos', 'DESC')->first();
for ($i = count($UserAboItems); $i <= $needNumComp; $i++) {
$UserAboItem = UserAboItem::create([
'user_abo_id' => $user_abo->id,
'product_id' => $product->id,
'comp' => $i + 1,
'qty' => 1,
'status' => 1,
]);
AboItemHistoryService::logSystemCompAdded($user_abo, $UserAboItem);
self::addProductToCart($UserAboItem);
for ($comp = $UserAboItems->count() + 1; $comp <= $needNumComp; $comp++) {
$UserAboItem = UserAboItem::create([
'user_abo_id' => $user_abo->id,
'product_id' => $product->id,
'comp' => $comp,
'qty' => 1,
'status' => 1,
]);
AboItemHistoryService::logSystemCompAdded($user_abo, $UserAboItem);
self::addProductToCart($UserAboItem);
}
}
if ($UserAboItems->count() > $needNumComp) {
foreach ($UserAboItems as $UserAboItem) {
if ($UserAboItem->comp > $needNumComp) {
AboItemHistoryService::logSystemCompRemoved($user_abo, $UserAboItem);
$UserAboItem->delete();
}
}
// need to remove
if (count($UserAboItems) > $needNumComp) {
foreach ($UserAboItems as $UserAboItem) {
if ($UserAboItem->comp > $needNumComp) {
AboItemHistoryService::logSystemCompRemoved($user_abo, $UserAboItem);
$UserAboItem->delete();
}
}
foreach (Yard::instance('shopping')->content() as $row) {
if ($row->options->comp > $needNumComp) {
Yard::instance('shopping')->remove($row->rowId);
}
foreach (Yard::instance(self::INSTANCE)->content() as $row) {
if (($row->options->comp ?? 0) > $needNumComp) {
Yard::instance(self::INSTANCE)->remove($row->rowId);
}
}
}

View file

@ -177,6 +177,34 @@ class Yard extends Cart
$this->calculateShippingPrice();
}
/**
* Höchstes Versandgewicht (in Gramm) des aktuell gesetzten Versandlandes.
* Ergibt sich aus der obersten Gewichtsstufe (max. weight_to). 0 = keine Begrenzung ermittelbar.
*/
public function getMaxWeight(): int
{
$shippingCountry = ShippingCountry::find($this->shipping_country_id);
if (! $shippingCountry || ! $shippingCountry->shipping) {
return 0;
}
return (int) $shippingCountry->shipping->shipping_prices->max('weight_to');
}
/**
* Prüft, ob das Warenkorbgewicht zzgl. des optionalen Zusatzgewichts das
* Maximalgewicht des Versandlandes überschreiten würde.
*/
public function exceedsMaxWeight(int $additionalWeight = 0): bool
{
$maxWeight = $this->getMaxWeight();
if ($maxWeight <= 0) {
return false;
}
return ($this->weight() + $additionalWeight) > $maxWeight;
}
public function setShippingCountryWithPrice($shipping_country_id, $shipping_is_for = 'ot-member')
{
$this->shipping_country_id = $shipping_country_id;
@ -276,9 +304,10 @@ class Yard extends Cart
// if(!$shipping_price){
// }
}
// default
// default: Über der höchsten Gewichtsstufe die teuerste (höchste) Stufe nehmen,
// nicht die günstigste. Im Normalfall wird das Hinzufügen vorher gestoppt (siehe exceedsMaxWeight()).
if (! $shipping_price) {
$shipping_price = $shipping->shipping_prices->first();
$shipping_price = $shipping->shipping_prices->sortByDesc('weight_to')->first();
}
}
if ($shipping_price) {