Warenwirtschaft: AP-24 Chargen-Nr.-Praefix auf Produktebene

- products.batch_prefix (z. B. TP1) mit Eingabefeld in der Warenwirtschaft-Card
- Product::suggestedBatchNumber(): Praefix + Produktionsdatum ttmmjj
  (TP1 + 15.06.26 => TP1150626), Vorbelegung in der Produktion folgt mit AP-28
- Repository trimmt, leeres Praefix => null

Tests: BatchPrefixTest (6 gruen), volle Suite 184 gruen, 657 Assertions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 12:09:41 +00:00
parent 97d420c98a
commit 209ea467eb
6 changed files with 136 additions and 4 deletions

View file

@ -267,6 +267,7 @@ class Product extends Model
'min_product_stock',
'critical_product_stock',
'show_in_product_stock',
'batch_prefix',
];
@ -602,6 +603,21 @@ class Product extends Model
return __('In ca. :days Tagen wieder da!', ['days' => $days]);
}
/**
* AP-24: Chargen-Nr.-Vorschlag = Präfix + Produktionsdatum (ttmmjj),
* z. B. „TP1" + 15.06.26 => „TP1150626". Ohne Präfix kein Vorschlag.
*/
public function suggestedBatchNumber(?\Carbon\Carbon $date = null): ?string
{
$prefix = trim((string) $this->batch_prefix);
if ($prefix === '') {
return null;
}
return $prefix.($date ?? now())->format('dmy');
}
public function getFormattedPriceEk()
{
return isset($this->attributes['price_ek']) ? Util::formatNumber($this->attributes['price_ek']) : '';

View file

@ -51,6 +51,11 @@ class ProductRepository extends BaseRepository
// AP-22: Sichtbarkeit im Produktbestand (Checkbox, Default sichtbar).
$data['show_in_product_stock'] = isset($data['show_in_product_stock']) ? 1 : 0;
// AP-24: Chargen-Nr.-Präfix (leer => null).
$data['batch_prefix'] = isset($data['batch_prefix']) && trim((string) $data['batch_prefix']) !== ''
? trim((string) $data['batch_prefix'])
: null;
// AP-11: Produktbestand-Schwellwerte (leer => null).
$data['min_product_stock'] = isset($data['min_product_stock']) && $data['min_product_stock'] !== ''
? max(0, (int) $data['min_product_stock'])

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* AP-24: Chargen-Nr.-Präfix auf Produktebene (Kategorie-Kürzel + Produkt-Nr.,
* z. B. „TP1"). Die Produktion schlägt daraus Präfix + Produktionsdatum
* (ttmmjj) als Chargen-Nr. vor, z. B. TP1150626.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->string('batch_prefix', 20)->nullable()->after('show_in_product_stock');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('batch_prefix');
});
}
};

View file

