Chargennummern kommen von extern und können kollidieren. Der Lieferant gehört jetzt zum Gruppenschlüssel (batchGroupKey b:<supplier>:<lot>) — gleiche Lot-Nummern verschiedener Lieferanten verschmelzen nicht mehr (kein gemeinsames min-MHD/Summen-Rest). Batch-Check (AP-27) prüft zusätzlich den Lieferanten des zu buchenden Eingangs. Tests: +1 (getrennte Optionen je Lieferant); Chargen-Tests auf neues Schlüsselformat umgestellt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
315 lines
11 KiB
PHP
315 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Models\Country;
|
|
use App\Models\Ingredient;
|
|
use App\Models\Location;
|
|
use App\Models\Product;
|
|
use App\Models\ProductIngredient;
|
|
use App\Models\Production;
|
|
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 Illuminate\Validation\ValidationException;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function ap27MakeUser(int $adminLevel = 7): User
|
|
{
|
|
Country::query()->firstOrCreate(
|
|
['code' => 'DE'],
|
|
[
|
|
'phone' => '00',
|
|
'en' => 'Germany',
|
|
'de' => 'Deutschland',
|
|
'es' => 'Germany',
|
|
'fr' => 'Germany',
|
|
'it' => 'Germany',
|
|
'ru' => 'Germany',
|
|
'active' => true,
|
|
]
|
|
);
|
|
|
|
$user = User::query()->create([
|
|
'email' => uniqid('ap27_', true).'@test.example',
|
|
'password' => bcrypt('password'),
|
|
]);
|
|
$user->forceFill([
|
|
'admin' => $adminLevel,
|
|
'confirmed' => true,
|
|
'active' => true,
|
|
'wizard' => 100,
|
|
'blocked' => false,
|
|
])->save();
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
function ap27MakeIngredient(string $name): Ingredient
|
|
{
|
|
return Ingredient::query()->create([
|
|
'name' => $name,
|
|
'trans_name' => '',
|
|
'inci' => $name,
|
|
'trans_inci' => '',
|
|
'effect' => '',
|
|
'trans_effect' => '',
|
|
'active' => true,
|
|
'pos' => 0,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function ap27MakeStock(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' => 5000,
|
|
'price_per_kg' => 15,
|
|
'status' => 'received',
|
|
'received_by' => $user->id,
|
|
'received_at' => '2026-01-15',
|
|
'received_quantity' => 5000,
|
|
'batch_number' => 'AP27-CHARGE',
|
|
'best_before' => '2030-12-31',
|
|
], $overrides));
|
|
}
|
|
|
|
function ap27Consume(Ingredient $ing, StockEntry $stock, Location $location, User $user, float $grams): void
|
|
{
|
|
$product = Product::query()->create([
|
|
'name' => 'AP27-Verbrauch-'.uniqid(),
|
|
'title' => 'AP27-Verbrauch',
|
|
'active' => true,
|
|
]);
|
|
|
|
$production = Production::query()->create([
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_by' => $user->id,
|
|
'produced_at' => '2026-06-01',
|
|
'quantity' => 1,
|
|
'mhd_warning' => false,
|
|
]);
|
|
|
|
$production->productionIngredients()->create([
|
|
'ingredient_id' => $ing->id,
|
|
'stock_entry_id' => $stock->id,
|
|
'quantity_used' => $grams,
|
|
]);
|
|
}
|
|
|
|
test('chargeGroups fuehrt gleiche charge zusammen, einkaeufe bleiben separat', function () {
|
|
$user = ap27MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap27MakeIngredient('AP27-Gruppe');
|
|
|
|
$first = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
|
|
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']);
|
|
ap27MakeStock($ing, $location, $supplier, $user, ['batch_number' => 'AP27-ANDERE', 'received_quantity' => 1000]);
|
|
ap27Consume($ing, $first, $location, $user, 1000);
|
|
|
|
$groups = app(InventoryService::class)->chargeGroupsForIngredient($ing->id);
|
|
|
|
expect($groups)->toHaveCount(2);
|
|
|
|
$merged = $groups->firstWhere('batch_number', 'AP27-CHARGE');
|
|
expect($merged['remaining'])->toBe(7000.0);
|
|
expect($merged['entries'])->toHaveCount(2);
|
|
|
|
$other = $groups->firstWhere('batch_number', 'AP27-ANDERE');
|
|
expect($other['remaining'])->toBe(1000.0);
|
|
expect($other['entries'])->toHaveCount(1);
|
|
|
|
// Beide Einkäufe existieren weiterhin als eigene stock_entries.
|
|
expect(StockEntry::query()->where('batch_number', 'AP27-CHARGE')->count())->toBe(2);
|
|
});
|
|
|
|
test('batch-check endpoint erkennt vorhandene charge und liefert vorbelegung', function () {
|
|
$user = ap27MakeUser();
|
|
$location = Location::factory()->create(['name' => 'AP27-Lager']);
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap27MakeIngredient('AP27-Check');
|
|
|
|
$first = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
|
|
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']);
|
|
ap27Consume($ing, $first, $location, $user, 1000);
|
|
|
|
$this->actingAs($user, 'user');
|
|
|
|
$response = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [
|
|
'ingredient_id' => $ing->id,
|
|
'batch_number' => 'AP27-CHARGE',
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$response->assertJson([
|
|
'exists' => true,
|
|
'purchases' => 2,
|
|
'remaining' => 7000.0,
|
|
'best_before' => '31.12.2030',
|
|
'location' => 'AP27-Lager',
|
|
]);
|
|
|
|
$miss = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [
|
|
'ingredient_id' => $ing->id,
|
|
'batch_number' => 'GIBT-ES-NICHT',
|
|
]));
|
|
$miss->assertOk();
|
|
$miss->assertJson(['exists' => false]);
|
|
});
|
|
|
|
test('rohstoff-detailansicht zeigt charge summiert mit einkaufs-unterzeilen', function () {
|
|
$user = ap27MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap27MakeIngredient('AP27-Detail');
|
|
|
|
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
|
|
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
|
|
|
|
$response->assertOk();
|
|
$response->assertSee('AP27-CHARGE');
|
|
$response->assertSee('2 Einkäufe');
|
|
$response->assertSee('8.000 g');
|
|
$response->assertSee('Einkauf vom 15.01.2026');
|
|
$response->assertSee('Einkauf vom 15.02.2026');
|
|
});
|
|
|
|
test('recipe-payload gruppiert chargen mit summen-rest', function () {
|
|
$user = ap27MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap27MakeIngredient('AP27-Payload');
|
|
|
|
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
|
|
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']);
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP27-Payload-Produkt',
|
|
'title' => 'AP27-Payload-Produkt',
|
|
'active' => true,
|
|
]);
|
|
ProductIngredient::query()->create([
|
|
'product_id' => $product->id,
|
|
'ingredient_id' => $ing->id,
|
|
'pos' => 0,
|
|
'gram' => 100,
|
|
'factor' => 1.0,
|
|
'recipe_type' => 'manufacturer',
|
|
]);
|
|
|
|
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
|
|
|
|
$batches = $payload['ingredients'][0]['batches'];
|
|
expect($batches)->toHaveCount(1);
|
|
expect($batches[0]['key'])->toBe('b:'.$supplier->id.':AP27-CHARGE');
|
|
expect($batches[0]['remaining'])->toBe(8000.0);
|
|
expect($batches[0]['purchases'])->toBe(2);
|
|
|
|
// Einzel-Einträge bleiben im Payload erhalten (Audit/Abwärtskompatibilität).
|
|
expect($payload['ingredients'][0]['stock_entries'])->toHaveCount(2);
|
|
});
|
|
|
|
test('expandBatchLines verteilt verbrauch fefo auf die einkaeufe der charge', function () {
|
|
$user = ap27MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap27MakeIngredient('AP27-FEFO');
|
|
|
|
// Früheres MHD zuerst (FEFO): Eintrag A 100 g Rest, Eintrag B 200 g Rest.
|
|
$entryA = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 100, 'best_before' => '2027-01-01']);
|
|
$entryB = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 200, 'best_before' => '2029-01-01']);
|
|
|
|
$lines = app(ProductionService::class)->expandBatchLines([
|
|
['ingredient_id' => $ing->id, 'batch_key' => 'b:'.$supplier->id.':AP27-CHARGE', 'quantity_used' => '150'],
|
|
], $location->id);
|
|
|
|
expect($lines)->toHaveCount(2);
|
|
expect($lines[0]['stock_entry_id'])->toBe($entryA->id);
|
|
expect((float) $lines[0]['quantity_used'])->toBe(100.0);
|
|
expect($lines[1]['stock_entry_id'])->toBe($entryB->id);
|
|
expect((float) $lines[1]['quantity_used'])->toBe(50.0);
|
|
});
|
|
|
|
test('expandBatchLines reicht stock_entry_id-zeilen unveraendert durch und wirft bei unbekannter charge', function () {
|
|
$user = ap27MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap27MakeIngredient('AP27-Passthrough');
|
|
|
|
$stock = ap27MakeStock($ing, $location, $supplier, $user);
|
|
|
|
$passthrough = [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '50']];
|
|
expect(app(ProductionService::class)->expandBatchLines($passthrough, $location->id))->toBe($passthrough);
|
|
|
|
expect(fn () => app(ProductionService::class)->expandBatchLines([
|
|
['ingredient_id' => $ing->id, 'batch_key' => 'b:'.$supplier->id.':UNBEKANNT', 'quantity_used' => '10'],
|
|
], $location->id))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('produktion per formular bucht charge fefo auf beide einkaeufe', function () {
|
|
$user = ap27MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap27MakeIngredient('AP27-Buchung');
|
|
|
|
$entryA = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 100, 'best_before' => '2027-01-01']);
|
|
$entryB = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 200, 'best_before' => '2029-01-01']);
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP27-Buchung-Produkt',
|
|
'title' => 'AP27-Buchung-Produkt',
|
|
'active' => true,
|
|
]);
|
|
ProductIngredient::query()->create([
|
|
'product_id' => $product->id,
|
|
'ingredient_id' => $ing->id,
|
|
'pos' => 0,
|
|
'gram' => 150,
|
|
'factor' => 1.0,
|
|
'recipe_type' => 'manufacturer',
|
|
]);
|
|
|
|
$this->actingAs($user, 'user');
|
|
|
|
$response = $this->post(route('admin.inventory.productions.store'), [
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-07-01',
|
|
'quantity' => 1,
|
|
'ingredient_lines' => [
|
|
['ingredient_id' => $ing->id, 'batch_key' => 'b:'.$supplier->id.':AP27-CHARGE', 'quantity_used' => '150'],
|
|
],
|
|
'outputs' => [
|
|
['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null],
|
|
],
|
|
]);
|
|
|
|
$response->assertSessionHasNoErrors();
|
|
$response->assertRedirect();
|
|
|
|
$production = Production::query()->where('product_id', $product->id)->firstOrFail();
|
|
$lines = $production->productionIngredients()->orderBy('stock_entry_id')->get();
|
|
|
|
expect($lines)->toHaveCount(2);
|
|
expect($lines[0]->stock_entry_id)->toBe($entryA->id);
|
|
expect((float) $lines[0]->quantity_used)->toBe(100.0);
|
|
expect($lines[1]->stock_entry_id)->toBe($entryB->id);
|
|
expect((float) $lines[1]->quantity_used)->toBe(50.0);
|
|
});
|