Abo-Wartungs-Commands & Ausfuehrungs-Reminder aktivieren

Neue Artisan-Commands CreateAboReplacementOrder (Ersatzbestellung fuer ein
Abo anlegen) und RepairAboOrderUserAssignment (fehlerhafte User-Zuordnung
von Abo-Bestellungen korrigieren), jeweils mit Feature-Tests.
Ausserdem den abo:send-execution-reminder-Schedule (06:00) aktiviert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-06-25 13:37:39 +00:00
parent 2b2847add1
commit 9b7665c59a
5 changed files with 1156 additions and 1 deletions

View file

@ -0,0 +1,429 @@
<?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,
];
$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'],
]);
}
}
/**
* @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;
}
}

View file

@ -0,0 +1,252 @@
<?php
namespace App\Console\Commands;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\Repositories\InvoiceRepository;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairAboOrderUserAssignment extends Command
{
protected $signature = 'abo:repair-order-user
{--fix : Reparatur ausfuehren (ohne: nur Vorschau)}
{--force : Mit --fix ohne Rueckfrage}
{--abo=3 : user_abos.id, Standard ist der Live-Fall}
{--user=455 : Ziel users.id, Standard ist der Live-Fall}
{--order= : Konkrete shopping_orders.id (ohne: letzte Bestellung zum Abo)}
{--old-user= : Erwartete falsche users.id zur zusaetzlichen Absicherung}
{--no-documents : Rechnung/Lieferschein nicht neu rendern}';
protected $description = 'Korrigiert die letzte Abo-Bestellung, wenn ein Abo mit falschem User ausgefuehrt wurde';
public function handle(): int
{
$userAbo = UserAbo::with(['user.account', 'shopping_user'])->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;
}
$userAboOrder = $this->resolveUserAboOrder($userAbo);
if (! $userAboOrder || ! $userAboOrder->shopping_order) {
$this->error('Keine passende Abo-Bestellung gefunden.');
return self::FAILURE;
}
$order = $userAboOrder->shopping_order;
$order->loadMissing(['shopping_user', 'user_invoices', 'user_sales_volume']);
if ($this->option('old-user') !== null && (int) $order->auth_user_id !== (int) $this->option('old-user')) {
$this->error('Abbruch: auth_user_id der Bestellung entspricht nicht --old-user.');
return self::FAILURE;
}
$this->printPreview($userAbo, $order, $targetUser);
if (! $this->option('fix')) {
$this->warn('Trockenlauf. Nutze --fix zur Reparatur.');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm('Diese Abo-Bestellung wirklich auf den Ziel-User korrigieren?')) {
return self::SUCCESS;
}
$salesVolumeRecalculations = [];
$newShoppingUserId = null;
DB::transaction(function () use ($userAbo, $order, $targetUser, &$salesVolumeRecalculations, &$newShoppingUserId) {
$lockedAbo = UserAbo::whereKey($userAbo->id)->lockForUpdate()->firstOrFail();
$lockedOrder = ShoppingOrder::whereKey($order->id)->lockForUpdate()->firstOrFail();
$newShoppingUser = $this->makeShoppingUserSnapshot($lockedAbo, $lockedOrder, $targetUser);
$newShoppingUser->save();
$newShoppingUserId = $newShoppingUser->id;
$lockedAbo->update([
'user_id' => $targetUser->id,
'email' => $targetUser->email,
]);
$lockedOrder->update([
'shopping_user_id' => $newShoppingUser->id,
'auth_user_id' => $targetUser->id,
'member_id' => $lockedAbo->member_id,
'country_id' => $this->resolveShippingCountryId($targetUser, $lockedOrder),
'language' => $targetUser->getLocale(),
]);
UserSalesVolume::where('shopping_order_id', $lockedOrder->id)
->get()
->each(function (UserSalesVolume $salesVolume) use ($targetUser, &$salesVolumeRecalculations) {
$salesVolumeRecalculations[] = [
'user_id' => $salesVolume->user_id,
'month' => $salesVolume->month,
'year' => $salesVolume->year,
];
$salesVolume->user_id = $targetUser->id;
$salesVolume->save();
$salesVolumeRecalculations[] = [
'user_id' => $targetUser->id,
'month' => $salesVolume->month,
'year' => $salesVolume->year,
];
});
});
$this->recalculateSalesVolumes($salesVolumeRecalculations);
$this->info("Daten korrigiert. Neuer ShoppingUser: {$newShoppingUserId}");
if (! $this->option('no-documents')) {
$this->regenerateDocuments($order->fresh(['user_invoice', 'shopping_user', 'shopping_order_items', 'shopping_payments.payment_transactions']));
} else {
$this->warn('Dokumente wurden wegen --no-documents nicht neu gerendert.');
}
return self::SUCCESS;
}
private function resolveUserAboOrder(UserAbo $userAbo): ?UserAboOrder
{
$query = UserAboOrder::with('shopping_order')
->where('user_abo_id', $userAbo->id);
if ($this->option('order')) {
$query->where('shopping_order_id', (int) $this->option('order'));
}
return $query->orderByDesc('id')->first();
}
private function printPreview(UserAbo $userAbo, ShoppingOrder $order, User $targetUser): void
{
$this->table(
['Feld', 'Aktuell', 'Neu'],
[
['user_abo_id', $userAbo->id, $userAbo->id],
['shopping_order_id', $order->id, $order->id],
['user_abos.user_id', $userAbo->user_id, $targetUser->id],
['shopping_orders.auth_user_id', $order->auth_user_id, $targetUser->id],
['shopping_orders.member_id', $order->member_id ?: '-', $userAbo->member_id ?: '-'],
['shopping_orders.shopping_user_id', $order->shopping_user_id, 'neuer Snapshot'],
['Rechnungen/Lieferscheine', $order->user_invoices->count(), $this->option('no-documents') ? 'nicht neu rendern' : 'neu rendern'],
['SalesVolume-Eintraege', UserSalesVolume::where('shopping_order_id', $order->id)->count(), 'user_id aktualisieren'],
]
);
}
private function makeShoppingUserSnapshot(UserAbo $userAbo, ShoppingOrder $order, User $targetUser): ShoppingUser
{
$account = $targetUser->account;
$source = $order->shopping_user ?: $userAbo->shopping_user;
$shoppingUser = $source ? $source->replicate() : new ShoppingUser;
$shoppingUser->auth_user_id = $targetUser->id;
$shoppingUser->member_id = $userAbo->member_id;
$shoppingUser->billing_salutation = $account->salutation;
$shoppingUser->billing_company = $account->company;
$shoppingUser->billing_firstname = $account->first_name;
$shoppingUser->billing_lastname = $account->last_name;
$shoppingUser->billing_address = $account->address;
$shoppingUser->billing_address_2 = $account->address_2;
$shoppingUser->billing_zipcode = $account->zipcode;
$shoppingUser->billing_city = $account->city;
$shoppingUser->billing_country_id = $account->country_id;
$shoppingUser->billing_phone = $account->phone;
$shoppingUser->billing_email = $targetUser->email;
$shoppingUser->language = $targetUser->getLocale();
$shoppingUser->is_for = $userAbo->is_for;
$shoppingUser->is_from = $shoppingUser->is_from ?: 'user_order';
$shoppingUser->mode = $order->mode;
if ($account->same_as_billing) {
$shoppingUser->same_as_billing = true;
$shoppingUser->shipping_salutation = $account->salutation;
$shoppingUser->shipping_company = $account->company;
$shoppingUser->shipping_firstname = $account->first_name;
$shoppingUser->shipping_lastname = $account->last_name;
$shoppingUser->shipping_address = $account->address;
$shoppingUser->shipping_address_2 = $account->address_2;
$shoppingUser->shipping_zipcode = $account->zipcode;
$shoppingUser->shipping_city = $account->city;
$shoppingUser->shipping_country_id = $account->country_id;
$shoppingUser->shipping_phone = $account->phone;
$shoppingUser->shipping_postnumber = null;
} else {
$shoppingUser->same_as_billing = false;
$shoppingUser->shipping_salutation = $account->shipping_salutation;
$shoppingUser->shipping_company = $account->shipping_company;
$shoppingUser->shipping_firstname = $account->shipping_firstname;
$shoppingUser->shipping_lastname = $account->shipping_lastname;
$shoppingUser->shipping_address = $account->shipping_address;
$shoppingUser->shipping_address_2 = $account->shipping_address_2;
$shoppingUser->shipping_zipcode = $account->shipping_zipcode;
$shoppingUser->shipping_city = $account->shipping_city;
$shoppingUser->shipping_country_id = $account->shipping_country_id;
$shoppingUser->shipping_phone = $account->shipping_phone;
$shoppingUser->shipping_postnumber = $account->shipping_postnumber;
}
return $shoppingUser;
}
private function resolveShippingCountryId(User $targetUser, ShoppingOrder $order): int
{
$account = $targetUser->account;
$countryId = $account->same_as_billing ? $account->country_id : $account->shipping_country_id;
$shippingCountry = ShippingCountry::where('country_id', $countryId)->first();
return $shippingCountry?->id ?? $order->country_id;
}
/**
* @param list<array{user_id: int|null, month: int|null, year: int|null}> $recalculations
*/
private function recalculateSalesVolumes(array $recalculations): void
{
collect($recalculations)
->filter(fn (array $item) => $item['user_id'] && $item['month'] && $item['year'])
->unique(fn (array $item) => $item['user_id'].'-'.$item['month'].'-'.$item['year'])
->each(fn (array $item) => SalesPointsVolume::reCalculateSalesPointsVolume(
(int) $item['user_id'],
(int) $item['month'],
(int) $item['year']
));
}
private function regenerateDocuments(ShoppingOrder $order): void
{
if (! $order->user_invoice) {
$this->warn('Keine Rechnung gefunden, Dokumente wurden nicht neu gerendert.');
return;
}
$invoiceRepository = new InvoiceRepository($order);
$invoiceRepository->update(['invoice_send_mail' => false]);
$this->info('Rechnung und Lieferschein wurden neu gerendert.');
}
}

