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>
273 lines
9.3 KiB
PHP
273 lines
9.3 KiB
PHP
<?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
|
||
});
|