gruene-seele/tests/Feature/SaleStockDeductionTest.php
Kevin Adametz c0476ebe24 Warenwirtschaft: AP-13 — Stichtags-Schutz für vor Einführung versendete Bestellungen
Erneutes Speichern des Versandstatus einer Alt-Bestellung (shipped_at vor
16.07.2026) hätte rückwirkend vom heutigen Bestand abgezogen. syncSaleStock()
überspringt historische Bestellungen jetzt per Stichtag SALE_STOCK_SINCE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 09:31:23 +00:00

273 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\ProductStockMovement;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap13MakeAdmin(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap13_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap13MakeProduct(string $name, array $overrides = []): Product
{
return Product::query()->create(array_merge([
'name' => $name,
'title' => $name,
'active' => true,
'is_set' => false,
], $overrides));
}
function ap13MakeOrder(array $items, array $overrides = []): ShoppingOrder
{
$country = Country::query()->forceCreate([
'code' => 'DE',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Germanija',
]);
$shipping = Shipping::query()->forceCreate(['name' => 'Standard']);
$shippingCountry = ShippingCountry::query()->forceCreate([
'shipping_id' => $shipping->id,
'country_id' => $country->id,
]);
$shoppingUser = ShoppingUser::query()->forceCreate([
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_firstname' => 'Test',
'billing_lastname' => 'Kunde',
]);
$order = ShoppingOrder::query()->forceCreate(array_merge([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => ap13MakeAdmin(0)->id,
'country_id' => $shippingCountry->id,
'shipped' => 0,
], $overrides));
foreach ($items as $item) {
ShoppingOrderItem::query()->forceCreate([
'shopping_order_id' => $order->id,
'product_id' => $item['product']->id,
'qty' => $item['qty'],
]);
}
return $order->fresh();
}
/**
* Mehrere Statuswechsel im selben Test müssen denselben Admin verwenden —
* ein User-Wechsel zwischen Requests kollidiert mit der bestehenden Session
* und endet als unauthentifizierter Redirect auf /login.
*/
function ap13SetShipped(ShoppingOrder $order, int $shipped, ?User $admin = null): void
{
$response = test()->actingAs($admin ?? ap13MakeAdmin())->post(route('admin_sales_store'), [
'id' => $order->id,
'action' => 'store_shipped',
'shipped' => $shipped,
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect();
}
function ap13Net(ShoppingOrder $order, Product $product): int
{
return (int) ProductStockMovement::query()
->where('source', 'sale')
->where('reference_type', $order->getMorphClass())
->where('reference_id', $order->id)
->where('product_id', $product->id)
->selectRaw("COALESCE(SUM(CASE WHEN direction = 'out' THEN quantity ELSE -quantity END), 0) as net")
->value('net');
}
test('versand bucht abgang je bestellposition', function () {
$service = app(ProductStockService::class);
$creme = ap13MakeProduct('AP13 Creme');
$seife = ap13MakeProduct('AP13 Seife');
$service->recordMovement($creme, 'in', 50, 'Inventur');
$service->recordMovement($seife, 'in', 20, 'Inventur');
$order = ap13MakeOrder([
['product' => $creme, 'qty' => 3],
['product' => $seife, 'qty' => 1],
]);
ap13SetShipped($order, 2);
expect($service->currentStock($creme->id))->toBe(47)
->and($service->currentStock($seife->id))->toBe(19)
->and(ap13Net($order, $creme))->toBe(3)
->and(ap13Net($order, $seife))->toBe(1);
});
test('set wird in einzelprodukte aufgeloest, set selbst bucht nichts', function () {
$creme = ap13MakeProduct('AP13 Set-Creme');
$oel = ap13MakeProduct('AP13 Set-Öl');
$set = ap13MakeProduct('AP13 Geschenkset', ['is_set' => true]);
$set->setItems()->attach($creme->id, ['quantity' => 2, 'pos' => 1]);
$set->setItems()->attach($oel->id, ['quantity' => 1, 'pos' => 2]);
$order = ap13MakeOrder([['product' => $set, 'qty' => 3]]);
ap13SetShipped($order, 2);
expect(ap13Net($order, $creme))->toBe(6)
->and(ap13Net($order, $oel))->toBe(3)
->and(ap13Net($order, $set))->toBe(0);
});
test('verschachteltes set wird rekursiv aufgeloest', function () {
$creme = ap13MakeProduct('AP13 Basis-Creme');
$innerSet = ap13MakeProduct('AP13 Duo', ['is_set' => true]);
$innerSet->setItems()->attach($creme->id, ['quantity' => 2, 'pos' => 1]);
$outerSet = ap13MakeProduct('AP13 Großpaket', ['is_set' => true]);
$outerSet->setItems()->attach($innerSet->id, ['quantity' => 3, 'pos' => 1]);
$order = ap13MakeOrder([['product' => $outerSet, 'qty' => 2]]);
ap13SetShipped($order, 2);
// 2 Großpakete × 3 Duos × 2 Cremes = 12
expect(ap13Net($order, $creme))->toBe(12)
->and(ap13Net($order, $innerSet))->toBe(0)
->and(ap13Net($order, $outerSet))->toBe(0);
});
test('wiederholter versand-statuswechsel bucht nicht doppelt', function () {
$creme = ap13MakeProduct('AP13 Idempotent');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 5]]);
$admin = ap13MakeAdmin();
ap13SetShipped($order, 2, $admin);
ap13SetShipped($order, 3, $admin);
ap13SetShipped($order, 2, $admin);
expect(ap13Net($order, $creme))->toBe(5)
->and(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(1);
});
test('zuruecksetzen auf offen fuehrt abgang per gegenbuchung zurueck', function () {
$service = app(ProductStockService::class);
$creme = ap13MakeProduct('AP13 Rückbuchung');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 4]]);
$admin = ap13MakeAdmin();
ap13SetShipped($order, 2, $admin);
ap13SetShipped($order, 0, $admin);
expect(ap13Net($order, $creme))->toBe(0)
->and($service->currentStock($creme->id))->toBe(0)
->and(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(2);
});
test('erneuter versand nach zuruecksetzen bucht wieder genau einmal ab', function () {
$creme = ap13MakeProduct('AP13 Wiederversand');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 4]]);
$admin = ap13MakeAdmin();
ap13SetShipped($order, 2, $admin);
ap13SetShipped($order, 0, $admin);
ap13SetShipped($order, 2, $admin);
expect(ap13Net($order, $creme))->toBe(4)
->and(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(3);
});
test('storno einer versendeten bestellung bucht zurueck', function () {
$creme = ap13MakeProduct('AP13 Storno');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 2]]);
$admin = ap13MakeAdmin();
ap13SetShipped($order, 2, $admin);
ap13SetShipped($order, 10, $admin);
expect(ap13Net($order, $creme))->toBe(0);
$reversal = ProductStockMovement::query()
->where('product_id', $creme->id)
->where('direction', 'in')
->first();
expect($reversal)->not->toBeNull()
->and($reversal->reason)->toBe('Verkauf storniert');
});
test('nicht versendete bestellung bucht nichts', function () {
$creme = ap13MakeProduct('AP13 Offen');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 2]]);
$admin = ap13MakeAdmin();
ap13SetShipped($order, 1, $admin);
ap13SetShipped($order, 5, $admin);
expect(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(0);
});
test('service-abgleich ist auch direkt idempotent (cron-storno)', function () {
$service = app(ProductStockService::class);
$creme = ap13MakeProduct('AP13 Cron');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 3]], ['shipped' => 2]);
$service->syncSaleStock($order);
$service->syncSaleStock($order);
expect(ap13Net($order, $creme))->toBe(3);
// Zahlungserinnerungs-Storno: Status 10 ohne Controller
$order->forceFill(['shipped' => 10])->save();
$service->syncSaleStock($order->fresh());
expect(ap13Net($order, $creme))->toBe(0);
});
test('historische bestellung (vor stichtag versendet) bucht bei erneutem speichern nichts', function () {
$creme = ap13MakeProduct('AP13 Historisch');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 5]], [
'shipped' => 3,
'shipped_at' => '2025-12-12 14:05:26',
]);
// Admin bestätigt den Status einer Alt-Bestellung erneut — darf nicht rückwirkend abziehen
ap13SetShipped($order, 3);
expect(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(0)
->and($order->fresh()->shipped_at?->format('Y-m-d'))->toBe('2025-12-12');
});
test('verkaufs-abgaenge zaehlen im monatsverbrauch', function () {
$service = app(ProductStockService::class);
$creme = ap13MakeProduct('AP13 Verbrauch');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 6]]);
ap13SetShipped($order, 2);
$consumption = $service->monthlyConsumptionByProduct([$creme->id]);
expect($consumption[$creme->id])->toBe(1.0); // 6 Stück / 6 Monate
});