- M-2: Produktionen buchen Bestand — StoreProductionRequest verlangt
jetzt admin >= 7 (wie manuelle Bestandsbewegungen); create/edit/copy
im Controller gegated, UI-Buttons für niedrigere Stufen ausgeblendet.
- M-3: Ausschuss-/Bewegungs-/Produktions-Buttons nur noch für
Berechtigte sichtbar (keine toten Formulare mehr).
- M-6: Zustandsändernde GET-Routen umgestellt — stock-entries/copy
jetzt POST mit CSRF; /admin/product/delete jetzt DELETE-Formulare
(Produktliste + Rezeptur-Zeile im Produktformular).
- M-7: ProductController::imageAttribute whitelistet Spalten
('active', 'pos') + numerische Werte, sonst 400 — kein beliebiger
Spalten-Write per URL mehr.
- M-10: URL-Felder (Lieferant, Verpackungsartikel, INCI-Lieferanten-
Repeater) validieren url:http,https — keine speicherbaren
javascript:-Links mehr.
Tests: ProductionHardeningTest +2; drei bestehende Tests an neue
Regeln angepasst (admin-Level 7 für Produktions-Formular, POST-Copy).
Suite: 231 grün.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
7.3 KiB
PHP
203 lines
7.3 KiB
PHP
<?php
|
|
|
|
use App\Models\Ingredient;
|
|
use App\Models\Location;
|
|
use App\Models\MaterialQuality;
|
|
use App\Models\StockEntry;
|
|
use App\Models\Supplier;
|
|
use App\Models\TaxRate;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function stockPriceAdmin(): User
|
|
{
|
|
$user = User::query()->create([
|
|
'email' => uniqid('sep_', true).'@test.example',
|
|
'password' => bcrypt('password'),
|
|
]);
|
|
$user->forceFill([
|
|
'admin' => 7,
|
|
'confirmed' => true,
|
|
'active' => true,
|
|
'wizard' => 100,
|
|
'blocked' => false,
|
|
])->save();
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
function stockPriceIngredient(): Ingredient
|
|
{
|
|
return Ingredient::query()->create(['name' => 'INCI-'.uniqid('', true), 'active' => true, 'pos' => 0]);
|
|
}
|
|
|
|
test('net price derives gross from tax rate on store', function () {
|
|
$admin = stockPriceAdmin();
|
|
$ingredient = stockPriceIngredient();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$quality = MaterialQuality::factory()->create();
|
|
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
|
|
|
|
$this->actingAs($admin, 'user');
|
|
|
|
$this->post(route('admin.inventory.stock-entries.store'), [
|
|
'entry_type' => 'ingredient',
|
|
'ingredient_id' => (string) $ingredient->id,
|
|
'supplier_id' => (string) $supplier->id,
|
|
'location_id' => (string) $location->id,
|
|
'quality_id' => (string) $quality->id,
|
|
'tax_rate_id' => (string) $tax->id,
|
|
'ordered_at' => '2026-06-01',
|
|
'ordered_quantity' => '1000',
|
|
'price_per_kg' => '10,00',
|
|
])->assertRedirect(route('admin.inventory.stock-entries.index'));
|
|
|
|
$entry = StockEntry::query()->latest('id')->firstOrFail();
|
|
|
|
expect((float) $entry->price_per_kg)->toBe(10.0)
|
|
->and((float) $entry->price_per_kg_gross)->toBe(11.9)
|
|
->and((float) $entry->tax_rate_percent)->toBe(19.0);
|
|
});
|
|
|
|
test('gross price derives net from tax rate on store', function () {
|
|
$admin = stockPriceAdmin();
|
|
$ingredient = stockPriceIngredient();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$quality = MaterialQuality::factory()->create();
|
|
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
|
|
|
|
$this->actingAs($admin, 'user');
|
|
|
|
$this->post(route('admin.inventory.stock-entries.store'), [
|
|
'entry_type' => 'ingredient',
|
|
'ingredient_id' => (string) $ingredient->id,
|
|
'supplier_id' => (string) $supplier->id,
|
|
'location_id' => (string) $location->id,
|
|
'quality_id' => (string) $quality->id,
|
|
'tax_rate_id' => (string) $tax->id,
|
|
'ordered_at' => '2026-06-01',
|
|
'ordered_quantity' => '1000',
|
|
'price_per_kg_gross' => '11,90',
|
|
])->assertRedirect(route('admin.inventory.stock-entries.index'));
|
|
|
|
$entry = StockEntry::query()->latest('id')->firstOrFail();
|
|
|
|
expect((float) $entry->price_per_kg)->toBe(10.0)
|
|
->and((float) $entry->price_per_kg_gross)->toBe(11.9);
|
|
});
|
|
|
|
test('without tax rate net equals gross', function () {
|
|
$admin = stockPriceAdmin();
|
|
$ingredient = stockPriceIngredient();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$quality = MaterialQuality::factory()->create();
|
|
|
|
$this->actingAs($admin, 'user');
|
|
|
|
$this->post(route('admin.inventory.stock-entries.store'), [
|
|
'entry_type' => 'ingredient',
|
|
'ingredient_id' => (string) $ingredient->id,
|
|
'supplier_id' => (string) $supplier->id,
|
|
'location_id' => (string) $location->id,
|
|
'quality_id' => (string) $quality->id,
|
|
'ordered_at' => '2026-06-01',
|
|
'ordered_quantity' => '500',
|
|
'price_per_kg' => '8,00',
|
|
])->assertRedirect(route('admin.inventory.stock-entries.index'));
|
|
|
|
$entry = StockEntry::query()->latest('id')->firstOrFail();
|
|
|
|
expect((float) $entry->price_per_kg)->toBe(8.0)
|
|
->and((float) $entry->price_per_kg_gross)->toBe(8.0)
|
|
->and($entry->tax_rate_percent)->toBeNull();
|
|
});
|
|
|
|
test('ingredient requires net or gross price', function () {
|
|
$admin = stockPriceAdmin();
|
|
$ingredient = stockPriceIngredient();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$quality = MaterialQuality::factory()->create();
|
|
|
|
$this->actingAs($admin, 'user');
|
|
|
|
$this->post(route('admin.inventory.stock-entries.store'), [
|
|
'entry_type' => 'ingredient',
|
|
'ingredient_id' => (string) $ingredient->id,
|
|
'supplier_id' => (string) $supplier->id,
|
|
'location_id' => (string) $location->id,
|
|
'quality_id' => (string) $quality->id,
|
|
'ordered_at' => '2026-06-01',
|
|
'ordered_quantity' => '500',
|
|
])->assertSessionHasErrors('price_per_kg');
|
|
});
|
|
|
|
test('copy creates pending duplicate without receiving data', function () {
|
|
$admin = stockPriceAdmin();
|
|
$ingredient = stockPriceIngredient();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$quality = MaterialQuality::factory()->create();
|
|
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
|
|
|
|
$source = StockEntry::query()->create([
|
|
'entry_type' => 'ingredient',
|
|
'ingredient_id' => $ingredient->id,
|
|
'supplier_id' => $supplier->id,
|
|
'location_id' => $location->id,
|
|
'unit' => 'gram',
|
|
'ordered_by' => $admin->id,
|
|
'ordered_at' => '2026-01-01',
|
|
'ordered_quantity' => 1000,
|
|
'price_per_kg' => 10,
|
|
'price_per_kg_gross' => 11.9,
|
|
'tax_rate_id' => $tax->id,
|
|
'tax_rate_percent' => 19,
|
|
'quality_id' => $quality->id,
|
|
'received_at' => '2026-01-10',
|
|
'received_quantity' => 1000,
|
|
'batch_number' => 'CHARGE-1',
|
|
'best_before' => '2027-01-01',
|
|
'status' => 'received',
|
|
]);
|
|
|
|
$this->actingAs($admin, 'user');
|
|
|
|
$this->post(route('admin.inventory.stock-entries.copy', $source))
|
|
->assertRedirect();
|
|
|
|
$copy = StockEntry::query()->where('status', 'pending')->latest('id')->firstOrFail();
|
|
|
|
expect($copy->id)->not->toBe($source->id)
|
|
->and($copy->ingredient_id)->toBe($ingredient->id)
|
|
->and($copy->supplier_id)->toBe($supplier->id)
|
|
->and((float) $copy->price_per_kg)->toBe(10.0)
|
|
->and((float) $copy->price_per_kg_gross)->toBe(11.9)
|
|
->and($copy->quality_id)->toBe($quality->id)
|
|
->and($copy->batch_number)->toBeNull()
|
|
->and($copy->best_before)->toBeNull()
|
|
->and($copy->received_quantity)->toBeNull()
|
|
->and($copy->ordered_by)->toBe($admin->id);
|
|
});
|
|
|
|
test('copy is blocked for non admin', function () {
|
|
$admin = stockPriceAdmin();
|
|
$source = StockEntry::factory()->create(['ordered_by' => $admin->id]);
|
|
|
|
$copyreader = User::query()->create([
|
|
'email' => uniqid('cr_', true).'@test.example',
|
|
'password' => bcrypt('password'),
|
|
]);
|
|
$copyreader->forceFill(['admin' => 1, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
|
|
|
|
$this->actingAs($copyreader->fresh(), 'user');
|
|
|
|
$this->post(route('admin.inventory.stock-entries.copy', $source))
|
|
->assertRedirect(route('home'));
|
|
});
|