Warenwirtschaft: OPT-6 Bestandswahrheit — Ausschuss in Chargen-Restmengen (Audit H-1, M-5)
- consumedByStockEntry(): chargen-bezogener Ausschuss zählt jetzt als Verbrauch — entsorgte Chargen verschwinden aus Rohstoff-Detail, Produktions-FEFO/Dropdown und AP-27-Batch-Check. - Rohstoff-Detail: Kopfwert aus remainingByIngredient() (identisch zur Index-Seite, inkl. Ausschuss ohne Chargenbezug). - Aggregationen iterieren über die Key-Vereinigung (received ∪ consumed ∪ disposed) — negative Bestände werden nicht mehr als 0 maskiert. - Standort-Aggregation: Chargen-Ausschuss zählt am Standort der Charge (COALESCE), nicht am frei gewählten Formular-Standort. Tests: neuer ChargeDisposalConsistencyTest (5). Suite: 219 grün, 795 Assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
46a832f2be
commit
84450385e3
4 changed files with 193 additions and 13 deletions
|
|
@ -77,7 +77,9 @@ class RawMaterialStockController extends Controller
|
||||||
|
|
||||||
// AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry).
|
// AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry).
|
||||||
$chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id);
|
$chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id);
|
||||||
$remaining = round((float) $chargeGroups->sum('remaining'), 2);
|
// Audit H-1: Kopfwert aus der Gesamt-Aggregation (zieht auch Ausschuss
|
||||||
|
// ohne Chargenbezug ab) — identisch zur Index-Seite, nicht Chargen-Summe.
|
||||||
|
$remaining = $this->inventoryService->remainingByIngredient([$ingredient->id])[$ingredient->id] ?? 0.0;
|
||||||
|
|
||||||
$remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id);
|
$remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,18 @@ class InventoryService
|
||||||
|
|
||||||
$disposed = $this->disposedByIngredient($ingredientIds);
|
$disposed = $this->disposedByIngredient($ingredientIds);
|
||||||
|
|
||||||
|
// Audit M-5: über die Vereinigung aller Keys iterieren — Rohstoffe mit
|
||||||
|
// Verbrauch/Ausschuss, aber ohne Eingang, würden sonst fehlen und
|
||||||
|
// negative Bestände als 0 maskiert.
|
||||||
|
$ids = collect($received->keys())
|
||||||
|
->merge($consumed->keys())
|
||||||
|
->merge(array_keys($disposed))
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->unique();
|
||||||
|
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($received as $id => $qty) {
|
foreach ($ids as $id) {
|
||||||
$result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $id] ?? 0), 2);
|
$result[$id] = round((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
@ -110,16 +119,25 @@ class InventoryService
|
||||||
->groupBy('stock_entries.location_id')
|
->groupBy('stock_entries.location_id')
|
||||||
->pluck('qty', 'location_id');
|
->pluck('qty', 'location_id');
|
||||||
|
|
||||||
|
// Audit M-5: chargen-bezogener Ausschuss zählt am Standort der Charge
|
||||||
|
// (COALESCE), nicht am frei gewählten Disposal-Standort.
|
||||||
$disposed = StockDisposal::query()
|
$disposed = StockDisposal::query()
|
||||||
->where('disposal_type', 'ingredient')
|
->leftJoin('stock_entries', 'stock_entries.id', '=', 'stock_disposals.stock_entry_id')
|
||||||
->where('ingredient_id', $ingredientId)
|
->where('stock_disposals.disposal_type', 'ingredient')
|
||||||
->selectRaw('location_id, SUM(quantity) as qty')
|
->where('stock_disposals.ingredient_id', $ingredientId)
|
||||||
->groupBy('location_id')
|
->selectRaw('COALESCE(stock_entries.location_id, stock_disposals.location_id) as loc_id, SUM(stock_disposals.quantity) as qty')
|
||||||
->pluck('qty', 'location_id');
|
->groupByRaw('COALESCE(stock_entries.location_id, stock_disposals.location_id)')
|
||||||
|
->pluck('qty', 'loc_id');
|
||||||
|
|
||||||
|
$ids = collect($received->keys())
|
||||||
|
->merge($consumed->keys())
|
||||||
|
->merge($disposed->keys())
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->unique();
|
||||||
|
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($received as $locationId => $qty) {
|
foreach ($ids as $locationId) {
|
||||||
$result[(int) $locationId] = round((float) $qty - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0), 2);
|
$result[$locationId] = round((float) ($received[$locationId] ?? 0) - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
@ -152,9 +170,15 @@ class InventoryService
|
||||||
|
|
||||||
$disposed = $this->disposedByPackagingItem($packagingItemIds);
|
$disposed = $this->disposedByPackagingItem($packagingItemIds);
|
||||||
|
|
||||||
|
$ids = collect($received->keys())
|
||||||
|
->merge($consumed->keys())
|
||||||
|
->merge(array_keys($disposed))
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->unique();
|
||||||
|
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($received as $id => $qty) {
|
foreach ($ids as $id) {
|
||||||
$result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $id] ?? 0), 2);
|
$result[$id] = round((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Services;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\Production;
|
use App\Models\Production;
|
||||||
use App\Models\ProductionIngredient;
|
use App\Models\ProductionIngredient;
|
||||||
|
use App\Models\StockDisposal;
|
||||||
use App\Models\StockEntry;
|
use App\Models\StockEntry;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
@ -340,7 +341,10 @@ class ProductionService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verbrauchte Menge je Wareneingang (über alle Produktionen), optional ohne eine bestimmte Produktion.
|
* Verbrauchte Menge je Wareneingang: Produktionsverbrauch **plus** chargen-
|
||||||
|
* bezogener Ausschuss (Audit H-1 — vorher zählte nur die Produktion, wodurch
|
||||||
|
* entsorgte Chargen weiter als verfügbar galten). Optional ohne eine
|
||||||
|
* bestimmte Produktion.
|
||||||
*
|
*
|
||||||
* @param array<int, int> $stockEntryIds
|
* @param array<int, int> $stockEntryIds
|
||||||
* @return array<int, float>
|
* @return array<int, float>
|
||||||
|
|
@ -365,6 +369,18 @@ class ProductionService
|
||||||
$result[(int) $stockEntryId] = (float) $used;
|
$result[(int) $stockEntryId] = (float) $used;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$disposed = StockDisposal::query()
|
||||||
|
->where('disposal_type', 'ingredient')
|
||||||
|
->whereNotNull('stock_entry_id')
|
||||||
|
->whereIn('stock_entry_id', $stockEntryIds)
|
||||||
|
->selectRaw('stock_entry_id, SUM(quantity) as used')
|
||||||
|
->groupBy('stock_entry_id')
|
||||||
|
->pluck('used', 'stock_entry_id');
|
||||||
|
|
||||||
|
foreach ($disposed as $stockEntryId => $used) {
|
||||||
|
$result[(int) $stockEntryId] = ($result[(int) $stockEntryId] ?? 0.0) + (float) $used;
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
138
tests/Feature/ChargeDisposalConsistencyTest.php
Normal file
138
tests/Feature/ChargeDisposalConsistencyTest.php
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Ingredient;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\StockDisposal;
|
||||||
|
use App\Models\StockEntry;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Services\InventoryService;
|
||||||
|
use App\Services\ProductionService;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function cdcUser(): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('cdc_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdcIngredient(string $name): Ingredient
|
||||||
|
{
|
||||||
|
return Ingredient::query()->create([
|
||||||
|
'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '',
|
||||||
|
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdcStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
|
||||||
|
{
|
||||||
|
return StockEntry::query()->create(array_merge([
|
||||||
|
'entry_type' => 'ingredient', 'ingredient_id' => $ing->id,
|
||||||
|
'supplier_id' => $supplier->id, 'location_id' => $location->id,
|
||||||
|
'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01',
|
||||||
|
'ordered_quantity' => 1000, 'status' => 'received',
|
||||||
|
'received_by' => $user->id, 'received_at' => '2026-01-15',
|
||||||
|
'received_quantity' => 1000, 'batch_number' => 'CDC-B1', 'best_before' => '2030-12-31',
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdcDisposal(Ingredient $ing, Location $location, float $qty, ?int $stockEntryId = null): StockDisposal
|
||||||
|
{
|
||||||
|
return StockDisposal::query()->create([
|
||||||
|
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id,
|
||||||
|
'stock_entry_id' => $stockEntryId, 'location_id' => $location->id,
|
||||||
|
'quantity' => $qty, 'unit' => 'gram', 'reason' => 'Test', 'disposed_at' => '2026-07-01',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('chargen-ausschuss reduziert chargen-restmenge in detail, produktion und batch-check', function () {
|
||||||
|
$user = cdcUser();
|
||||||
|
$this->actingAs($user, 'user');
|
||||||
|
$ing = cdcIngredient('CDC-Voll');
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$entry = cdcStock($ing, $location, $supplier, $user);
|
||||||
|
|
||||||
|
cdcDisposal($ing, $location, 1000, $entry->id);
|
||||||
|
|
||||||
|
// Detail: Charge vollständig entsorgt => keine verfügbare Charge mehr.
|
||||||
|
expect(app(InventoryService::class)->chargesWithRemainingForIngredient($ing->id))->toHaveCount(0);
|
||||||
|
|
||||||
|
// Produktion: Charge erscheint nicht mehr im FEFO-Angebot.
|
||||||
|
expect(app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $location->id))->toHaveCount(0);
|
||||||
|
|
||||||
|
// Batch-Check (AP-27): remaining = 0.
|
||||||
|
$res = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [
|
||||||
|
'ingredient_id' => $ing->id, 'batch_number' => 'CDC-B1',
|
||||||
|
]));
|
||||||
|
$res->assertOk();
|
||||||
|
expect((float) $res->json('remaining'))->toBe(0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teil-ausschuss je charge: rest sinkt entsprechend', function () {
|
||||||
|
$user = cdcUser();
|
||||||
|
$ing = cdcIngredient('CDC-Teil');
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$entry = cdcStock($ing, $location, $supplier, $user);
|
||||||
|
|
||||||
|
cdcDisposal($ing, $location, 300, $entry->id);
|
||||||
|
|
||||||
|
$charges = app(InventoryService::class)->chargesWithRemainingForIngredient($ing->id);
|
||||||
|
expect($charges)->toHaveCount(1);
|
||||||
|
expect((float) $charges->first()->getAttribute('remaining_quantity'))->toBe(700.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detail-kopfwert entspricht der index-aggregation auch bei ausschuss ohne chargenbezug', function () {
|
||||||
|
$user = cdcUser();
|
||||||
|
$this->actingAs($user, 'user');
|
||||||
|
$ing = cdcIngredient('CDC-Kopf');
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
cdcStock($ing, $location, $supplier, $user);
|
||||||
|
|
||||||
|
cdcDisposal($ing, $location, 400, null); // ohne Chargenbezug
|
||||||
|
|
||||||
|
$indexRemaining = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id];
|
||||||
|
expect($indexRemaining)->toBe(600.0);
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->viewData('remaining'))->toBe(600.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negativer bestand wird nicht mehr als 0 maskiert (key-vereinigung)', function () {
|
||||||
|
$user = cdcUser();
|
||||||
|
$ing = cdcIngredient('CDC-Negativ');
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
|
||||||
|
// Ausschuss ohne jeglichen Wareneingang.
|
||||||
|
cdcDisposal($ing, $location, 250, null);
|
||||||
|
|
||||||
|
$remaining = app(InventoryService::class)->remainingByIngredient([$ing->id]);
|
||||||
|
expect($remaining[$ing->id] ?? null)->toBe(-250.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standort-aggregation rechnet chargen-ausschuss am standort der charge ab', function () {
|
||||||
|
$user = cdcUser();
|
||||||
|
$ing = cdcIngredient('CDC-Standort');
|
||||||
|
$locationA = Location::factory()->create(['name' => 'A']);
|
||||||
|
$locationB = Location::factory()->create(['name' => 'B']);
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$entry = cdcStock($ing, $locationA, $supplier, $user);
|
||||||
|
|
||||||
|
// Ausschuss der Charge (liegt an A), aber im Formular mit Standort B gebucht.
|
||||||
|
cdcDisposal($ing, $locationB, 100, $entry->id);
|
||||||
|
|
||||||
|
$byLocation = app(InventoryService::class)->remainingByLocationForIngredient($ing->id);
|
||||||
|
expect($byLocation[$locationA->id])->toBe(900.0);
|
||||||
|
expect($byLocation)->not->toHaveKey($locationB->id);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue