mivita/app/Console/Commands/CreateAboReplacementOrder.php
Kevin cdb121fde0 Abo: Einmalprodukte bereits bei der Abo-Anlage + es/fr-Sprachsync
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>
2026-07-21 14:36:40 +00:00

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;
}
}