mivita/app/Services/AboOrderCart.php
Kevin 8288ea59ac Abo Einmalprodukte: Review-Gate (VIP), Verbindlichkeit & Summen-Layout
- Live-Review-Gate: Einmalprodukte nur fuer VIP im Sales Center sichtbar,
  Portal ausgeblendet (AboHelper::isOneTimeFeatureVisible + Gates in Controllern)
- Nur verbindlich bestaetigte Einmal-Artikel fliessen in die Lieferung;
  Service-Helfer confirmedItems/pendingItems/pendingGross
- Footer-Layout der Einmalprodukt-Liste: bestaetigte Summe + Gesamtbetrag,
  Trennstrich, offener Betrag und neue Gesamtsumme (dunkelgruen)
- Uebersetzungen DE/EN/ES/FR (onetime_new_total u.a.), Tests angepasst/ergaenzt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:59:22 +00:00

515 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
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)
{
// WICHTIG: Statische Variablen zurücksetzen, um sicherzustellen, dass keine Daten
// aus vorherigen Abos verwendet werden
self::$user_abo = $user_abo;
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(self::INSTANCE);
$itemsBeforeDestroy = $yard->content()->count();
$yard->destroy();
\Log::info('AboOrderCart::initYard: Yard geleert', [
'abo_id' => $user_abo->id,
'items_vor_destroy' => $itemsBeforeDestroy,
]);
$itemsAfterDestroy = $yard->content()->count();
\Log::info('AboOrderCart::initYard: Yard geleert', [
'abo_id' => $user_abo->id,
'items_after_destroy' => $itemsAfterDestroy,
]);
self::$customer_detail = self::makeCustomerDetail($user_abo);
if ($user_abo->is_for === 'me') {
self::$is_for = 'abo-me';
if ($user_abo->user && $user_abo->user->account->same_as_billing) {
$country_id = $user_abo->user->account->country_id;
} else {
$country_id = $user_abo->user->account->shipping_country_id;
}
if ($country_id && $shipping_country = ShippingCountry::whereCountryId($country_id)->first()) {
if ($shipping_country->shipping && $shipping_country->shipping->active) {
UserService::initUserYard($user_abo->user, $shipping_country->id, 'abo-me');
return true;
}
}
abort(403, 'Fehler: Versandland nicht gefunden');
}
if ($user_abo->is_for === 'ot') {
self::$is_for = 'abo-ot-customer';
UserService::initCustomerYard(self::$customer_detail, 'abo-ot-customer');
return true;
}
return false;
}
public static function makeOrderYard($user_abo)
{
// WICHTIG: Statische Variablen explizit setzen für dieses Abo
self::$user_abo = $user_abo;
if ($user_abo->is_for === 'ot') {
self::$is_for = 'abo-ot-customer';
}
if ($user_abo->is_for === 'me') {
self::$is_for = 'abo-me';
}
// 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(self::INSTANCE);
$itemsBefore = $yard->content()->count();
$yard->destroy();
if ($itemsBefore > 0) {
\Log::warning('AboOrderCart::makeOrderYard: Yard war nicht leer vor makeOrderYard und wurde geleert', [
'abo_id' => $user_abo->id,
'items_before' => $itemsBefore,
]);
}
AboHelper::ensureUserAboItemsFromLatestOrder($user_abo);
// Sicherstellen, dass die Items für dieses spezifische Abo geladen werden
// Verwende fresh() um sicherzustellen, dass wir die aktuellen Daten haben
$abo_items = $user_abo->user_abo_items()->get();
\Log::info('AboOrderCart::makeOrderYard: Füge Produkte zum Cart hinzu', [
'abo_id' => $user_abo->id,
'item_count' => $abo_items->count(),
'items' => $abo_items->map(function ($item) {
return [
'id' => $item->id,
'product_id' => $item->product_id,
'qty' => $item->qty,
'comp' => $item->comp,
];
})->toArray(),
]);
foreach ($abo_items as $abo_item) {
self::addProductToCart($abo_item);
}
Yard::instance(self::INSTANCE)->reCalculateShippingPrice();
$user_abo->amount = Yard::instance(self::INSTANCE)->totalWithShipping(2, '.', '') * 100;
$user_abo->save();
}
private static function addProductToCart($item)
{
$product = Product::find($item->product_id);
$tax_free = Yard::instance(self::INSTANCE)->getUserTaxFree();
$user_country = Yard::instance(self::INSTANCE)->getUserCountry();
if ($product) {
if ($item->comp) {
$cartItem = Yard::instance(self::INSTANCE)->add(
$product->id,
$product->getLang('name'),
1,
0,
false,
false,
[
'image' => '',
'slug' => $product->slug,
'weight' => 0,
'points' => 0,
'comp' => $item->comp,
'product_id' => $product->id,
]
);
Yard::setTax($cartItem->rowId, 0);
return true;
}
if (self::$is_for === 'ot-customer' || self::$is_for === 'abo-ot-customer') {
$cartItem = Yard::instance(self::INSTANCE)
->add(
$product->id,
$product->getLang('name'),
$item->qty,
round($product->getPriceWith($tax_free, false, $user_country, false, self::$user_abo->user), 1),
false,
false,
['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(self::INSTANCE)
->add(
$product->id,
$product->getLang('name'),
$item->qty,
$product->getPriceWith($tax_free, true, $user_country, false, self::$user_abo->user),
false,
false,
['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]
);
}
if ($tax_free) {
Yard::setTax($cartItem->rowId, 0);
} else {
Yard::setTax($cartItem->rowId, $product->getTaxWith($user_country));
}
}
}
/**
* 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);
// Nur verbindlich bestätigte Einmal-Artikel fließen in die nächste Lieferung
// (Snapshot-Menge == bestätigte Menge). Unbestätigte Entwürfe bleiben außen vor.
$confirmedItems = $user_abo->one_time_items()
->whereNotNull('confirmed_at')
->whereColumn('confirmed_qty', 'qty')
->get();
foreach ($confirmedItems 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(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;
}
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();
}
}
foreach (Yard::instance(self::INSTANCE)->content() as $row) {
if (($row->options->comp ?? 0) > $needNumComp) {
Yard::instance(self::INSTANCE)->remove($row->rowId);
}
}
}
}
}
public static function getCustomerDetail()
{
return self::$customer_detail;
}
/* Need this, can change the address */
public static function makeCustomerDetail($user_abo)
{
if ($user_abo->is_for === 'me') {
// only on Abo!
$user = $user_abo->user;
// WICHTIG: Wenn bereits ein shopping_user existiert, diesen replizieren um alle Felder zu behalten
// Ansonsten neues Objekt erstellen
if ($user_abo->shopping_user) {
$shopping_user = $user_abo->shopping_user->replicate();
\Log::info('AboOrderCart::makeCustomerDetail: ShoppingUser repliziert für Abo ID: '.$user_abo->id, [
'abo_id' => $user_abo->id,
'original_shopping_user_id' => $user_abo->shopping_user->id,
]);
} else {
$shopping_user = new ShoppingUser;
\Log::info('AboOrderCart::makeCustomerDetail: Neuer ShoppingUser erstellt für Abo ID: '.$user_abo->id);
}
// Account-Daten überschreiben/aktualisieren
$shopping_user->billing_salutation = $user->account->salutation;
$shopping_user->billing_company = $user->account->company;
$shopping_user->billing_firstname = $user->account->first_name;
$shopping_user->billing_lastname = $user->account->last_name;
$shopping_user->billing_address = $user->account->address;
$shopping_user->billing_address_2 = $user->account->address_2;
$shopping_user->billing_zipcode = $user->account->zipcode;
$shopping_user->billing_city = $user->account->city;
$shopping_user->billing_country_id = $user->account->country_id;
$shopping_user->billing_phone = $user->account->phone;
$shopping_user->billing_email = $user->email ?? null;
$shopping_user->language = $user->account->getLocale();
// Auth User ID setzen falls noch nicht gesetzt
if (! $shopping_user->auth_user_id) {
$shopping_user->auth_user_id = $user->id;
}
if ($user->account->same_as_billing) {
$shopping_user->shipping_salutation = $user->account->salutation;
$shopping_user->shipping_company = $user->account->company;
$shopping_user->shipping_firstname = $user->account->first_name;
$shopping_user->shipping_lastname = $user->account->last_name;
$shopping_user->shipping_address = $user->account->address;
$shopping_user->shipping_address_2 = $user->account->address_2;
$shopping_user->shipping_zipcode = $user->account->zipcode;
$shopping_user->shipping_city = $user->account->city;
$shopping_user->shipping_country_id = $user->account->country_id;
$shopping_user->shipping_phone = $user->account->phone;
$shopping_user->shipping_postnumber = $user->account->shipping_postnumber;
$shopping_user->same_as_billing = 1;
} else {
$shopping_user->shipping_salutation = $user->account->shipping_salutation;
$shopping_user->shipping_company = $user->account->shipping_company;
$shopping_user->shipping_firstname = $user->account->shipping_firstname;
$shopping_user->shipping_lastname = $user->account->shipping_lastname;
$shopping_user->shipping_address = $user->account->shipping_address;
$shopping_user->shipping_address_2 = $user->account->shipping_address_2;
$shopping_user->shipping_zipcode = $user->account->shipping_zipcode;
$shopping_user->shipping_city = $user->account->shipping_city;
$shopping_user->shipping_country_id = $user->account->shipping_country_id;
$shopping_user->shipping_phone = $user->account->shipping_phone;
$shopping_user->shipping_postnumber = $user->account->shipping_postnumber;
$shopping_user->same_as_billing = 0;
}
}
if ($user_abo->is_for === 'ot') {
// look for the primary user of this abo
$shopping_user = $user_abo->shopping_user->replicate();
}
return $shopping_user;
}
}