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:
parent
6b8fcadcbf
commit
211aeed384
4 changed files with 373 additions and 0 deletions
259
tests/Feature/SaleStockDeductionTest.php
Normal file
259
tests/Feature/SaleStockDeductionTest.php
Normal 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
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue