- 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>
269 lines
9.2 KiB
PHP
269 lines
9.2 KiB
PHP
<?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);
|
|
});
|