diff --git a/app/Models/Product.php b/app/Models/Product.php index cff3075..3fa339d 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -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']) : ''; diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 58be35e..87fdab7 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -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']) diff --git a/database/migrations/2026_07_06_140324_add_batch_prefix_to_products_table.php b/database/migrations/2026_07_06_140324_add_batch_prefix_to_products_table.php new file mode 100644 index 0000000..1293148 --- /dev/null +++ b/database/migrations/2026_07_06_140324_add_batch_prefix_to_products_table.php @@ -0,0 +1,27 @@ +string('batch_prefix', 20)->nullable()->after('show_in_product_stock'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('batch_prefix'); + }); + } +}; diff --git a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md index 4c08483..d55fa09 100644 --- a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md +++ b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md @@ -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). diff --git a/resources/views/admin/product/form.blade.php b/resources/views/admin/product/form.blade.php index bf9338d..23fbad7 100755 --- a/resources/views/admin/product/form.blade.php +++ b/resources/views/admin/product/form.blade.php @@ -1105,6 +1105,12 @@

{{ __('Abwählen für Positionen ohne Lagerführung (z. B. Abrechnung Druckkosten, Logo-Etiketten).') }}

+
+ + {{ Form::text('batch_prefix', old('batch_prefix', $product->batch_prefix), ['placeholder' => __('z. B. TP1'), 'class' => 'form-control', 'id' => 'batch_prefix', 'maxlength' => 20]) }} + {{ __('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.') }} +
+

{{ __('Produktbestand-Schwellwerte (Stück). Bei Erreichen wird das Produkt im Produktbestand farblich markiert.') }}

diff --git a/tests/Feature/BatchPrefixTest.php b/tests/Feature/BatchPrefixTest.php new file mode 100644 index 0000000..d2093ae --- /dev/null +++ b/tests/Feature/BatchPrefixTest.php @@ -0,0 +1,78 @@ +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); +});