@ -102,8 +102,8 @@ Alle Punkte dieser älteren Runde sind in V4.0 eingeflossen und umgesetzt (URL-F
| 3 | ✅ AP-21 | INCI-Erweiterungen (Lieferant+URL-Repeater, „nur aktive", Alternativname, Lagerort + 3 Stammdaten-CRUDs) — **erledigt 06.07.2026** | OPT-2 | 2–3,5 Tage |
| 4 | ✅ AP-20 | Rohstoffbestand-Filter + „Kein Rohstoffbestand"-Flag — **erledigt 06.07.2026** | AP-21 | 1–2 Tage |
| 5 | ✅ AP-23 | Rezeptur-Phasen (Hersteller-Rezeptur) — **erledigt 06.07.2026** | – | 3–5 Tage |
| 6 | ➡️ AP-24 | **NÄCHSTER SCHRITT:** Chargen-Nr.-Präfix | – | 1 Tag |
| 7 | OPT-3 + OPT-4 | Pagination + show()-Refactoring | – | 1,5–2,5 Tage |
| 6 | ✅ AP-24 | Chargen-Nr.-Präfix — **erledigt 06.07.2026** | – | 1 Tag |
| 7 | ➡️ OPT-3 + OPT-4 | **NÄCHSTER SCHRITT:** Pagination + show()-Refactoring | – | 1,5–2,5 Tage |
| 8 | AP-27 | Wareneingang: Chargen-Merge | OPT-4 | 1–2 Tage |
| 9 | AP-28 | Neue Produktions-Maske + Produktionsergebnis | AP-21, AP-23, AP-24, AP-27 | 5–8 Tage |
| 10 | AP-13 | Shop-Bestandsabzug bei Versand (inkl. Sets; Konzept aus V4.0) | AP-28 (saubere Buchungslogik) | 3–5 Tage |
@ -152,11 +152,11 @@ Die fachlichen Detailspezifikationen zu **AP-20, AP-21, AP-23, AP-24, AP-27, AP-
| 06.07.2026 | **AP-21 (INCI-Erweiterungen)** | **DB:** Migrationen `2026_07_06_120752_create_storage_segment_tables` (`storage_rooms`/`storage_shelves`/`storage_slots`, je `label`/`active`/`pos`) + `2026_07_06_120752_add_storage_and_alt_name_to_ingredients_table` (`ingredients.alt_name` + 3 FKs `storage_room/shelf/slot_id`, `nullOnDelete`); auf DB ausgeführt. **Models:** abstrakte Basis `StorageSegment` + `StorageRoom`/`StorageShelf`/`StorageSlot` (+ Factories); `Ingredient` um `alt_name`, Lagerort-FKs, Relationen, `storageLabel()` („Raum 1 \| Regal 3 \| A") und `scopeUsedInActiveRecipes()` (Hersteller-Rezeptur ∧ aktives Produkt) erweitert. **Settings:** 3 CRUDs als `SettingCrudController`-Kinder (OPT-2, je ~25 Zeilen) mit **einem** gemeinsamen `StoreStorageSegmentRequest`; 3 Karten auf Einstellungen → Allgemein über neues generisches Partial `setting-list-card`; gemeinsames Form-Partial `setting-segment-form`; Routen-Resources in der `superadmin`-Gruppe. **INCI-Formular:** Lieferanten-**Repeater** (Zeile = Lieferant-Select + URL-Feld, „+ weiteren Lieferanten anlegen", Entfernen-Button; jQuery-Template) statt Multi-Select — speichert `ingredient_supplier.url` per `sync()` (URL leer ⇒ null ⇒ Mailbestellung; `preferred`/`supplier_sku` bleiben erhalten); Feld „Alternativer Name"; 3 Lagerort-Dropdowns (nur aktive Bausteine). **INCI-Liste:** Checkbox „nur aktive (in Rezeptur aktiver Produkte)" (`?only_active=1`, serverseitig via Scope) + Spalte „Alternativer Name". **Rohstoffbestand-Detail:** Bestellweg jetzt **pro INCI-Lieferant** — Pivot-URL gesetzt ⇒ „Zum Shop", leer ⇒ „Per Mail" (ersetzt `order_method`-Fallback); Kopf zeigt Alternativname + Lagerort. **Hinweise-Doku (AP-18)** fortgeschrieben inkl. Kundenentscheid Chargen-Format `ttmmjj` (für AP-24). | `StorageSettingsTest` (7), `IngredientActiveRecipeFilterTest` (3), `IngredientOrderFieldsTest` (9, umgestellt auf Repeater-Format + URL/Lagerort), `RawMaterialStockTest` +2 (Bestellweg, Alternativname/Lagerort). Volle Suite: **170 grün, 613 Assertions** |
| 06.07.2026 | **AP-20 (Rohstoffbestand-Filter + „Kein Rohstoffbestand")** | Migration `2026_07_06_133714_add_exclude_from_stock_to_ingredients_table` (`ingredients.exclude_from_stock` bool default 0); `Ingredient` fillable+cast; Checkbox „Kein Rohstoffbestand" (Hinweistext Allergen-Angaben) im INCI-Formular. `RawMaterialStockController@index`: grundsätzlich `exclude_from_stock=false`, neuer Umschalt-Modus `?scope=all\|recipe` (Default `all`; `recipe` nutzt `scopeUsedInActiveRecipes()` aus AP-21); Button-Gruppe „Alle / Nur aus Herstellerrezepturen" in der Toolbar. `InventoryService::criticalIngredientCount()` (Sidenav-Badge) respektiert das Flag. Hinweise-Doku fortgeschrieben. **Nebenbei behoben:** die beiden AP-21-Migrationen hatten denselben Timestamp, wodurch `add_storage…` alphabetisch **vor** `create_storage…` lief und auf MySQL am FK scheiterte (Teilreste `alt_name`/`storage_room_id`) — Migration auf `…120753` umbenannt, Teilreste bereinigt, alle drei Migrationen sauber ausgeführt. | `RawMaterialStockTest` +2 (Flag blendet aus Übersicht + Kritisch-Zähler aus; Modus `recipe` filtert, Default zeigt alle, Pills rendern). Volle Suite: **172 grün, 624 Assertions** |
| 06.07.2026 | **AP-23 (Rezeptur-Phasen)** | **DB:** Migration `2026_07_06_134518_create_recipe_phases_table` — Tabelle `recipe_phases` (`product_id` FK cascade als `unsignedInteger` — `products.id` ist historisch int, kein `foreignId`!; `recipe_type` default `manufacturer`, `name`, `note` nullable, `pos`) + `product_ingredients.recipe_phase_id` (nullable FK, `nullOnDelete`); auf DB ausgeführt. **Abweichung zur V5.0-Spec:** kein Unique auf (`product_id`,`recipe_type`,`name`) — frei editierbare Namen sollen beim Speichern nicht crashen; Duplikate sind fachlich unkritisch. **Models:** `RecipePhase` (+ `Product::recipePhases()`, `ProductIngredient::phase()`, Pivot um `recipe_phase_id` erweitert). **Formular** (`admin/product/form.blade.php` + JS in `edit.blade.php`): Hersteller-Rezeptur-Card in **Phasen-Blöcke** umgebaut — je Block editierbarer Name (Platzhalter Phase A/B … aktualisiert sich), Rohstoff-Tabelle (Anteil %/Faktor/Effektiv wie bisher), Phasen-**Notiz**, „+ Rohstoffe hinzufügen" je Phase (gemeinsames Modal mit Ziel-Phase), „+ Neue Phase" (JS-Template), Phase entfernen (mit Warnung bei Zeilen); **Drag&Drop**: Phasen untereinander + Zeilen innerhalb und **zwischen** Phasen (Sortable `group`); 100-%-Summe global über alle Phasen (Anzeige grün/rot wie bisher); `mfg_row_phase[]`-Sync bei Drag/Add/Remove/Submit. **Repository:** `updateManufacturerIngredients()` persistiert Phasen (`persistRecipePhases()`: leerer Name ⇒ Auto „Phase A/B…", einzelner unbenannter Block ohne Notiz ⇒ **phasenlos wie bisher**, `recipe_phase_id=NULL`); `copy()` kopiert Phasen inkl. Zeilen-Zuordnung (ID-Mapping). Produktion (`requiredGramsByIngredient`) rechnet unverändert über alle Zeilen. **Blade-Falle behoben:** inline `@php(…)` (AP-25-Verfügbarkeits-Card) + neuer `@php…@endphp`-Block im selben Template ließen den Compiler beide falsch paaren (Raw-Block von Z. 106 bis 771) — inline-Variante auf Block-Form umgestellt. Hinweise-Doku fortgeschrieben. | Neuer `tests/Feature/RecipePhaseTest.php` (6 grün, 25 Assertions): Phasen+Zeilen+Notiz+Reihenfolge, Auto-Name, implizit phasenlos, Re-Save ersetzt vollständig, Kopie inkl. Zuordnung, Produktion phasenunabhängig. Volle Suite: **178 grün, 649 Assertions** |
| _offen_ | AP-24 | Chargen-Präfix | – |
| 06.07.2026 | **AP-24 (Chargen-Nr.-Präfix)** | Migration `2026_07_06_140324_add_batch_prefix_to_products_table` (`products.batch_prefix` VARCHAR(20) nullable); auf DB ausgeführt. `Product` fillable + Helper `suggestedBatchNumber(?Carbon $date)` = Präfix + Datum `ttmmjj` (Beispiel `TP1` + 15.06.26 ⇒ `TP1150626`; ohne Präfix ⇒ null; Default-Datum heute). Eingabefeld „Chargen-Nr.-Präfix" in der Warenwirtschaft-Card des Produktformulars (Hilfetext mit Beispiel); `ProductRepository::update()` trimmt (leer ⇒ null). Vorbelegung des editierbaren Chargen-Nr.-Felds folgt in AP-28 (Produktionsergebnis). Chargen-Format war bereits in der Hinweise-Doku dokumentiert (AP-21-Lieferung). | Neuer `tests/Feature/BatchPrefixTest.php` (6 grün): Speichern/Trim, leer ⇒ null, `ttmmjj`-Zusammensetzung, Default heute, ohne Präfix null, Formular-Render. Volle Suite: **184 grün, 657 Assertions** |
| _offen_ | OPT-3/OPT-4 | Pagination + show()-Refactoring | – |
| _offen_ | AP-27 | Chargen-Merge Wareneingang | – |
| _offen_ | AP-28 | Neue Produktions-Maske + Produktionsergebnis | – |
| _offen_ | AP-13 | Shop-Bestandsabzug | – |
| _offen_ | AP-14/15/16/17 | Audit-Trail, Blockrechte, 2FA, WaWi-Einstellungen | – |
> **➡️ HIER GEHT ES WEITER: AP-24 (Chargen-Nr.-Präfix)** gemäß V5.0-Spezifikation: `products.batch_prefix` (Migration; **Achtung:** `products.id` ist int unsigned — bei FKs kein `foreignId`), Eingabefeld in der Warenwirtschaft-Card des Produktformulars, Helper `Product::suggestedBatchNumber(?Carbon $date)` = `batch_prefix` + Datum `ttmmjj` (Kundenentscheid dokumentiert, §4 Punkt 2). Danach OPT-3/OPT-4 → AP-27 → AP-28 (nutzt die Vorbelegung im Produktionsergebnis).
> **➡️ HIER GEHT ES WEITER: OPT-3 (Pagination der Index-Listen) + OPT-4 (`RawMaterialStockController::show` entschlacken)** gemäß §2. OPT-4 bewusst vor AP-27/AP-28, weil die Chargen-Gruppierung dieselben Stellen anfasst. Danach AP-27 (Wareneingang: Chargen-Merge) → AP-28 (neue Produktions-Maske + Produktionsergebnis; nutzt `suggestedBatchNumber()` aus AP-24, Phasen aus AP-23, Lagerort/Alternativname aus AP-21).

View file

@ -1105,6 +1105,12 @@
<p class="text-muted small mb-0">{{ __('Abwählen für Positionen ohne Lagerführung (z. B. Abrechnung Druckkosten, Logo-Etiketten).') }}</p>
</div>
<div class="form-group col-sm-6 px-0">
<label class="form-label" for="batch_prefix">{{ __('Chargen-Nr.-Präfix') }}</label>
{{ Form::text('batch_prefix', old('batch_prefix', $product->batch_prefix), ['placeholder' => __('z. B. TP1'), 'class' => 'form-control', 'id' => 'batch_prefix', 'maxlength' => 20]) }}
<small class="text-muted">{{ __('Kategorie-Kürzel + Produkt-Nr. (z. B. TP1). Die Produktion schlägt daraus Präfix + Produktionsdatum vor: TP1 → Charge TP1150626 (15.06.26). Der Vorschlag bleibt editierbar.') }}</small>
</div>
<p class="text-muted small mb-2">{{ __('Produktbestand-Schwellwerte (Stück). Bei Erreichen wird das Produkt im Produktbestand farblich markiert.') }}</p>
<div class="form-row">
<div class="form-group col-sm-6">

View file

@ -0,0 +1,78 @@
<?php
use App\Models\Product;
use App\Repositories\ProductRepository;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('chargen-praefix wird am produkt gespeichert und getrimmt', function () {
$product = Product::query()->create(['name' => 'AP24-Tattoocreme', 'title' => 'AP24-Tattoocreme', 'active' => true]);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'batch_prefix' => ' TP1 ',
]);
expect($product->fresh()->batch_prefix)->toBe('TP1');
});
test('leeres praefix wird als null gespeichert', function () {
$product = Product::query()->create(['name' => 'AP24-Leer', 'title' => 'AP24-Leer', 'active' => true, 'batch_prefix' => 'ALT1']);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'batch_prefix' => ' ',
]);
expect($product->fresh()->batch_prefix)->toBeNull();
});
test('suggestedBatchNumber setzt praefix und datum ttmmjj zusammen', function () {
$product = Product::query()->create(['name' => 'AP24-Vorschlag', 'title' => 'AP24-Vorschlag', 'active' => true, 'batch_prefix' => 'TP1']);
// Beispiel aus der Anforderung: TP1 + 15.06.2026 => TP1150626
expect($product->suggestedBatchNumber(Carbon::create(2026, 6, 15)))->toBe('TP1150626');
});
test('suggestedBatchNumber nutzt heute als default-datum', function () {
$this->travelTo(Carbon::create(2026, 7, 6));
$product = Product::query()->create(['name' => 'AP24-Heute', 'title' => 'AP24-Heute', 'active' => true, 'batch_prefix' => 'KP1']);
expect($product->suggestedBatchNumber())->toBe('KP1060726');
});
test('ohne praefix gibt es keinen vorschlag', function () {
$product = Product::query()->create(['name' => 'AP24-Ohne', 'title' => 'AP24-Ohne', 'active' => true]);
expect($product->suggestedBatchNumber(Carbon::create(2026, 6, 15)))->toBeNull();
});
test('produktformular zeigt das praefix-feld', function () {
$user = User::query()->create([
'email' => uniqid('ap24_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
$product = Product::query()->create(['name' => 'AP24-Form', 'title' => 'AP24-Form', 'active' => true, 'batch_prefix' => 'TP1']);
$this->actingAs($user->fresh(), 'user');
$response = $this->get(route('admin_product_edit', $product->id));
$response->assertSuccessful();
$response->assertSee('Chargen-Nr.-Präfix');
$response->assertSee('value="TP1"', false);
});