View file

@ -55,7 +55,7 @@ class Kernel extends ConsoleKernel
// Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster).
// BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP
// im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren:
// $schedule->command('abo:send-execution-reminder')->dailyAt('06:00');
$schedule->command('abo:send-execution-reminder')->dailyAt('06:00');
// Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs)
$schedule->command('abo:store-chart-snapshots')->dailyAt('04:30');

View file

@ -0,0 +1,295 @@
<?php
use App\Models\Country;
use App\Models\PaymentTransaction;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShippingPrice;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAccount;
use App\Models\UserInvoice;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function createAboReplacementOrderSeed(): array
{
$country = Country::create([
'code' => 'AT',
'phone' => '43',
'en' => 'Austria',
'de' => 'Österreich',
'es' => 'Austria',
'fr' => 'Autriche',
'it' => 'Austria',
'ru' => 'Австрия',
'eu_country' => true,
]);
$shipping = Shipping::create([
'name' => 'Österreich',
'active' => true,
]);
$shippingCountry = ShippingCountry::create([
'shipping_id' => $shipping->id,
'country_id' => $country->id,
]);
ShippingPrice::create([
'shipping_id' => $shipping->id,
'price' => 12.90,
'price_comp' => 37.00,
'tax_rate' => 19.00,
'weight_from' => 1,
'weight_to' => 20000,
]);
$account = UserAccount::create([
'first_name' => 'Marlene',
'last_name' => 'Mathis',
'address' => 'Oberdorferstrasse 11b',
'zipcode' => '6850',
'city' => 'Dornbirn',
'country_id' => $country->id,
'same_as_billing' => false,
'shipping_firstname' => 'Marlene',
'shipping_lastname' => 'Mathis',
'shipping_address' => 'Oberdorferstrasse 11b',
'shipping_zipcode' => '6850',
'shipping_city' => 'Dornbirn',
'shipping_country_id' => $country->id,
'reverse_charge' => true,
'reverse_charge_valid' => now(),
'reverse_charge_code' => 'AT',
'tax_identification_number' => 'ATU64282555',
'taxable_sales' => 1,
]);
$user = User::forceCreate([
'account_id' => $account->id,
'email' => 'marlene@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'billing_firstname' => 'Marlene',
'billing_lastname' => 'Mathis',
'billing_address' => 'Oberdorferstrasse 11b',
'billing_zipcode' => '6850',
'billing_city' => 'Dornbirn',
'billing_country_id' => $country->id,
'billing_email' => $user->email,
'same_as_billing' => false,
'shipping_firstname' => 'Marlene',
'shipping_lastname' => 'Mathis',
'shipping_address' => 'Oberdorferstrasse 11b',
'shipping_zipcode' => '6850',
'shipping_city' => 'Dornbirn',
'shipping_country_id' => $country->id,
'is_for' => 'me',
'is_from' => 'user_order',
'mode' => 'live',
]);
$userAbo = UserAbo::create([
'user_id' => $user->id,
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'me',
'email' => $user->email,
'payone_userid' => 123456,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
]);
$productA = makeProduct(['2'], 119, 19);
$productB = makeProduct(['2'], 10.70, 7);
$sourceOrder = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $user->id,
'country_id' => $shippingCountry->id,
'language' => 'de',
'user_shop_id' => 1,
'payment_for' => 3,
'total' => 248.70,
'subtotal' => 210.00,
'shipping' => 39.90,
'shipping_net' => 33.53,
'subtotal_ws' => 243.53,
'tax' => 45.07,
'tax_split' => ['19' => '44.37', '7' => '0.70'],
'net_split' => ['19' => '233.53', '7' => '10.00'],
'total_shipping' => 288.60,
'points' => 20,
'weight' => 18100,
'paid' => true,
'shipped' => 10,
'is_abo' => true,
'abo_interval' => 5,
'txaction' => 'cancelled',
'mode' => 'live',
]);
ShoppingOrderItem::create([
'shopping_order_id' => $sourceOrder->id,
'row_id' => 'row-a',
'product_id' => $productA->id,
'qty' => 2,
'price' => 119.00,
'price_net' => 100.00,
'tax_rate' => 19.00,
'tax' => 19.00,
'price_vk_net' => 100.00,
'discount' => 30.00,
'points' => 10,
'slug' => $productA->slug,
]);
ShoppingOrderItem::create([
'shopping_order_id' => $sourceOrder->id,
'row_id' => 'row-b',
'product_id' => $productB->id,
'qty' => 1,
'price' => 10.70,
'price_net' => 10.00,
'tax_rate' => 7.00,
'tax' => 0.70,
'price_vk_net' => 10.00,
'discount' => 30.00,
'points' => 0,
'slug' => $productB->slug,
]);
$sourcePayment = ShoppingPayment::create([
'shopping_order_id' => $sourceOrder->id,
'clearingtype' => 'cc',
'reference' => 'source-payment',
'amount' => 28860,
'currency' => 'EUR',
'mode' => 'live',
'is_abo' => true,
'abo_interval' => 5,
]);
$sourcePayment->txaction = 'paid';
$sourcePayment->status = 'APPROVED';
$sourcePayment->save();
PaymentTransaction::create([
'shopping_payment_id' => $sourcePayment->id,
'request' => 'authorization',
'txid' => 111,
'userid' => 222,
'status' => 'APPROVED',
'txaction' => 'paid',
'mode' => 'live',
]);
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $sourceOrder->id,
'status' => 2,
'paid' => true,
]);
$originalInvoice = UserInvoice::create([
'shopping_order_id' => $sourceOrder->id,
'year' => now()->year,
'month' => now()->month,
'date' => now(),
'full_number' => '202600001',
'number' => 1,
'filename' => 'original.pdf',
'dir' => 'invoice/2026/06/',
'delivery_filename' => 'delivery.pdf',
'delivery_dir' => 'delivery/2026/06/',
'disk' => 'public',
'cancellation' => true,
'cancellation_id' => 2,
'cancellation_date' => now(),
'status' => 1,
]);
$cancellationInvoice = UserInvoice::create([
'shopping_order_id' => $sourceOrder->id,
'year' => now()->year,
'month' => now()->month,
'date' => now(),
'full_number' => '202600002',
'number' => 2,
'filename' => 'cancellation.pdf',
'dir' => 'invoice/2026/06/',
'delivery_filename' => 'cancellation-delivery.pdf',
'delivery_dir' => 'delivery/2026/06/',
'disk' => 'public',
'cancellation' => true,
'status' => 11,
]);
$originalInvoice->cancellation_id = $cancellationInvoice->id;
$originalInvoice->save();
return [$sourceOrder, $userAbo, $user];
}
it('zeigt die Ersatzbestellung im Trockenlauf ohne Aenderung', function () {
[$sourceOrder, $userAbo, $user] = createAboReplacementOrderSeed();
$exit = Artisan::call('abo:create-replacement-order', [
'--source-order' => (string) $sourceOrder->id,
'--abo' => (string) $userAbo->id,
'--user' => (string) $user->id,
]);
expect($exit)->toBe(0);
expect(ShoppingOrder::where('customer_order_source_comment', 'like', '%replacement_for_order:%')->exists())->toBeFalse();
});
it('erstellt eine steuerfreie Ersatzbestellung ohne neue Zahlung auszuloesen', function () {
[$sourceOrder, $userAbo, $user] = createAboReplacementOrderSeed();
$exit = Artisan::call('abo:create-replacement-order', [
'--fix' => true,
'--force' => true,
'--no-invoice' => true,
'--source-order' => (string) $sourceOrder->id,
'--abo' => (string) $userAbo->id,
'--user' => (string) $user->id,
]);
expect($exit)->toBe(0);
$replacement = ShoppingOrder::where('customer_order_source_comment', 'like', '%replacement_for_order:'.$sourceOrder->id.'%')->first();
expect($replacement)->not->toBeNull()
->and((float) $replacement->subtotal)->toBe(210.00)
->and((float) $replacement->shipping)->toBe(37.00)
->and((float) $replacement->shipping_net)->toBe(31.09)
->and((float) $replacement->tax)->toBe(0.00)
->and((float) $replacement->total_shipping)->toBe(241.09)
->and($replacement->tax_split)->toBeNull()
->and($replacement->net_split)->toBeNull()
->and($replacement->txaction)->toBe('extern_paid')
->and($replacement->shopping_order_items()->count())->toBe(2)
->and(UserAboOrder::where('shopping_order_id', $replacement->id)->where('user_abo_id', $userAbo->id)->exists())->toBeTrue()
->and(ShoppingPayment::where('shopping_order_id', $replacement->id)->value('amount'))->toBe(24109);
$replacement->shopping_order_items->each(function (ShoppingOrderItem $item) {
expect((float) $item->tax_rate)->toBe(0.00)
->and((float) $item->tax)->toBe(0.00)
->and((float) $item->price)->toBe((float) $item->price_net);
});
});

View file

@ -0,0 +1,179 @@
<?php
use App\Models\Country;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function repairAboOrderUserAssignmentMakeUser(string $email, array $accountData): User
{
$country = Country::firstOrFail();
$account = UserAccount::create(array_merge([
'salutation' => 'mr',
'first_name' => 'Vorname',
'last_name' => 'Nachname',
'address' => 'Alte Strasse 1',
'zipcode' => '10000',
'city' => 'Berlin',
'country_id' => $country->id,
'same_as_billing' => true,
], $accountData));
return User::forceCreate([
'account_id' => $account->id,
'email' => $email,
'password' => bcrypt('secret'),
'lang' => 'de',
]);
}
function repairAboOrderUserAssignmentSeed(): array
{
makeShopEnv();
$oldUser = repairAboOrderUserAssignmentMakeUser('old@example.com', [
'first_name' => 'Alter',
'last_name' => 'User',
'address' => 'Falsche Strasse 1',
'zipcode' => '11111',
'city' => 'Altstadt',
]);
$targetUser = repairAboOrderUserAssignmentMakeUser('target@example.com', [
'first_name' => 'Richtiger',
'last_name' => 'User',
'address' => 'Richtige Strasse 455',
'zipcode' => '45500',
'city' => 'Zielstadt',
]);
$wrongShoppingUser = ShoppingUser::create([
'auth_user_id' => $oldUser->id,
'billing_firstname' => 'Alter',
'billing_lastname' => 'User',
'billing_address' => 'Falsche Strasse 1',
'billing_zipcode' => '11111',
'billing_city' => 'Altstadt',
'billing_country_id' => Country::firstOrFail()->id,
'billing_email' => $oldUser->email,
'same_as_billing' => true,
'shipping_country_id' => Country::firstOrFail()->id,
'is_for' => 'me',
'is_from' => 'user_order',
'mode' => 'live',
]);
$userAbo = UserAbo::create([
'user_id' => $oldUser->id,
'shopping_user_id' => $wrongShoppingUser->id,
'is_for' => 'me',
'email' => $oldUser->email,
'payone_userid' => 900100,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
]);
$order = ShoppingOrder::create([
'shopping_user_id' => $wrongShoppingUser->id,
'auth_user_id' => $oldUser->id,
'country_id' => 1,
'language' => 'de',
'user_shop_id' => 1,
'payment_for' => 3,
'total' => 100,
'subtotal' => 90,
'shipping' => 10,
'shipping_net' => 8.4,
'subtotal_ws' => 98.4,
'tax' => 11.6,
'total_shipping' => 100,
'points' => 10,
'is_abo' => true,
'abo_interval' => 5,
'paid' => true,
'txaction' => 'paid',
'mode' => 'live',
]);
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $order->id,
'status' => 2,
'paid' => true,
]);
UserSalesVolume::create([
'user_id' => $oldUser->id,
'shopping_order_id' => $order->id,
'month' => now()->month,
'year' => now()->year,
'date' => now(),
'points' => 10,
'total_net' => 90,
'status_points' => 1,
'status' => 1,
]);
return [$oldUser, $targetUser, $userAbo, $order, $wrongShoppingUser];
}
it('zeigt die betroffene Abo-Bestellung im Trockenlauf ohne Aenderung', function () {
[$oldUser, $targetUser, $userAbo, $order, $wrongShoppingUser] = repairAboOrderUserAssignmentSeed();
$exit = Artisan::call('abo:repair-order-user', [
'--abo' => (string) $userAbo->id,
'--user' => (string) $targetUser->id,
'--order' => (string) $order->id,
'--old-user' => (string) $oldUser->id,
'--no-documents' => true,
]);
expect($exit)->toBe(0);
expect($order->fresh()->auth_user_id)->toBe($oldUser->id);
expect($order->fresh()->shopping_user_id)->toBe($wrongShoppingUser->id);
});
it('korrigiert die letzte Abo-Bestellung auf den Ziel-User', function () {
[$oldUser, $targetUser, $userAbo, $order, $wrongShoppingUser] = repairAboOrderUserAssignmentSeed();
$exit = Artisan::call('abo:repair-order-user', [
'--fix' => true,
'--force' => true,
'--abo' => (string) $userAbo->id,
'--user' => (string) $targetUser->id,
'--order' => (string) $order->id,
'--old-user' => (string) $oldUser->id,
'--no-documents' => true,
]);
expect($exit)->toBe(0);
$order->refresh();
$userAbo->refresh();
expect($userAbo->user_id)->toBe($targetUser->id)
->and($userAbo->email)->toBe($targetUser->email)
->and($order->auth_user_id)->toBe($targetUser->id)
->and($order->shopping_user_id)->not->toBe($wrongShoppingUser->id)
->and(UserSalesVolume::where('shopping_order_id', $order->id)->first()->user_id)->toBe($targetUser->id);
$newShoppingUser = $order->shopping_user;
expect($newShoppingUser->auth_user_id)->toBe($targetUser->id)
->and($newShoppingUser->billing_firstname)->toBe('Richtiger')
->and($newShoppingUser->billing_lastname)->toBe('User')
->and($newShoppingUser->billing_address)->toBe('Richtige Strasse 455')
->and($newShoppingUser->billing_email)->toBe($targetUser->email);
});