Warenwirtschaft: Audit-Niedrigbefunde N-2/3/4/5/7/8/9/15

- N-2: Letzter Netto-Preis je Lieferant nur aus tatsächlichen
  Eingängen (status received).
- N-3: Verbrauchsfenster exakt (kein startOfDay-Off-by-one).
- N-4: Kritisch-Zählung vereinheitlicht — Controller liefert die
  Kachel-Zählweise (inkl. critical_ordered), tote View-Überschreibung
  entfernt; Sidenav-Badge bleibt bewusst ohne bestellte Kritische.
- N-5: Ausschuss-Liste zeigt das 500er-Limit an + "Alle anzeigen"
  (?all=1), analog OPT-3.
- N-7: parseQuantity parst Strings konsequent im deutschen Format —
  Server und UI-Ampel interpretieren "1.500" jetzt identisch.
- N-8: Produktions-Kopie übernimmt keine alten Chargen-Nummern mehr
  (Auto-Vorschlag Präfix+ttmmjj greift).
- N-9: recordProductionStock lädt Produkte withTrashed — Korrektur-
  Gegenbuchungen soft-gelöschter Output-Produkte werden nicht mehr
  übersprungen.
- N-15: Hinweise/Leitfaden-Markdown mit html_input=strip +
  allow_unsafe_links=false gerendert.

Bewusst offen (Kundenentscheid/Design): N-1 (Chargen-Gruppierung ohne
supplier_id), N-6 (reFormatNumber-Konvention app-weit), N-10 (manuelle
Bewegung darf ins Minus, Inventur-Korrekturen), N-13 (mfg_row_phase-
Fallback auf phasenlos).

Suite: 231 grün, 823 Assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-15 12:57:26 +00:00
parent 9ed47c02f3
commit d34f0a77f4
10 changed files with 35 additions and 8 deletions

View file

@ -37,7 +37,8 @@ class NoticeController extends Controller
return view('admin.inventory.notices.index', [
'title' => $title,
'subtitle' => $subtitle,
'content' => Str::markdown($markdown),
// Audit N-15: Roh-HTML im Markdown strippen (Defense-in-Depth).
'content' => Str::markdown($markdown, ['html_input' => 'strip', 'allow_unsafe_links' => false]),
]);
}
}

View file

@ -181,6 +181,11 @@ class ProductionController extends Controller
'outputs',
]);
// Audit N-8: Chargen-Nummern nicht in die Kopie übernehmen — das neue
// Produktionsdatum erzeugt sonst Duplikat-Chargen mit falschem Datum;
// leer ⇒ JS-Vorschlag (Präfix + ttmmjj) greift.
$production->outputs->each(fn ($output) => $output->setAttribute('batch_number', null));
$defaultLocationId = $production->location_id;
return view('admin.inventory.productions.create', [

View file

@ -59,7 +59,8 @@ class RawMaterialStockController extends Controller
return view('admin.inventory.raw-material-stock.index', [
'rows' => $rows,
'criticalCount' => $rows->where('status', 'critical')->count(),
// Audit N-4: gleiche Zählweise wie die Kacheln (kritisch inkl. bereits bestellt).
'criticalCount' => $rows->whereIn('status', ['critical', 'critical_ordered'])->count(),
'horizonOptions' => $this->horizonOptions(),
'defaultHorizon' => 90,
'scope' => $scope,

View file

@ -32,9 +32,13 @@ class StockDisposalController extends Controller
$query->where('disposal_type', $request->query('type'));
}
// Audit N-5: Limit sichtbar machen statt still abzuschneiden (analog OPT-3).
$limit = $request->boolean('all') ? null : 500;
return view('admin.inventory.stock-disposals.index', [
'values' => $query->limit(500)->get(),
'values' => $limit !== null ? $query->limit($limit)->get() : $query->get(),
'typeFilter' => $request->query('type'),
'listLimit' => $limit,
]);
}

View file

@ -130,6 +130,9 @@ class StockEntryRepository
$ranked = StockEntry::query()
->selectRaw('supplier_id, price_per_kg, ROW_NUMBER() OVER (PARTITION BY supplier_id ORDER BY ordered_at DESC, id DESC) as rn')
->where('entry_type', 'ingredient')
// Audit N-2: nur tatsächliche Eingänge — offene, evtl. nie gelieferte
// Bestellungen bestimmen den letzten Einkaufspreis nicht mehr.
->where('status', 'received')
->where('ingredient_id', $ingredientId)
->whereNotNull('supplier_id')
->whereNotNull('price_per_kg');

View file

@ -241,8 +241,10 @@ class InventoryService
*/
public function dailyConsumptionByIngredient(?array $ingredientIds = null, int $windowDays = self::CONSUMPTION_WINDOW_DAYS): array
{
// Audit N-3: exaktes Fenster — startOfDay() verlängerte den Zeitraum um
// den angebrochenen heutigen Tag (Tagesverbrauch bis ~1 % überschätzt).
$windowDays = max(1, $windowDays);
$since = Carbon::now()->subDays($windowDays)->startOfDay();
$since = Carbon::now()->subDays($windowDays);
$rows = ProductionIngredient::query()
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')

View file

@ -105,7 +105,10 @@ class ProductStockService
return;
}
$products = Product::query()->whereIn('id', $productIds)->get()->keyBy('id');
// Audit N-9: withTrashed — Gegenbuchungen entfernter Output-Produkte
// dürfen nicht still übersprungen werden, wenn das Produkt inzwischen
// soft-gelöscht wurde.
$products = Product::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id');
foreach ($productIds as $productId) {
$alreadyBooked = (int) ($booked[$productId] ?? 0);

View file

@ -629,12 +629,15 @@ class ProductionService
if ($value === null || $value === '') {
return 0.0;
}
if (is_numeric($value)) {
// Audit N-7: Zahlen (interne Aufrufer) direkt; Strings konsequent im
// deutschen Format parsen — vorher wich „1.500" (Server: 1,5) von der
// UI-Ampel (1500) ab.
if (is_int($value) || is_float($value)) {
return (float) $value;
}
$n = reFormatNumber((string) $value);
return $n !== null && $n !== '' ? (float) $n : 0.0;
return $n !== null && $n !== '' && is_numeric($n) ? (float) $n : 0.0;
}
}

View file

@ -2,7 +2,6 @@
@php
$total = $rows->count();
$criticalCount = $rows->whereIn('status', ['critical', 'critical_ordered'])->count();
$warningCount = $rows->where('status', 'warning')->count();
$okCount = $total - $criticalCount - $warningCount;
@endphp

View file

@ -95,6 +95,12 @@
</tbody>
</table>
</div>
@if(($listLimit ?? null) !== null && $values->count() >= $listLimit)
<p class="text-muted small px-3 pb-2 mb-0">
{{ __('Es werden die neuesten :n Abgänge angezeigt.', ['n' => $listLimit]) }}
<a href="{{ route('admin.inventory.stock-disposals.index', array_filter(['all' => 1, 'type' => $typeFilter])) }}">{{ __('Alle anzeigen') }}</a>
</p>
@endif
</div>
</div>
@endsection