- 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>
177 lines
6.8 KiB
PHP
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);
|
|
});
|