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:
parent
78679e0c55
commit
3ee2d756e9
63 changed files with 5968 additions and 901 deletions
|
|
@ -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']) : '';
|
||||
|
|
|
|||
70
app/Models/ProductStockMovement.php
Normal file
70
app/Models/ProductStockMovement.php
Normal 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;
|
||||
}
|
||||
}
|
||||
87
app/Models/StockDisposal.php
Normal file
87
app/Models/StockDisposal.php
Normal 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 ?? '—');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue