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

@ -10,6 +10,7 @@ use App\Services\CustomerPriority;
use App\Services\Invoice;
use App\Services\Payment;
use App\Services\PaymentService;
use App\Services\ProductStockService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Request;
@ -485,6 +486,9 @@ class SalesController extends Controller
$shopping_order->shopping_order_margin->save();
}
}
// AP-13: Verkaufs-Abgänge (Produktbestand) mit dem neuen Versandstatus abgleichen
app(ProductStockService::class)->syncSaleStock($shopping_order, auth()->id());
});
}

View file

@ -187,6 +187,8 @@ class PaymentReminderService
$this->setNoNPayment($payment);
$payment->shopping_order->shipped = 10;
$payment->shopping_order->save();
// AP-13: war die Bestellung bereits versendet, gebuchte Verkaufs-Abgänge zurückführen
app(ProductStockService::class)->syncSaleStock($payment->shopping_order);
$this->createLog(
'action_completed',
"Action abgeschlossen: Bestellung auf 'Storniert' gesetzt, Payment auf 'non' gesetzt",

View file

@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\Product;
use App\Models\Production;
use App\Models\ProductStockMovement;
use App\Models\ShoppingOrder;
use Illuminate\Database\Eloquent\Model;
class ProductStockService
@ -170,6 +171,113 @@ class ProductStockService
}
}
/**
* AP-13: Verkaufs-Abgänge einer Bestellung idempotent mit dem Versandstatus abgleichen.
*
* Soll = Positionsmengen bei versendeter/abgeschlossener Bestellung (Sets rekursiv in
* Einzelprodukte aufgelöst), sonst 0 — Zurücksetzen des Versandstatus oder Storno führt
* bereits gebuchte Abgänge per Gegenbuchung zurück. Gebucht wird nur die Differenz zum
* bereits gebuchten Stand (append-only, wie recordProductionStock) — mehrfache Aufrufe
* und Versand→Zurücksetzen→Versand buchen dadurch nie doppelt.
*/
public function syncSaleStock(ShoppingOrder $order, ?int $userId = null): void
{
$target = [];
if (in_array($order->getAPIShippedType(), ['sent', 'close'], true)) {
$order->loadMissing('shopping_order_items');
$itemProducts = Product::withTrashed()
->whereIn('id', $order->shopping_order_items->pluck('product_id')->unique())
->get()
->keyBy('id');
foreach ($order->shopping_order_items as $item) {
$product = $itemProducts->get((int) $item->product_id);
if (! $product) {
continue;
}
foreach ($this->resolveSaleComponents($product, (int) $item->qty) as $productId => $qty) {
$target[$productId] = ($target[$productId] ?? 0) + $qty;
}
}
}
$booked = ProductStockMovement::query()
->where('source', 'sale')
->where('reference_type', $order->getMorphClass())
->where('reference_id', $order->getKey())
->selectRaw("product_id, COALESCE(SUM(CASE WHEN direction = 'out' THEN quantity ELSE -quantity END), 0) as net")
->groupBy('product_id')
->pluck('net', 'product_id');
$productIds = collect(array_keys($target))
->merge($booked->keys())
->map(fn ($id) => (int) $id)
->unique()
->values();
if ($productIds->isEmpty()) {
return;
}
// withTrashed: Gegenbuchungen auch für inzwischen soft-gelöschte Produkte (wie N-9).
$products = Product::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id');
foreach ($productIds as $productId) {
$alreadyBooked = (int) ($booked[$productId] ?? 0);
$targetQty = (int) ($target[$productId] ?? 0);
$delta = $targetQty - $alreadyBooked;
if ($delta === 0 || ! isset($products[$productId])) {
continue;
}
$reason = $alreadyBooked === 0
? __('Verkauf')
: ($targetQty === 0 ? __('Verkauf storniert') : __('Verkaufskorrektur'));
$this->recordMovement(
$products[$productId],
$delta > 0 ? 'out' : 'in',
abs($delta),
$reason,
'sale',
__('Bestellung').' #'.$order->id,
$userId,
$order,
);
}
}
/**
* Löst ein Produkt in bestandsführende Einzelprodukte auf: Sets rekursiv über ihre
* Komponenten (product_set_items, Pivot-Menge je Set-Stück), Einzelprodukte 1:1.
*
* @param array<int, bool> $visited Zyklus-Schutz über bereits besuchte Set-IDs
* @return array<int, int> [product_id => Stückzahl]
*/
private function resolveSaleComponents(Product $product, int $qty, array $visited = []): array
{
if ($qty <= 0) {
return [];
}
if (! $product->is_set) {
return [$product->id => $qty];
}
if (isset($visited[$product->id])) {
return [];
}
$visited[$product->id] = true;
$result = [];
foreach ($product->setItems()->withTrashed()->get() as $component) {
$componentQty = $qty * max(1, (int) $component->pivot->quantity);
foreach ($this->resolveSaleComponents($component, $componentQty, $visited) as $productId => $componentTotal) {
$result[$productId] = ($result[$productId] ?? 0) + $componentTotal;
}
}
return $result;
}
/**
* Durchschnittlicher Verbrauch pro Monat je Produkt (Ø der letzten X Monate, Default 6).
*

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
});