gruene-seele/tests/Feature/SaleStockDeductionTest.php
Kevin Adametz ddd6dde593 Warenwirtschaft: Review-Fix — Mahnwesen-Storno über OrderStatusService::setShipped
Der Auto-Storno der Zahlungserinnerung schrieb shipped=10 direkt und rief
nur syncSaleStock() — Margen-Storno-Flag, pending_to/shipped_at-Reset und
Promotions-/Guthaben-Folgen fehlten. Die Partnerprovision einer
versendeten, nie bezahlten Bestellung wäre zahlbar geblieben.

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

345 lines
12 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\PaymentReminder;
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\ShoppingOrderMargin;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Services\PaymentReminderService;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
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');
// sale_stock_exempt entspricht dem Migrations-Backfill (shipped_at vor dem Stichtag)
$order = ap13MakeOrder([['product' => $creme, 'qty' => 5]], [
'shipped' => 3,
'shipped_at' => '2025-12-12 14:05:26',
'sale_stock_exempt' => true,
]);
// 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('stichtags-schutz ueberlebt storno und erneutes versenden (status-toggle)', function () {
$creme = ap13MakeProduct('AP13 Historisch-Toggle');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 5]], [
'shipped' => 3,
'shipped_at' => '2025-12-12 14:05:26',
'sale_stock_exempt' => true,
]);
// Storno nullt shipped_at, erneutes Versenden stempelt es neu (nach Stichtag) —
// das unveränderliche Flag muss den rückwirkenden Abzug trotzdem verhindern
$admin = ap13MakeAdmin();
ap13SetShipped($order, 10, $admin);
ap13SetShipped($order, 2, $admin);
expect(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(0)
->and($order->fresh()->sale_stock_exempt)->toBeTrue();
});
test('mahnwesen-storno bucht bestand zurueck und storniert die marge', function () {
Mail::fake();
$creme = ap13MakeProduct('AP13 Mahn-Storno');
$order = ap13MakeOrder([['product' => $creme, 'qty' => 2]], ['total_shipping' => 1000]);
$order->shopping_user->forceFill(['billing_email' => 'mahnung@test.example'])->save();
$margin = ShoppingOrderMargin::query()->forceCreate([
'shopping_order_id' => $order->id,
'user_id' => $order->auth_user_id,
'net_partner_commission' => 500,
]);
// Rechnungs-Bestellung wird vor Zahlungseingang versendet (Abzug + Provision pending)
ap13SetShipped($order, 2);
expect(ap13Net($order, $creme))->toBe(2)
->and($margin->fresh()->partner_commission_pending_to)->not->toBeNull();
$payment = ShoppingPayment::query()->forceCreate([
'shopping_order_id' => $order->id,
'clearingtype' => 'fnc',
'txaction' => 'open',
'mode' => 'live',
'amount' => 1000,
'currency' => 'EUR',
'reminder' => 0,
'reference' => 'AP13-MAHN-1',
]);
PaymentReminder::query()->forceCreate([
'active' => true,
'clearingtype' => 'fnc',
'interval' => 7,
'action' => 'set_order_status_cancelled',
'subject' => 'Letzte Mahnung {order_number}',
'message' => 'Bestellung {order_number} wird storniert.',
]);
app(PaymentReminderService::class)->sendReminder($payment);
// Der zentrale Statuswechsel muss greifen: Rückbuchung UND Margen-Storno
$order = $order->fresh();
expect($order->shipped)->toBe(10)
->and($order->shipped_at)->toBeNull()
->and(ap13Net($order, $creme))->toBe(0)
->and($margin->fresh()->cancellation)->toBeTruthy()
->and($margin->fresh()->partner_commission_pending_to)->toBeNull();
});
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
});