Absatzmengen: nach Warenausgang (Versanddatum) oder nach Rechnung auswertbar

- shopping_orders.shipped_at (+ Herkunft live/dhl/geschaetzt): gesetzt im
  Model, sobald der Versandstatus auf versendet wechselt, egal an welcher
  Stelle der Status gesetzt wird. sales:backfill-shipped-at traegt
  Altbestellungen aus dem DHL-Label nach oder schaetzt.
- SalesVolumeService rechnet Absatzmengen je Produkt wahlweise nach
  Versand (inkl. externer/API-Bestellungen, Stornos im Monat des
  Stornobelegs) oder nach Rechnung. Sets werden fuer die zweite Liste in
  Einzelartikel aufgeloest.
- Admin-Seite: Umschalter Versand/Rechnung, Datenqualitaet (Anteil
  geschaetzter Versanddaten), Export Produkte und Einzelartikel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-23 15:42:39 +00:00
parent a21bebc932
commit d01b9bca3a
7 changed files with 1139 additions and 432 deletions

View file

@ -0,0 +1,269 @@
<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\ProductBundle;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserAccount;
use App\Models\UserInvoice;
use App\Models\UserShop;
use App\Services\SalesVolumeService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function salesVolumeSeed(): array
{
$country = Country::create([
'code' => 'DE',
'phone' => '49',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Германия',
'eu_country' => true,
]);
$account = UserAccount::create([
'first_name' => 'Klaus',
'last_name' => 'Kunde',
'address' => 'Hauptstrasse 1',
'zipcode' => '10115',
'city' => 'Berlin',
'country_id' => $country->id,
]);
$user = User::forceCreate([
'account_id' => $account->id,
'email' => 'klaus@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'billing_firstname' => 'Klaus',
'billing_lastname' => 'Kunde',
'billing_address' => 'Hauptstrasse 1',
'billing_zipcode' => '10115',
'billing_city' => 'Berlin',
'billing_country_id' => $country->id,
'billing_email' => 'klaus@example.com',
'shipping_country_id' => $country->id,
'same_as_billing' => true,
]);
$shop = UserShop::create(['user_id' => $user->id, 'name' => 'Testshop', 'active' => 1]);
return [$country, $shoppingUser, $shop, $user];
}
function salesVolumeProduct(string $name, string $number, float $price = 16.9): Product
{
return Product::create([
'name' => $name,
'title' => $name,
'number' => $number,
'price' => $price,
'tax' => 19,
'active' => 1,
]);
}
function salesVolumeOrder(array $seed, array $attributes = []): ShoppingOrder
{
[$country, $shoppingUser, $shop, $user] = $seed;
return ShoppingOrder::create(array_merge([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $user->id,
'country_id' => $country->id,
'user_shop_id' => $shop->id,
'payment_for' => 3,
'mode' => 'live',
'txaction' => 'paid',
'shipped' => 0,
], $attributes));
}
it('setzt das Versanddatum beim Wechsel auf versendet', function () {
$order = salesVolumeOrder(salesVolumeSeed());
expect($order->shipped_at)->toBeNull();
$order->shipped = 2;
$order->save();
expect($order->fresh()->shipped_at)->not->toBeNull()
->and($order->fresh()->shipped_at_source)->toBe(ShoppingOrder::SHIPPED_AT_SOURCE_LIVE);
});
it('ueberschreibt ein vorhandenes Versanddatum nicht', function () {
$order = salesVolumeOrder(salesVolumeSeed(), [
'shipped' => 2,
'shipped_at' => '2026-05-04 10:00:00',
'shipped_at_source' => ShoppingOrder::SHIPPED_AT_SOURCE_DHL,
]);
$order->tracking = '123';
$order->save();
expect($order->fresh()->shipped_at->format('Y-m-d'))->toBe('2026-05-04')
->and($order->fresh()->shipped_at_source)->toBe(ShoppingOrder::SHIPPED_AT_SOURCE_DHL);
});
it('zaehlt auf Versandbasis den Monat des Warenausgangs, nicht den der Rechnung', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-03 09:00:00']);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 4]);
// Rechnung liegt im Vormonat (Zahlung Ende Juni), Versand erfolgte im Juli
UserInvoice::create([
'shopping_order_id' => $order->id,
'month' => 6,
'year' => 2026,
'date' => '2026-06-30',
'full_number' => 'RE-1',
'number' => 1,
]);
$shippingJuly = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
$invoiceJuly = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
$invoiceJune = (new SalesVolumeService(6, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
expect($shippingJuly[$product->id]['value'])->toBe(4)
->and($invoiceJuly)->toBeEmpty()
->and($invoiceJune[$product->id]['value'])->toBe(4);
});
it('zaehlt externe API-Bestellungen auf Versandbasis mit', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Direktsaft', '6030100', 26.9);
$order = salesVolumeOrder($seed, [
'payment_for' => 7,
'txaction' => 'extern_paid',
'shipped' => 2,
'shipped_at' => '2026-07-10 09:00:00',
]);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 3]);
$volumes = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
expect($volumes[$product->id]['value'])->toBe(3);
});
it('zieht Stornorechnungen ab statt sie zu addieren', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-05 09:00:00']);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 2]);
$invoice = UserInvoice::create([
'shopping_order_id' => $order->id,
'month' => 7,
'year' => 2026,
'date' => '2026-07-05',
'full_number' => 'RE-1',
'number' => 1,
]);
$cancellation = UserInvoice::create([
'shopping_order_id' => $order->id,
'month' => 7,
'year' => 2026,
'date' => '2026-07-20',
'full_number' => 'RE-2',
'number' => 2,
'cancellation' => true,
]);
$invoice->update(['cancellation' => true, 'cancellation_id' => $cancellation->id]);
$invoiceBasis = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
$shippingBasis = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
// Rechnung +2, Storno -2; auf Versandbasis Warenausgang +2 und Retoure -2
expect($invoiceBasis[$product->id]['value'])->toBe(0)
->and($shippingBasis[$product->id]['value'])->toBe(0);
});
it('zieht Stornos ohne vorherigen Versand auf Versandbasis nicht ab', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
$order = salesVolumeOrder($seed, ['shipped' => 10]);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 2]);
UserInvoice::create([
'shopping_order_id' => $order->id,
'month' => 7,
'year' => 2026,
'date' => '2026-07-20',
'full_number' => 'RE-2',
'number' => 2,
'cancellation' => true,
]);
$volumes = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
expect($volumes)->toBeEmpty();
});
it('loest Sets in ihre Anlagen auf', function () {
$seed = salesVolumeSeed();
$single = salesVolumeProduct('Direktsaft', '6030100', 26.9);
$set = salesVolumeProduct('6 x Direktsaft', '5010050', 129.9);
ProductBundle::create([
'product_id' => $set->id,
'bundle_product_id' => $single->id,
'quantity' => 6,
'pos' => 1,
]);
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-08 09:00:00']);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $set->id, 'qty' => 2]);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $single->id, 'qty' => 1]);
$service = new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING);
$products = $service->getProductVolumes();
$articles = $service->getArticleVolumes();
// Produktliste: Set bleibt eine eigene Zeile
expect($products[$set->id]['value'])->toBe(2)
->and($products[$single->id]['value'])->toBe(1);
// Einzelartikel: 2 Sets a 6 Flaschen + 1 Einzelflasche, Set-Zeile entfaellt
expect($articles[$single->id]['value'])->toBe(13)
->and($articles)->not->toHaveKey($set->id);
});
it('weist den Anteil geschaetzter Versanddaten aus', function () {
$seed = salesVolumeSeed();
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
foreach ([ShoppingOrder::SHIPPED_AT_SOURCE_DHL, ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED] as $source) {
$order = salesVolumeOrder($seed, [
'shipped' => 2,
'shipped_at' => '2026-07-08 09:00:00',
'shipped_at_source' => $source,
]);
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1]);
}
$stats = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getQualityStats();
expect($stats['orders'])->toBe(2)
->and($stats['estimated'])->toBe(1)
->and($stats['estimated_percent'])->toBe(50.0);
});