Kundennachtrag 09.09.2026: Ist-Mengen je Charge in der Produktion sollen auf das Milligramm eingegeben werden können (z. B. 0,125 g). Bisher rundeten Datenbank (decimal 12,2), Bestandslogik, Anzeige und Toleranzsockel auf zwei Stellen; „0,125" wurde still zu 0,13, „0.125" nach der Punkt-Regel zu 125 g. - Migration 2026_09_09_100000: production_ingredients.quantity_used, stock_entries.ordered_quantity/received_quantity, stock_disposals.quantity → decimal(12,3); Casts decimal:3. Auf der Dev-DB (Live-Kopie) ausgeführt, 53/78/7 Zeilen unverändert. - InventoryService::QUANTITY_DECIMALS = 3; roundWawiQuantity() ersetzt alle round(…, 2) in InventoryService, ProductionService, StockDisposalController/-Request, StockEntryRepository/-Controller, RawMaterialStockController. - formatWawiQuantity($v, $min = 2): bis drei Stellen, mindestens zwei — Produktionsdetail, Einkauf Liste/Detail/Formular, Ausschuss-Liste, Chargen-Rest im Rohstoff-Detail. - Eingaberegel (reFormatWawiNumber, JS parseGerman/parseNumber): zusätzlich „0.xxx" als Dezimalzahl; „1.500" bleibt 1500. Produktions-JS: fmt() bis drei Stellen, fmtInput() schreibt gespeicherte Zahlen deutsch ins Feld (1,125 statt 1.125 = 1125). - Soll-Ist-Toleranz: Sockel 0,05 → 0,005 g; Ausschuss min 0,001. - Doku: Hinweise (Neu/geändert), Leitfaden Version 3.1 mit Abschnitt 26, Plan V7 AP-53, README (Deploy-Checkliste, Suite 378). Tests: QuantityPrecisionTest (6) — Parsing, Anzeige, Produktion 0,125 g exakt + Rest 9,875 g, Entwurf 1,125 wiederöffnen, Toleranz Kleinmengen, Ausschuss 0,005 g. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
250 lines
9 KiB
PHP
250 lines
9 KiB
PHP
<?php
|
|
|
|
namespace App\Repositories;
|
|
|
|
use App\Models\Ingredient;
|
|
use App\Models\StockEntry;
|
|
use App\Models\TaxRate;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class StockEntryRepository
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function create(array $data): StockEntry
|
|
{
|
|
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
|
|
$data = $this->applyPurchaseUnit($data);
|
|
$data = $this->resolvePrices($data);
|
|
|
|
return StockEntry::query()->create($data);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function update(StockEntry $stockEntry, array $data): StockEntry
|
|
{
|
|
if (array_key_exists('entry_type', $data)) {
|
|
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
|
|
// N-06: Dichte-Snapshot nur bei unverändertem Rohstoff als Rückfall mitgeben —
|
|
// eine fremde alte Dichte darf bei Rohstoffwechsel nicht übernommen werden.
|
|
if (($data['ordered_unit'] ?? null) === 'ml'
|
|
&& (int) ($data['ingredient_id'] ?? 0) === (int) $stockEntry->ingredient_id
|
|
&& (float) $stockEntry->density_used > 0) {
|
|
$data['density_used'] = (float) $stockEntry->density_used;
|
|
}
|
|
$data = $this->applyPurchaseUnit($data);
|
|
}
|
|
|
|
$data = $this->resolvePrices($data);
|
|
|
|
$stockEntry->update($data);
|
|
|
|
return $stockEntry->fresh();
|
|
}
|
|
|
|
/**
|
|
* AP-42: In ml erfasste Rohstoffmengen über die Dichte in Gramm umrechnen und
|
|
* kanonisch als Gramm speichern (Bestand bleibt in Gramm). purchase_unit +
|
|
* density_used protokollieren die Erfassungseinheit für Anzeige/Bearbeitung.
|
|
* Ohne hinterlegte Dichte gilt die Menge als Gramm.
|
|
*
|
|
* @param array<string, mixed> $data
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function applyPurchaseUnit(array $data): array
|
|
{
|
|
$orderedUnit = $data['ordered_unit'] ?? 'gram';
|
|
// F-15: Dichte-Snapshot der Vorlage (Kopie) als Rückfall, falls die Rohstoff-Dichte
|
|
// inzwischen entfernt wurde — sonst würde die ml-Menge still als Gramm gebucht.
|
|
$fallbackDensity = isset($data['density_used']) && (float) $data['density_used'] > 0 ? (float) $data['density_used'] : null;
|
|
unset($data['ordered_unit']);
|
|
|
|
if (($data['entry_type'] ?? '') !== 'ingredient') {
|
|
$data['purchase_unit'] = null;
|
|
$data['density_used'] = null;
|
|
|
|
return $data;
|
|
}
|
|
|
|
$data['purchase_unit'] = 'gram';
|
|
$data['density_used'] = null;
|
|
|
|
if ($orderedUnit === 'ml') {
|
|
$density = Ingredient::query()->whereKey($data['ingredient_id'] ?? null)->value('density') ?? $fallbackDensity;
|
|
if ($density !== null && (float) $density > 0) {
|
|
$data['purchase_unit'] = 'ml';
|
|
$data['density_used'] = (float) $density;
|
|
if (isset($data['ordered_quantity'])) {
|
|
$data['ordered_quantity'] = roundWawiQuantity((float) $data['ordered_quantity'] * (float) $density);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Ergänzt Netto-/Brutto-Preis pro kg und den UST-Snapshot.
|
|
* Es genügt, Netto oder Brutto anzugeben; der jeweils fehlende Wert wird
|
|
* aus dem gewählten Steuersatz berechnet.
|
|
*
|
|
* @param array<string, mixed> $data
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function resolvePrices(array $data): array
|
|
{
|
|
if (($data['entry_type'] ?? '') !== 'ingredient') {
|
|
$data['price_per_kg'] = null;
|
|
$data['price_per_kg_gross'] = null;
|
|
$data['tax_rate_id'] = null;
|
|
$data['tax_rate_percent'] = null;
|
|
|
|
return $data;
|
|
}
|
|
|
|
$percent = null;
|
|
if (! empty($data['tax_rate_id'])) {
|
|
$percent = TaxRate::query()->whereKey($data['tax_rate_id'])->value('percent');
|
|
}
|
|
$data['tax_rate_percent'] = $percent;
|
|
|
|
$factor = 1 + ((float) ($percent ?? 0)) / 100;
|
|
|
|
$net = isset($data['price_per_kg']) && $data['price_per_kg'] !== null && $data['price_per_kg'] !== ''
|
|
? (float) $data['price_per_kg']
|
|
: null;
|
|
$gross = isset($data['price_per_kg_gross']) && $data['price_per_kg_gross'] !== null && $data['price_per_kg_gross'] !== ''
|
|
? (float) $data['price_per_kg_gross']
|
|
: null;
|
|
|
|
if ($net !== null && $gross === null) {
|
|
$gross = round($net * $factor, 4);
|
|
} elseif ($gross !== null && $net === null) {
|
|
$net = $factor > 0 ? round($gross / $factor, 4) : $gross;
|
|
} elseif ($net !== null && $gross !== null) {
|
|
$gross = round($net * $factor, 4);
|
|
}
|
|
|
|
$data['price_per_kg'] = $net;
|
|
$data['price_per_kg_gross'] = $gross;
|
|
$data['price_total'] = null;
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function receive(StockEntry $stockEntry, array $data): StockEntry
|
|
{
|
|
return DB::transaction(function () use ($stockEntry, $data) {
|
|
// Prüfung 08.09.2026 F-16: Zeile sperren und „offen“ innerhalb der Sperre erneut
|
|
// prüfen — zwei parallele Wareneingänge schreiben sonst nacheinander verschiedene
|
|
// Mengen auf denselben Eintrag.
|
|
$locked = StockEntry::query()->lockForUpdate()->findOrFail($stockEntry->id);
|
|
if (! $locked->isPending()) {
|
|
throw ValidationException::withMessages(['status' => __('Eintrag nicht mehr offen.')]);
|
|
}
|
|
|
|
$data['status'] = 'received';
|
|
$data['received_by'] = auth()->id();
|
|
|
|
// AP-42: Wurde in ml eingekauft, wird auch die gelieferte Menge in ml erfasst
|
|
// und über die (beim Bestellen gespeicherte) Dichte in Gramm umgerechnet.
|
|
if ($locked->purchase_unit === 'ml' && $locked->density_used !== null && isset($data['received_quantity'])) {
|
|
$data['received_quantity'] = roundWawiQuantity((float) $data['received_quantity'] * (float) $locked->density_used);
|
|
}
|
|
|
|
$locked->update($data);
|
|
|
|
return $locked->fresh();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, StockEntry>
|
|
*/
|
|
public function getByStatus(string $status): Collection
|
|
{
|
|
return StockEntry::query()
|
|
->where('status', $status)
|
|
->orderByDesc('ordered_at')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, StockEntry>
|
|
*/
|
|
public function getForIngredient(int $ingredientId): Collection
|
|
{
|
|
return StockEntry::query()
|
|
->where('ingredient_id', $ingredientId)
|
|
->where('status', 'received')
|
|
->orderByDesc('received_at')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Letzter Netto-kg-Preis je Lieferant für einen Rohstoff (neuester Einkauf zählt).
|
|
*
|
|
* @return array<int, float> [supplier_id => price_per_kg_net]
|
|
*/
|
|
public function lastNetPricePerSupplier(int $ingredientId): array
|
|
{
|
|
$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');
|
|
|
|
// DB::query statt StockEntry::query: der SoftDeletes-Global-Scope würde
|
|
// sonst "stock_entries.deleted_at" auf dem Sub-Select-Alias referenzieren.
|
|
return \DB::query()
|
|
->fromSub($ranked, 'ranked')
|
|
->where('rn', 1)
|
|
->pluck('price_per_kg', 'supplier_id')
|
|
->map(fn ($price) => (float) $price)
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Liste: Pending zuerst (neuestes Bestelldatum), dann Received (neuester Eingang).
|
|
* Eingegangene Einträge sind auf $receivedLimit gedeckelt (OPT-3); null = alle.
|
|
*
|
|
* @return Collection<int, StockEntry>
|
|
*/
|
|
public function listForIndex(?int $receivedLimit = 500): Collection
|
|
{
|
|
$with = [
|
|
'ingredient',
|
|
'packagingItem',
|
|
'supplier',
|
|
'location',
|
|
'quality',
|
|
];
|
|
|
|
$pending = StockEntry::query()->with($with)
|
|
->where('status', 'pending')
|
|
->orderByDesc('ordered_at')
|
|
->orderByDesc('id')
|
|
->get();
|
|
|
|
$received = StockEntry::query()->with($with)
|
|
->where('status', 'received')
|
|
->orderByDesc('received_at')
|
|
->orderByDesc('id')
|
|
->when($receivedLimit !== null, fn ($q) => $q->limit($receivedLimit))
|
|
->get();
|
|
|
|
return $pending->concat($received)->values();
|
|
}
|
|
}
|