mivita/app/Services/AboOrderCart.php
Kevin a21bebc932 Punkte: Dezimalpunkte in Einmalprodukten, Homeparty und Shop-API nicht mehr abschneiden
Produkte mit 7,5 Punkten wurden an drei Stellen zu ganzen Zahlen:
user_abo_one_time_items (PHP-Cast int, 7,5 -> 7), homeparty_user_order_items
(MySQL rundete auf 8) und shopping_collect_orders (Summe der Shop-API).
Die Migration zieht die drei Spalten auf DECIMAL(10,2) nach (Nachtrag zu
2026_01_22), Cast und Abo-Snapshot rechnen mit float.

points:report-decimal-diff wertet die Differenzen je Berater und Monat aus
(nur lesend), points:repair-decimal korrigiert den laufenden Monat und
zieht die Monatssummen nach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:42:27 +00:00

515 lines
21 KiB
PHP
Raw Permalink 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: float}
*/
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' => round((float) $product->points, 2),
];
}
/**
* 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;
}
}