Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)

- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService)
- AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController)
- AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop
- AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService)
- Set-Produkte (product_set_items) inkl. Aufloesung
- Produktentwicklung & Hinweise-Verwaltung (Notices)
- AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert
- Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-06-03 11:04:22 +00:00
parent 78679e0c55
commit 3ee2d756e9
63 changed files with 5968 additions and 901 deletions

View file

@ -5,8 +5,10 @@ namespace App\Models;
use App\Services\Type;
use App\Services\Util;
use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -200,6 +202,14 @@ class Product extends Model
'max_buy' => 'bool',
'max_buy_num' => 'int',
'whitelabel' => 'bool',
'no_recipe_required' => 'bool',
'is_set' => 'bool',
'main_product_id' => 'int',
'main_product_quantity' => 'int',
'out_of_stock_until' => 'date',
'out_of_stock_indefinite' => 'bool',
'min_product_stock' => 'int',
'critical_product_stock' => 'int',
];
use Sluggable;
@ -247,6 +257,14 @@ class Product extends Model
'max_buy_num',
'shelf_life_type',
'shelf_life_months',
'no_recipe_required',
'is_set',
'main_product_id',
'main_product_quantity',
'out_of_stock_until',
'out_of_stock_indefinite',
'min_product_stock',
'critical_product_stock',
];
@ -369,6 +387,94 @@ class Product extends Model
return $this->hasMany(Production::class);
}
/**
* Produktbestands-Bewegungen (Eingang/Ausgang).
*
* @return HasMany<ProductStockMovement, $this>
*/
public function stockMovements(): HasMany
{
return $this->hasMany(ProductStockMovement::class);
}
/**
* Set-Bestandteile (Einzelprodukte) dieses Sets mit Menge und Reihenfolge.
*
* @return BelongsToMany<Product, $this>
*/
public function setItems(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'product_set_items', 'set_product_id', 'component_product_id')
->withPivot('quantity', 'pos')
->withTimestamps()
->orderByPivot('pos');
}
/**
* Sets, in denen dieses Produkt als Bestandteil enthalten ist.
*
* @return BelongsToMany<Product, $this>
*/
public function partOfSets(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'product_set_items', 'component_product_id', 'set_product_id')
->withPivot('quantity', 'pos')
->withTimestamps();
}
/**
* Übergeordnetes Hauptprodukt (z. B. „50 × 15 ml").
*
* @return BelongsTo<Product, $this>
*/
public function mainProduct(): BelongsTo
{
return $this->belongsTo(Product::class, 'main_product_id');
}
/**
* Untergeordnete Varianten, die auf dieses Produkt als Hauptprodukt zeigen.
*
* @return HasMany<Product, $this>
*/
public function variants(): HasMany
{
return $this->hasMany(Product::class, 'main_product_id');
}
/**
* Nur Einzelprodukte (keine Sets).
*
* @param Builder<Product> $query
* @return Builder<Product>
*/
public function scopeSingleProducts(Builder $query): Builder
{
return $query->where('is_set', false);
}
/**
* Sets.
*
* @param Builder<Product> $query
* @return Builder<Product>
*/
public function scopeSets(Builder $query): Builder
{
return $query->where('is_set', true);
}
/**
* Haupt-/Einzelprodukte, die keinem übergeordneten Hauptprodukt zugeordnet sind.
*
* @param Builder<Product> $query
* @return Builder<Product>
*/
public function scopeMainProducts(Builder $query): Builder
{
return $query->whereNull('main_product_id');
}
public function getShortCopy($clean = false, $len = false)
{
$ret = $this->short_copy ? $this->short_copy : $this->description;
@ -437,6 +543,53 @@ class Product extends Model
return isset($this->attributes['price']) ? Util::formatNumber($this->attributes['price']) : '';
}
public function isOutOfStock(): bool
{
if ($this->out_of_stock_indefinite) {
return true;
}
return $this->out_of_stock_until !== null && $this->out_of_stock_until->endOfDay()->isFuture();
}
public function outOfStockRemainingDays(): ?int
{
if ($this->out_of_stock_indefinite || $this->out_of_stock_until === null) {
return null;
}
if (! $this->out_of_stock_until->endOfDay()->isFuture()) {
return null;
}
$diffSeconds = $this->out_of_stock_until->copy()->startOfDay()->getTimestamp() - now()->startOfDay()->getTimestamp();
return (int) max(0, (int) round($diffSeconds / 86400));
}
public function outOfStockNotice(): ?string
{
if ($this->out_of_stock_indefinite) {
return __('Zur Zeit nicht vorrätig');
}
$days = $this->outOfStockRemainingDays();
if ($days === null) {
return null;
}
if ($days <= 0) {
return __('In Kürze wieder verfügbar');
}
if ($days === 1) {
return __('In ca. 1 Tag wieder da!');
}
return __('In ca. :days Tagen wieder da!', ['days' => $days]);
}
public function getFormattedPriceEk()
{
return isset($this->attributes['price_ek']) ? Util::formatNumber($this->attributes['price_ek']) : '';

View file

@ -0,0 +1,70 @@
<?php
namespace App\Models;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ProductStockMovement extends Model
{
protected $fillable = [
'product_id',
'direction',
'quantity',
'reason',
'source',
'note',
'user_id',
'reference_type',
'reference_id',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'quantity' => 'integer',
];
}
/**
* @return BelongsTo<Product, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return MorphTo<Model, $this>
*/
public function reference(): MorphTo
{
return $this->morphTo();
}
public function isIn(): bool
{
return $this->direction === 'in';
}
/**
* Vorzeichenbehaftete Menge (Eingang positiv, Ausgang negativ).
*/
public function signedQuantity(): int
{
return $this->isIn() ? (int) $this->quantity : -(int) $this->quantity;
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Models;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StockDisposal extends Model
{
protected $fillable = [
'disposal_type',
'ingredient_id',
'packaging_item_id',
'stock_entry_id',
'location_id',
'quantity',
'unit',
'reason',
'note',
'user_id',
'disposed_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'disposed_at' => 'date',
'quantity' => 'decimal:2',
];
}
/**
* @return BelongsTo<Ingredient, $this>
*/
public function ingredient(): BelongsTo
{
return $this->belongsTo(Ingredient::class);
}
/**
* @return BelongsTo<PackagingItem, $this>
*/
public function packagingItem(): BelongsTo
{
return $this->belongsTo(PackagingItem::class);
}
/**
* @return BelongsTo<StockEntry, $this>
*/
public function stockEntry(): BelongsTo
{
return $this->belongsTo(StockEntry::class);
}
/**
* @return BelongsTo<Location, $this>
*/
public function location(): BelongsTo
{
return $this->belongsTo(Location::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isIngredient(): bool
{
return $this->disposal_type === 'ingredient';
}
public function articleName(): string
{
return $this->isIngredient()
? ($this->ingredient?->name ?? '—')
: ($this->packagingItem?->name ?? '—');
}
}