Warenwirtschaft: Anforderungsrunde 12.06. — Plan V5.0 + AP-26/AP-25/AP-22
Neue Anforderungen (docs/) interpretiert und als Entwicklungsplan V5.0 (AP-20 bis AP-28) aufgenommen; erste drei Pakete umgesetzt: AP-26 Ausschuss-Gründe konfigurierbar: - Stammdaten-Tabelle disposal_reasons + CRUD unter Einstellungen → Allgemein - StockDisposalController liest aktive DB-Gründe statt hartkodierter Liste - Seeder übernimmt die bisherigen 6 Gründe idempotent AP-25 Lieferbestand — Datum statt Tage: - "Nicht vorrätig" wird über Datepicker "Wieder lieferbar ab" gepflegt; Resttage-Hinweis zählt täglich automatisch herunter - Interne Bestellliste wieder kaufbar: Hinweis erscheint zusätzlich zu den Mengen-Buttons (VP entscheidet selbst) AP-22 Produktbestand-Erweiterungen: - Default-Sortierung nach Dringlichkeit, Status-Kopf toggelt - Alle vier Status-Kacheln als Filter klickbar - Neue Spalte "Verbrauch/Monat" (Ø Abgänge der letzten 6 Monate) - Produkt-Flag "Im Produktbestand anzeigen" (products.show_in_product_stock) Tests: 77 grün (DisposalReasonSettings 8, ProductOutOfStock 8, ProductStock 13 + Regression). Hinweise-Doku + Plan-Protokoll fortgeschrieben; nächster Schritt laut Plan: AP-21 (INCI-Erweiterungen). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a8f6fef38e
commit
e53201f229
32 changed files with 1377 additions and 94 deletions
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Inventory\StoreDisposalReasonRequest;
|
||||
use App\Http\Requests\Inventory\UpdateDisposalReasonRequest;
|
||||
use App\Models\DisposalReason;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class DisposalReasonController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.inventory.disposal-reasons.form', [
|
||||
'model' => new DisposalReason(['active' => true, 'pos' => 0]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreDisposalReasonRequest $request): RedirectResponse
|
||||
{
|
||||
DisposalReason::create($request->validated());
|
||||
|
||||
\Session::flash('alert-save', '1');
|
||||
|
||||
return redirect()->route('admin.inventory.general');
|
||||
}
|
||||
|
||||
public function edit(DisposalReason $disposalReason): View
|
||||
{
|
||||
return view('admin.inventory.disposal-reasons.form', [
|
||||
'model' => $disposalReason,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateDisposalReasonRequest $request, DisposalReason $disposalReason): RedirectResponse
|
||||
{
|
||||
$disposalReason->update($request->validated());
|
||||
|
||||
\Session::flash('alert-save', '1');
|
||||
|
||||
return redirect()->route('admin.inventory.general');
|
||||
}
|
||||
|
||||
public function destroy(DisposalReason $disposalReason): RedirectResponse
|
||||
{
|
||||
$disposalReason->delete();
|
||||
|
||||
\Session::flash('alert-success', __('Eintrag gelöscht'));
|
||||
|
||||
return redirect()->route('admin.inventory.general');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin\Inventory;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DeliveryTime;
|
||||
use App\Models\DisposalReason;
|
||||
use App\Models\TaxRate;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ class GeneralSettingController extends Controller
|
|||
return view('admin.inventory.general.index', [
|
||||
'taxRates' => TaxRate::query()->orderBy('pos')->orderBy('percent')->get(),
|
||||
'deliveryTimes' => DeliveryTime::query()->orderBy('pos')->orderBy('label')->get(),
|
||||
'disposalReasons' => DisposalReason::query()->orderBy('pos')->orderBy('label')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,27 +22,40 @@ class ProductStockController extends Controller
|
|||
$products = Product::query()
|
||||
->where('active', true)
|
||||
->where('is_set', false)
|
||||
->where('show_in_product_stock', true)
|
||||
->whereNull('main_product_id')
|
||||
->with('images')
|
||||
->orderBy('pos')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$stock = $this->productStockService->currentStockByProduct($products->pluck('id')->all());
|
||||
$ids = $products->pluck('id')->all();
|
||||
$stock = $this->productStockService->currentStockByProduct($ids);
|
||||
$consumption = $this->productStockService->monthlyConsumptionByProduct($ids);
|
||||
|
||||
$rows = $products->map(function (Product $product) use ($stock) {
|
||||
$statusRank = ['critical' => 0, 'warning' => 1, 'ok' => 2];
|
||||
|
||||
$rows = $products->map(function (Product $product) use ($stock, $consumption) {
|
||||
$current = $stock[$product->id] ?? 0;
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'stock' => $current,
|
||||
'monthly_consumption' => $consumption[$product->id] ?? 0.0,
|
||||
'status' => $this->productStockService->productStatus(
|
||||
$current,
|
||||
$product->min_product_stock,
|
||||
$product->critical_product_stock,
|
||||
),
|
||||
];
|
||||
});
|
||||
})
|
||||
// AP-22: Default-Sortierung nach Dringlichkeit (kritisch → niedrig → ok), innerhalb nach Bestand/Name.
|
||||
->sortBy([
|
||||
fn (array $a, array $b) => ($statusRank[$a['status']] <=> $statusRank[$b['status']])
|
||||
?: ($a['stock'] <=> $b['stock'])
|
||||
?: strcasecmp($a['product']->name, $b['product']->name),
|
||||
])
|
||||
->values();
|
||||
|
||||
return view('admin.inventory.product-stock.index', [
|
||||
'rows' => $rows,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin\Inventory;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Inventory\StoreStockDisposalRequest;
|
||||
use App\Models\DisposalReason;
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Location;
|
||||
use App\Models\StockDisposal;
|
||||
|
|
@ -109,17 +110,17 @@ class StockDisposalController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* Aktive Ausschuss-Gründe aus den Einstellungen (Warenwirtschaft → Allgemein).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function reasons(): array
|
||||
{
|
||||
return [
|
||||
__('Bruch / Beschädigung'),
|
||||
__('Verfall / MHD überschritten'),
|
||||
__('Qualitätsmangel'),
|
||||
__('Schwund / Inventurdifferenz'),
|
||||
__('Muster / Testverbrauch'),
|
||||
__('Sonstiges'),
|
||||
];
|
||||
return DisposalReason::query()
|
||||
->active()
|
||||
->orderBy('pos')
|
||||
->orderBy('label')
|
||||
->pluck('label')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,21 +319,23 @@ class OrderController extends Controller
|
|||
$qty = isset($cartItem->qty) ? $cartItem->qty : 0;
|
||||
$rowId = isset($cartItem->rowId) ? $cartItem->rowId : '';
|
||||
|
||||
// AP-25: „Nicht vorrätig" ist nur ein Hinweis — der VP entscheidet selbst,
|
||||
// ob er die Ware später bekommt. Die Mengen-Buttons bleiben immer aktiv.
|
||||
$controls = '';
|
||||
if ($product->isOutOfStock()) {
|
||||
$controls = '<div class="product-stock-hint">'.e($product->outOfStockNotice()).'</div>';
|
||||
} else {
|
||||
$controls = '<div class="no-line-break input-group-min-w">
|
||||
<div class="input-group d-inline-flex w-auto">
|
||||
<span class="input-group-prepend">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">-</button>
|
||||
</span>
|
||||
<input type="text" class="form-control text-center input-extra table-input-event-onchange" name="product_qty_'.$product->id.'" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'" value="'.$qty.'">
|
||||
<span class="input-group-append">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>';
|
||||
$controls .= '<div class="product-stock-hint">'.e($product->outOfStockNotice()).'</div>';
|
||||
}
|
||||
$controls .= '<div class="no-line-break input-group-min-w">
|
||||
<div class="input-group d-inline-flex w-auto">
|
||||
<span class="input-group-prepend">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">-</button>
|
||||
</span>
|
||||
<input type="text" class="form-control text-center input-extra table-input-event-onchange" name="product_qty_'.$product->id.'" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'" value="'.$qty.'">
|
||||
<span class="input-group-append">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>';
|
||||
|
||||
return '<strong>'.$product->name.'</strong><br>'.$controls;
|
||||
})
|
||||
|
|
|
|||
33
app/Http/Requests/Inventory/StoreDisposalReasonRequest.php
Normal file
33
app/Http/Requests/Inventory/StoreDisposalReasonRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Inventory;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreDisposalReasonRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'label' => ['required', 'string', 'max:100'],
|
||||
'active' => ['sometimes', 'boolean'],
|
||||
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'active' => $this->boolean('active'),
|
||||
'pos' => $this->input('pos', '') === '' ? 0 : $this->input('pos'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Inventory;
|
||||
|
||||
class UpdateDisposalReasonRequest extends StoreDisposalReasonRequest {}
|
||||
39
app/Models/DisposalReason.php
Normal file
39
app/Models/DisposalReason.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\DisposalReasonFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DisposalReason extends Model
|
||||
{
|
||||
/** @use HasFactory<DisposalReasonFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'label',
|
||||
'active',
|
||||
'pos',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<DisposalReason> $query
|
||||
* @return Builder<DisposalReason>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('active', true);
|
||||
}
|
||||
}
|
||||
|
|
@ -210,6 +210,7 @@ class Product extends Model
|
|||
'out_of_stock_indefinite' => 'bool',
|
||||
'min_product_stock' => 'int',
|
||||
'critical_product_stock' => 'int',
|
||||
'show_in_product_stock' => 'bool',
|
||||
];
|
||||
|
||||
use Sluggable;
|
||||
|
|
@ -265,6 +266,7 @@ class Product extends Model
|
|||
'out_of_stock_indefinite',
|
||||
'min_product_stock',
|
||||
'critical_product_stock',
|
||||
'show_in_product_stock',
|
||||
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Models\ProductCategory;
|
|||
use App\Models\ProductImage;
|
||||
use App\Models\ProductIngredient;
|
||||
use App\Services\Slim;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ProductRepository extends BaseRepository
|
||||
|
|
@ -46,6 +47,9 @@ class ProductRepository extends BaseRepository
|
|||
? (int) $data['main_product_id']
|
||||
: null;
|
||||
|
||||
// AP-22: Sichtbarkeit im Produktbestand (Checkbox, Default sichtbar).
|
||||
$data['show_in_product_stock'] = isset($data['show_in_product_stock']) ? 1 : 0;
|
||||
|
||||
// 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'])
|
||||
|
|
@ -54,14 +58,18 @@ class ProductRepository extends BaseRepository
|
|||
? max(0, (int) $data['critical_product_stock'])
|
||||
: null;
|
||||
|
||||
// AP-03: „Nicht vorrätig"-Status. „Unbestimmt" hat Vorrang vor der Tagesangabe.
|
||||
// AP-03/AP-25: „Nicht vorrätig"-Status. „Unbestimmt" hat Vorrang vor der Datumsangabe.
|
||||
// Eingabe als festes Datum („Wieder lieferbar ab", dd.mm.yyyy); Resttage werden daraus berechnet.
|
||||
$data['out_of_stock_indefinite'] = isset($data['out_of_stock_indefinite']) ? 1 : 0;
|
||||
|
||||
if ($data['out_of_stock_indefinite']) {
|
||||
$data['out_of_stock_until'] = null;
|
||||
} elseif (isset($data['out_of_stock_active']) && isset($data['out_of_stock_days']) && $data['out_of_stock_days'] !== '') {
|
||||
$days = max(0, (int) $data['out_of_stock_days']);
|
||||
$data['out_of_stock_until'] = now()->addDays($days)->startOfDay();
|
||||
} elseif (isset($data['out_of_stock_active']) && isset($data['out_of_stock_date']) && $data['out_of_stock_date'] !== '') {
|
||||
try {
|
||||
$data['out_of_stock_until'] = Carbon::createFromFormat('d.m.Y', trim($data['out_of_stock_date']))->startOfDay();
|
||||
} catch (\Throwable) {
|
||||
$data['out_of_stock_until'] = null;
|
||||
}
|
||||
} else {
|
||||
$data['out_of_stock_until'] = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,37 @@ class ProductStockService
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchschnittlicher Verbrauch pro Monat je Produkt (Ø der letzten X Monate, Default 6).
|
||||
*
|
||||
* „Verbrauch" = alle Abgänge (direction=out) außer Produktions-Gegenbuchungen
|
||||
* (source=production sind Korrekturen der Eingangs-Buchung, kein echter Abgang).
|
||||
* Mit AP-13 fließen Verkaufs-Abgänge (source=sale) automatisch mit ein.
|
||||
*
|
||||
* @param array<int, int>|null $productIds
|
||||
* @return array<int, float> [product_id => Stück/Monat]
|
||||
*/
|
||||
public function monthlyConsumptionByProduct(?array $productIds = null, int $months = 6): array
|
||||
{
|
||||
$months = max(1, $months);
|
||||
|
||||
$rows = ProductStockMovement::query()
|
||||
->when($productIds !== null, fn ($q) => $q->whereIn('product_id', $productIds))
|
||||
->where('direction', 'out')
|
||||
->where('source', '!=', 'production')
|
||||
->where('created_at', '>=', now()->subMonths($months))
|
||||
->selectRaw('product_id, SUM(quantity) as consumed')
|
||||
->groupBy('product_id')
|
||||
->pluck('consumed', 'product_id');
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $id => $consumed) {
|
||||
$result[(int) $id] = round(((int) $consumed) / $months, 1);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestands-Status: "critical" (rot) ≤ kritischer Schwellwert, "warning" (gelb) ≤ Meldebestand, sonst "ok".
|
||||
*/
|
||||
|
|
@ -129,6 +160,7 @@ class ProductStockService
|
|||
$products = Product::query()
|
||||
->where('active', true)
|
||||
->where('is_set', false)
|
||||
->where('show_in_product_stock', true)
|
||||
->whereNull('main_product_id')
|
||||
->whereNotNull('critical_product_stock')
|
||||
->get(['id', 'critical_product_stock']);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue