Einmalprodukte (Normalsortiment, show_on 3) können jetzt schon beim Abo-Abschluss hinzugefügt werden – im Portal-Wizard (Abschnitt im Upgrade-Schritt) und in allen drei Sales-Center-Abo-Strecken (eigene Produktliste). Variante A: Zeilen laufen als Cart-Option abo_addon mit durch den Checkout, werden sofort bezahlt und mit der Erstlieferung verschickt (Rechnung/Lieferschein gruppieren automatisch). - Checkout persistiert is_abo_addon (CheckoutRepository, PaymentHelper) - createAboItems/ensureUserAboItemsFromLatestOrder überspringen Addons (sonst würden Einmalprodukte dauerhaft wiederkehren); amount ohne Addon-Anteil; aboHasBaseProduct ignoriert Addon-Zeilen - CreateAboReplacementOrder propagiert is_abo_addon in Ersatzbestellungen - iq-shopping-cart.js: zweite Datatable + abo_addon-Flag-Weitergabe - i18n: neue abo.onetime_create_*-Keys (de/en/es/fr); zusätzlich es/fr komplett synchronisiert (abo-Kartenblock, navigation, payment, weborder inkl. Entfernung PAYONE-Altlasten, withdrawal.php neu) - Tests: AboCreateOneTimeItemsTest, AboCreateOneTimeCartTest, AboPortalCreateOneTimeTest (17 neue, 122 Tests gesamt grün) Plan: dev/2026-07-21-abo-einmalprodukte-anlage/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
431 lines
17 KiB
PHP
431 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\PaymentTransaction;
|
|
use App\Models\ShippingCountry;
|
|
use App\Models\ShippingPrice;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\ShoppingOrderItem;
|
|
use App\Models\ShoppingPayment;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboOrder;
|
|
use App\Models\UserInvoice;
|
|
use App\Repositories\InvoiceRepository;
|
|
use App\Services\BusinessPlan\SalesPointsVolume;
|
|
use App\Services\Incentive\IncentiveTracker;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
class CreateAboReplacementOrder extends Command
|
|
{
|
|
protected $signature = 'abo:create-replacement-order
|
|
{--source-order=55435 : Stornierte Original-shopping_orders.id}
|
|
{--abo=3 : user_abos.id fuer die neue UserAboOrder-Verknuepfung}
|
|
{--user=455 : Ziel users.id}
|
|
{--invoice-date= : Rechnungsdatum der Ersatzrechnung (d.m.Y oder Y-m-d, Standard: heute)}
|
|
{--fix : Ersatzbestellung erstellen (ohne: nur Vorschau)}
|
|
{--force : Mit --fix ohne Rueckfrage}
|
|
{--send-mail : Ersatzrechnung per Mail versenden}
|
|
{--no-invoice : Nur Ersatzbestellung ohne Rechnung/SalesVolume erstellen (nur Test/Diagnose)}';
|
|
|
|
protected $description = 'Erstellt nach einer Stornorechnung eine korrekt berechnete Ersatzbestellung ohne neue Payone-Zahlung';
|
|
|
|
public function handle(): int
|
|
{
|
|
$sourceOrder = ShoppingOrder::with([
|
|
'shopping_user',
|
|
'shopping_order_items.product',
|
|
'shopping_payments.payment_transactions',
|
|
'user_invoice',
|
|
'user_cancellation_invoice',
|
|
])->find((int) $this->option('source-order'));
|
|
|
|
if (! $sourceOrder) {
|
|
$this->error('Originalbestellung wurde nicht gefunden.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$userAbo = UserAbo::find((int) $this->option('abo'));
|
|
if (! $userAbo) {
|
|
$this->error('UserAbo wurde nicht gefunden.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$targetUser = User::with('account')->find((int) $this->option('user'));
|
|
if (! $targetUser || ! $targetUser->account) {
|
|
$this->error('Ziel-User wurde nicht gefunden oder hat keinen Account.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if (! $sourceOrder->isCancellationInvoice()) {
|
|
$this->error('Abbruch: Zur Originalbestellung existiert noch keine Stornorechnung.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if ($existingReplacement = $this->findExistingReplacement($sourceOrder)) {
|
|
$this->error("Abbruch: Es existiert bereits eine Ersatzbestellung #{$existingReplacement->id}.");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$calculation = $this->calculateReplacement($sourceOrder, $userAbo);
|
|
$invoiceDate = $this->resolveInvoiceDate();
|
|
|
|
$this->printPreview($sourceOrder, $calculation, $invoiceDate);
|
|
|
|
if (! $this->option('fix')) {
|
|
$this->warn('Trockenlauf. Nutze --fix zur Erstellung der Ersatzbestellung.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
if (! $this->option('force') && ! $this->confirm('Ersatzbestellung und Ersatzrechnung wirklich erstellen?')) {
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$replacementOrder = null;
|
|
$replacementInvoice = null;
|
|
|
|
DB::transaction(function () use ($sourceOrder, $userAbo, $targetUser, $calculation, $invoiceDate, &$replacementOrder, &$replacementInvoice) {
|
|
$replacementOrder = $this->createReplacementOrder($sourceOrder, $targetUser, $calculation);
|
|
$this->createReplacementItems($sourceOrder, $replacementOrder, $calculation);
|
|
$this->createReplacementPayment($sourceOrder, $replacementOrder, $calculation, $invoiceDate);
|
|
|
|
UserAboOrder::create([
|
|
'user_abo_id' => $userAbo->id,
|
|
'shopping_order_id' => $replacementOrder->id,
|
|
'status' => 2,
|
|
'paid' => true,
|
|
]);
|
|
|
|
if (! $this->option('no-invoice')) {
|
|
$replacementInvoice = $this->createInvoiceAndSalesVolume($replacementOrder, $invoiceDate);
|
|
}
|
|
});
|
|
|
|
if (! $replacementOrder) {
|
|
$this->error('Ersatzbestellung konnte nicht erstellt werden.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->info("Ersatzbestellung erstellt: #{$replacementOrder->id}");
|
|
if ($replacementInvoice) {
|
|
$this->info("Ersatzrechnung erstellt: {$replacementInvoice->full_number}");
|
|
} else {
|
|
$this->warn('Keine Ersatzrechnung erstellt (--no-invoice).');
|
|
}
|
|
|
|
$difference = round((float) $sourceOrder->total_shipping - $calculation['total_shipping'], 2);
|
|
if ($difference > 0) {
|
|
$this->warn('Zu erstattender Differenzbetrag: '.number_format($difference, 2, ',', '.').' EUR');
|
|
} elseif ($difference < 0) {
|
|
$this->warn('Nachzuberechnender Differenzbetrag: '.number_format(abs($difference), 2, ',', '.').' EUR');
|
|
} else {
|
|
$this->info('Keine Zahlungsdifferenz.');
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function findExistingReplacement(ShoppingOrder $sourceOrder): ?ShoppingOrder
|
|
{
|
|
return ShoppingOrder::query()
|
|
->where('customer_order_source_comment', 'like', '%replacement_for_order:'.$sourceOrder->id.'%')
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* items: list<array<string, mixed>>,
|
|
* subtotal: float,
|
|
* shipping: float,
|
|
* shipping_net: float,
|
|
* subtotal_ws: float,
|
|
* tax: float,
|
|
* total: float,
|
|
* total_shipping: float,
|
|
* points: float,
|
|
* weight: int,
|
|
* shipping_price_id: int|null
|
|
* }
|
|
*/
|
|
private function calculateReplacement(ShoppingOrder $sourceOrder, UserAbo $userAbo): array
|
|
{
|
|
$items = [];
|
|
$subtotal = 0.0;
|
|
$points = 0.0;
|
|
$weight = 0;
|
|
|
|
foreach ($sourceOrder->shopping_order_items as $item) {
|
|
$qty = (int) $item->qty;
|
|
$unitNet = round((float) ($item->price_net ?: $item->price), 2);
|
|
$lineNet = round($unitNet * $qty, 2);
|
|
$linePoints = (float) $item->points * $qty;
|
|
$lineWeight = (int) ($item->product?->weight ?? 0) * $qty;
|
|
|
|
$items[] = [
|
|
'source_item_id' => $item->id,
|
|
'row_id' => $item->row_id,
|
|
'product_id' => $item->product_id,
|
|
'homeparty_id' => $item->homeparty_id,
|
|
'shopping_collect_order_id' => $item->shopping_collect_order_id,
|
|
'comp' => $item->comp,
|
|
'qty' => $qty,
|
|
'price' => $unitNet,
|
|
'price_net' => $unitNet,
|
|
'tax_rate' => 0.0,
|
|
'tax' => 0.0,
|
|
'price_vk_net' => $item->price_vk_net,
|
|
'discount' => $item->discount,
|
|
'points' => $item->points,
|
|
'slug' => $item->slug,
|
|
'is_abo_addon' => (bool) $item->is_abo_addon,
|
|
];
|
|
|
|
$subtotal += $lineNet;
|
|
$points += $linePoints;
|
|
$weight += $lineWeight;
|
|
}
|
|
|
|
$shipping = $this->resolveShipping($sourceOrder, $userAbo, $weight);
|
|
$shippingNet = round($shipping['price'] / ((100 + $shipping['tax_rate']) / 100), 2);
|
|
$subtotal = round($subtotal, 2);
|
|
$subtotalWithShipping = round($subtotal + $shippingNet, 2);
|
|
|
|
return [
|
|
'items' => $items,
|
|
'subtotal' => $subtotal,
|
|
'shipping' => round($shipping['price'], 2),
|
|
'shipping_net' => $shippingNet,
|
|
'subtotal_ws' => $subtotalWithShipping,
|
|
'tax' => 0.0,
|
|
'total' => $subtotal,
|
|
'total_shipping' => $subtotalWithShipping,
|
|
'points' => round($points, 2),
|
|
'weight' => $weight,
|
|
'shipping_price_id' => $shipping['shipping_price_id'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{price: float, tax_rate: float, shipping_price_id: int|null}
|
|
*/
|
|
private function resolveShipping(ShoppingOrder $sourceOrder, UserAbo $userAbo, int $weight): array
|
|
{
|
|
$shippingCountry = ShippingCountry::with('shipping.shipping_prices')->findOrFail($sourceOrder->country_id);
|
|
$shippingPrices = $shippingCountry->shipping?->shipping_prices ?? collect();
|
|
|
|
$shippingPrice = $shippingPrices
|
|
->first(fn (ShippingPrice $price) => $weight >= (int) $price->weight_from && $weight <= (int) $price->weight_to);
|
|
|
|
if (! $shippingPrice) {
|
|
$shippingPrice = $shippingPrices->sortByDesc('weight_to')->first();
|
|
}
|
|
|
|
if (! $shippingPrice) {
|
|
throw new \RuntimeException('Keine Versandpreisstufe fuer das Versandland gefunden.');
|
|
}
|
|
|
|
$useCompPrice = $sourceOrder->is_abo && $userAbo->is_for === 'me';
|
|
|
|
return [
|
|
'price' => (float) ($useCompPrice ? $shippingPrice->price_comp : $shippingPrice->price),
|
|
'tax_rate' => (float) $shippingPrice->tax_rate,
|
|
'shipping_price_id' => $shippingPrice->id,
|
|
];
|
|
}
|
|
|
|
private function resolveInvoiceDate(): string
|
|
{
|
|
if ($this->option('invoice-date')) {
|
|
return Carbon::parse($this->option('invoice-date'))->format('d.m.Y');
|
|
}
|
|
|
|
return now()->format('d.m.Y');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $calculation
|
|
*/
|
|
private function printPreview(ShoppingOrder $sourceOrder, array $calculation, string $invoiceDate): void
|
|
{
|
|
$difference = round((float) $sourceOrder->total_shipping - (float) $calculation['total_shipping'], 2);
|
|
|
|
$this->table(
|
|
['Feld', 'Stornierte Originalorder', 'Ersatzorder'],
|
|
[
|
|
['shopping_order_id', $sourceOrder->id, 'neu'],
|
|
['Rechnungsdatum', $sourceOrder->user_invoice?->date ?? '-', $invoiceDate],
|
|
['Positionen', $sourceOrder->shopping_order_items->count(), count($calculation['items'])],
|
|
['Gewicht', (int) $sourceOrder->weight.' g', $calculation['weight'].' g'],
|
|
['Artikel netto', number_format((float) $sourceOrder->subtotal, 2, ',', '.').' EUR', number_format($calculation['subtotal'], 2, ',', '.').' EUR'],
|
|
['Porto brutto', number_format((float) $sourceOrder->shipping, 2, ',', '.').' EUR', number_format($calculation['shipping'], 2, ',', '.').' EUR'],
|
|
['Porto netto', number_format((float) $sourceOrder->shipping_net, 2, ',', '.').' EUR', number_format($calculation['shipping_net'], 2, ',', '.').' EUR'],
|
|
['MwSt.', number_format((float) $sourceOrder->tax, 2, ',', '.').' EUR', '0,00 EUR'],
|
|
['Gesamt', number_format((float) $sourceOrder->total_shipping, 2, ',', '.').' EUR', number_format($calculation['total_shipping'], 2, ',', '.').' EUR'],
|
|
['Differenz', '-', number_format($difference, 2, ',', '.').' EUR'],
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $calculation
|
|
*/
|
|
private function createReplacementOrder(ShoppingOrder $sourceOrder, User $targetUser, array $calculation): ShoppingOrder
|
|
{
|
|
return ShoppingOrder::create([
|
|
'shopping_user_id' => $sourceOrder->shopping_user_id,
|
|
'auth_user_id' => $targetUser->id,
|
|
'member_id' => $sourceOrder->member_id,
|
|
'homeparty_id' => $sourceOrder->homeparty_id,
|
|
'payment_for' => $sourceOrder->payment_for,
|
|
'country_id' => $sourceOrder->country_id,
|
|
'language' => $sourceOrder->language ?: $targetUser->getLocale(),
|
|
'user_shop_id' => $sourceOrder->user_shop_id,
|
|
'total' => $calculation['total'],
|
|
'subtotal' => $calculation['subtotal'],
|
|
'shipping' => $calculation['shipping'],
|
|
'shipping_net' => $calculation['shipping_net'],
|
|
'subtotal_ws' => $calculation['subtotal_ws'],
|
|
'tax' => $calculation['tax'],
|
|
'tax_split' => null,
|
|
'net_split' => null,
|
|
'total_shipping' => $calculation['total_shipping'],
|
|
'points' => $calculation['points'],
|
|
'weight' => $calculation['weight'],
|
|
'paid' => true,
|
|
'shipped' => 0,
|
|
'is_abo' => $sourceOrder->is_abo,
|
|
'abo_interval' => $sourceOrder->abo_interval,
|
|
'txaction' => 'extern_paid',
|
|
'mode' => $sourceOrder->mode,
|
|
'customer_order_source' => 'other',
|
|
'customer_order_source_comment' => 'replacement_for_order:'.$sourceOrder->id.'; stornierte Rechnung: '.($sourceOrder->user_invoice?->full_number ?? '-'),
|
|
'api_notice' => [
|
|
'replacement_for_order_id' => $sourceOrder->id,
|
|
'replacement_reason' => 'Korrektur nach falscher User-/Steuer-/Porto-Zuordnung',
|
|
'shipping_price_id' => $calculation['shipping_price_id'],
|
|
],
|
|
'api_status' => $sourceOrder->api_status,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $calculation
|
|
*/
|
|
private function createReplacementItems(ShoppingOrder $sourceOrder, ShoppingOrder $replacementOrder, array $calculation): void
|
|
{
|
|
foreach ($calculation['items'] as $item) {
|
|
ShoppingOrderItem::create([
|
|
'shopping_order_id' => $replacementOrder->id,
|
|
'row_id' => $item['row_id'] ?: 'replacement-'.$sourceOrder->id.'-'.$item['source_item_id'],
|
|
'product_id' => $item['product_id'],
|
|
'homeparty_id' => $item['homeparty_id'],
|
|
'shopping_collect_order_id' => $item['shopping_collect_order_id'],
|
|
'comp' => $item['comp'],
|
|
'qty' => $item['qty'],
|
|
'price' => $item['price'],
|
|
'price_net' => $item['price_net'],
|
|
'tax_rate' => $item['tax_rate'],
|
|
'tax' => $item['tax'],
|
|
'price_vk_net' => $item['price_vk_net'],
|
|
'discount' => $item['discount'],
|
|
'points' => $item['points'],
|
|
'slug' => $item['slug'],
|
|
'is_abo_addon' => $item['is_abo_addon'] ?? false,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $calculation
|
|
*/
|
|
private function createReplacementPayment(ShoppingOrder $sourceOrder, ShoppingOrder $replacementOrder, array $calculation, string $invoiceDate): ShoppingPayment
|
|
{
|
|
$sourcePayment = $sourceOrder->shopping_payments->last();
|
|
|
|
$paymentData = [
|
|
'shopping_order_id' => $replacementOrder->id,
|
|
'clearingtype' => $sourcePayment?->clearingtype ?? 'cc',
|
|
'wallettype' => $sourcePayment?->wallettype,
|
|
'onlinebanktransfertype' => $sourcePayment?->onlinebanktransfertype,
|
|
'reference' => 'replacement-for-'.$sourceOrder->id,
|
|
'amount' => (int) round($calculation['total_shipping'] * 100),
|
|
'currency' => $sourcePayment?->currency ?? 'EUR',
|
|
];
|
|
|
|
foreach (['carddata', 'mode', 'is_abo', 'abo_interval', 'identifier'] as $column) {
|
|
if (Schema::hasColumn('shopping_payments', $column)) {
|
|
$paymentData[$column] = match ($column) {
|
|
'carddata' => $sourcePayment?->carddata,
|
|
'mode' => $sourceOrder->mode,
|
|
'is_abo' => $sourceOrder->is_abo,
|
|
'abo_interval' => $sourceOrder->abo_interval,
|
|
'identifier' => $sourcePayment?->identifier,
|
|
};
|
|
}
|
|
}
|
|
|
|
$payment = ShoppingPayment::create($paymentData);
|
|
|
|
if (Schema::hasColumn('shopping_payments', 'txaction')) {
|
|
$payment->txaction = 'extern_paid';
|
|
}
|
|
if (Schema::hasColumn('shopping_payments', 'status')) {
|
|
$payment->status = 'APPROVED';
|
|
}
|
|
|
|
$payment->save();
|
|
|
|
$transactionData = [
|
|
'shopping_payment_id' => $payment->id,
|
|
'request' => 'replacement',
|
|
'txid' => 0,
|
|
'userid' => 0,
|
|
'status' => 'APPROVED',
|
|
'txaction' => 'extern_paid',
|
|
];
|
|
|
|
if (Schema::hasColumn('payment_transactions', 'transmitted_data')) {
|
|
$transactionData['transmitted_data'] = [
|
|
'source_order_id' => $sourceOrder->id,
|
|
'source_payment_id' => $sourcePayment?->id,
|
|
'invoice_date' => $invoiceDate,
|
|
'note' => 'Technischer Ersatz ohne neue Payone-Abbuchung',
|
|
];
|
|
}
|
|
if (Schema::hasColumn('payment_transactions', 'mode')) {
|
|
$transactionData['mode'] = $sourceOrder->mode;
|
|
}
|
|
|
|
PaymentTransaction::create($transactionData);
|
|
|
|
return $payment;
|
|
}
|
|
|
|
private function createInvoiceAndSalesVolume(ShoppingOrder $replacementOrder, string $invoiceDate): UserInvoice
|
|
{
|
|
$userSalesVolume = SalesPointsVolume::User($replacementOrder);
|
|
$invoiceRepository = new InvoiceRepository($replacementOrder->fresh(['shopping_order_items', 'shopping_payments.payment_transactions', 'shopping_user', 'auth_user.account']));
|
|
$userInvoice = $invoiceRepository->create([
|
|
'invoice_date' => $invoiceDate,
|
|
'invoice_send_mail' => $this->option('send-mail'),
|
|
]);
|
|
|
|
$userSalesVolume->user_invoice_id = $userInvoice->id;
|
|
$userSalesVolume->save();
|
|
|
|
IncentiveTracker::trackSalesVolume($userSalesVolume);
|
|
|
|
return $userInvoice;
|
|
}
|
|
}
|