create(array_merge([ 'name' => $name, 'title' => $name, 'number' => strtoupper(substr(md5($name), 0, 6)), 'active' => true, 'is_set' => false, ], $overrides)); } /** * @param array $items */ function svOrder(string $createdAt, array $items, array $overrides = []): ShoppingOrder { $country = Country::query()->firstOrCreate(['code' => 'DE'], [ 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Germanija', ]); $shippingCountry = ShippingCountry::query()->firstOrCreate( ['country_id' => $country->id], ['shipping_id' => Shipping::query()->firstOrCreate(['name' => 'Standard'])->id], ); $shoppingUser = ShoppingUser::query()->forceCreate([ 'billing_country_id' => $country->id, 'shipping_country_id' => $country->id, 'billing_firstname' => 'Test', 'billing_lastname' => 'Kunde', ]); $user = User::query()->create([ 'email' => uniqid('sv_', true).'@test.example', 'password' => bcrypt('password'), ]); $order = ShoppingOrder::query()->forceCreate(array_merge([ 'shopping_user_id' => $shoppingUser->id, 'auth_user_id' => $user->id, 'country_id' => $shippingCountry->id, 'paid' => 1, 'mode' => 'live', 'txaction' => 'paid', 'discount' => 0, ], $overrides)); $order->forceFill(['created_at' => $createdAt])->save(); foreach ($items as $item) { ShoppingOrderItem::query()->forceCreate([ 'shopping_order_id' => $order->id, 'product_id' => $item['product']->id, 'qty' => $item['qty'], 'price' => round($item['price_net'] * 1.19, 2), 'price_net' => $item['price_net'], 'tax_rate' => 19, 'comp' => $item['comp'] ?? null, ]); } return $order->fresh(); } function svNumber(string $formatted): float { return (float) str_replace(['.', ','], ['', '.'], $formatted); } test('summen werden aus positionen berechnet und bleiben konsistent', function () { $creme = svProduct('Creme'); $oel = svProduct('Öl'); $intern = svProduct('Interner Artikel', ['exclude_stats_sales' => true]); // Checkout-Bestellung mit Kopf-Feldern und Warenkorb-Rabatt svOrder('2026-08-05 10:00:00', [ ['product' => $creme, 'qty' => 2, 'price_net' => 10.0], ['product' => $intern, 'qty' => 1, 'price_net' => 5.0], ], ['subtotal_full' => 25.0, 'discount' => 4.0, 'subtotal' => 21.0]); // WooCommerce-Import ohne Kopf-Felder (subtotal_full/discount NULL) svOrder('2026-08-20 10:00:00', [ ['product' => $oel, 'qty' => 3, 'price_net' => 20.0], ], ['txaction' => 'extern', 'subtotal_full' => null, 'discount' => null, 'subtotal' => 60.0]); // Kompensationsprodukt: 0 €, zählt in die Menge svOrder('2026-08-22 10:00:00', [ ['product' => $creme, 'qty' => 1, 'price_net' => 10.0], ['product' => $creme, 'qty' => 1, 'price_net' => 0.0, 'comp' => 1], ], ['subtotal_full' => 10.0, 'subtotal' => 10.0]); // Außerhalb des Monats / nicht bezahlt / dev: dürfen nicht zählen svOrder('2026-09-01 00:00:01', [['product' => $creme, 'qty' => 9, 'price_net' => 10.0]]); svOrder('2026-08-10 10:00:00', [['product' => $creme, 'qty' => 9, 'price_net' => 10.0]], ['paid' => 0]); svOrder('2026-08-10 10:00:00', [['product' => $creme, 'qty' => 9, 'price_net' => 10.0]], ['mode' => 'dev']); // Vorjahr svOrder('2025-08-15 10:00:00', [['product' => $creme, 'qty' => 4, 'price_net' => 9.0]], ['subtotal_full' => 36.0, 'subtotal' => 36.0]); $service = new Sales; $service->setFilterVars(8, 2026, []); $rows = $service->getObjects(); expect($rows[$creme->id]['qty'])->toBe(4) ->and($rows[$creme->id]['comp_qty'])->toBe(1) ->and(svNumber($rows[$creme->id]['total']))->toBe(30.0) ->and($rows[$creme->id]['pre_qty'])->toBe(4) ->and(svNumber($rows[$creme->id]['pre_total']))->toBe(36.0) ->and($rows[$oel->id]['qty'])->toBe(3) ->and(svNumber($rows[$oel->id]['total']))->toBe(60.0) ->and($rows)->not->toHaveKey($intern->id); $shown = svNumber($rows[Sales::ROW_SHOWN]['total']); $hidden = svNumber($rows[Sales::ROW_HIDDEN]['total']); $total = svNumber($rows[Sales::ROW_TOTAL]['total']); $discount = svNumber($rows[Sales::ROW_DISCOUNT]['total']); $net = svNumber($rows[Sales::ROW_NET]['total']); expect($shown)->toBe(90.0) // 20 + 60 + 10 ->and($hidden)->toBe(5.0) ->and($total)->toBe(95.0) ->and($shown + $hidden)->toBe($total) ->and($discount)->toBe(4.0) ->and($net)->toBe(91.0) ->and($rows[Sales::ROW_COMP]['qty'])->toBe(1) ->and(svNumber($rows[Sales::ROW_TOTAL]['pre_total']))->toBe(36.0); }); test('produktfilter verschiebt umsatz nach ausgeblendet und geloeschte produkte bleiben sichtbar', function () { $creme = svProduct('Creme'); $alt = svProduct('Altprodukt'); svOrder('2026-08-05 10:00:00', [ ['product' => $creme, 'qty' => 2, 'price_net' => 10.0], ['product' => $alt, 'qty' => 1, 'price_net' => 7.0], ], ['subtotal_full' => 27.0, 'subtotal' => 27.0]); $alt->delete(); $service = new Sales; $service->setFilterVars(8, 2026, [(string) $creme->id]); $rows = $service->getObjects(); expect($rows)->not->toHaveKey($creme->id) ->and($rows[$alt->id]['name'])->toBe('Altprodukt (gelöscht)') ->and($rows[$alt->id]['qty'])->toBe(1) ->and(svNumber($rows[Sales::ROW_SHOWN]['total']))->toBe(7.0) ->and(svNumber($rows[Sales::ROW_HIDDEN]['total']))->toBe(20.0) ->and(svNumber($rows[Sales::ROW_TOTAL]['total']))->toBe(27.0); }); test('ganzes jahr ueber monat 13', function () { $creme = svProduct('Creme'); svOrder('2026-01-01 00:00:00', [['product' => $creme, 'qty' => 1, 'price_net' => 10.0]]); svOrder('2026-12-31 23:59:59', [['product' => $creme, 'qty' => 1, 'price_net' => 10.0]]); svOrder('2027-01-01 00:00:00', [['product' => $creme, 'qty' => 1, 'price_net' => 10.0]]); $service = new Sales; $service->setFilterVars(13, 2026, []); $rows = $service->getObjects(); expect($rows[$creme->id]['qty'])->toBe(2) ->and(svNumber($rows[Sales::ROW_TOTAL]['total']))->toBe(20.0); });