Warenwirtschaft: AP-13 Shop-Bestandsabzug bei Versand (inkl. Sets)

ProductStockService::syncSaleStock(): idempotenter Differenz-Abgleich der
Verkaufs-Abgänge (source='sale', Referenz = Bestellung) mit dem Versandstatus —
Abzug je Bestellposition bei versendet/abgeschlossen, Sets rekursiv über
product_set_items in Einzelprodukte aufgelöst; Zurücksetzen/Storno führt per
Gegenbuchung zurück (behebt auch das Doppelabzug-Risiko Versand→Zurücksetzen→
Versand). Andockpunkte: SalesController::store (in der Transaktion) und
PaymentReminderService (Auto-Storno per Cron). Verbrauch/Monat zählt
Verkaufs-Abgänge automatisch mit (source != production).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-16 07:53:52 +00:00
parent 6b8fcadcbf
commit 211aeed384
4 changed files with 373 additions and 0 deletions

View file

@ -0,0 +1,259 @@
<?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('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
});