gruene-seele/tests/Feature/StatsSalesVolumeTest.php
Kevin Adametz ef19dc2ced Absatzstatistik: Summen aus Bestellpositionen, Kompensationsprodukte ausgewiesen, Extern-Bestellungen nachgefüllt
- Stats\Sales: alle Summenzeilen (Angezeigt/Ausgeblendet/Gesamt/Rabatte/Netto)
  aus price_net × qty der Positionen statt aus subtotal_full/discount des
  Bestellkopfs; WooCommerce-Importe (txaction extern) hatten dort NULL und
  zählten seit 06/2026 mit 0 € (Aug 2026: 449 € statt 3.318 € angezeigt)
- Kompensationsprodukte (comp > 0) bleiben in der Menge, neue Spalten
  „davon Kompensation“ (aktuell + Vorjahr) und Summenzeile 9993
- Gelöschte Produkte bleiben im Umsatz sichtbar (withTrashed, „(gelöscht)“)
- WP-Import setzt subtotal_full und discount jetzt wie der Checkout
- Migration füllt subtotal_full/discount bestehender Extern-Bestellungen nach
- Excel-Export um die beiden Kompensationsspalten erweitert
- Test tests/Feature/StatsSalesVolumeTest.php (Konsistenz, Filter, Jahr)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:15:30 +00:00

177 lines
6.8 KiB
PHP

<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Services\Stats\Sales;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function svProduct(string $name, array $overrides = []): Product
{
return Product::query()->create(array_merge([
'name' => $name,
'title' => $name,
'number' => strtoupper(substr(md5($name), 0, 6)),
'active' => true,
'is_set' => false,
], $overrides));
}
/**
* @param array<int, array{product: Product, qty: int, price_net: float, comp?: int}> $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);
});