diff --git a/modules/erp/lager/api/lot.php b/modules/erp/lager/api/lot.php index b1351af..1ff552f 100644 --- a/modules/erp/lager/api/lot.php +++ b/modules/erp/lager/api/lot.php @@ -46,6 +46,9 @@ try { $manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId; + $receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0; + $adjustmentQty = isset($data['adjustmentQty']) && $data['adjustmentQty'] !== '' ? (float) $data['adjustmentQty'] : 0.0; + $outQty = isset($data['outQty']) && $data['outQty'] !== '' ? (float) $data['outQty'] : 0.0; $updatedLot = update_stock_lot_traceability( $pdo, (int) ($data['lotId'] ?? 0), @@ -54,9 +57,12 @@ try { isset($data['mfgDate']) ? (string) $data['mfgDate'] : null, isset($data['expiryDate']) ? (string) $data['expiryDate'] : null, isset($data['purchaseDate']) ? (string) $data['purchaseDate'] : null, - (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown') + (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'), + $receiptQty, + $adjustmentQty, + $outQty ); - json_response(200, ['ok' => true, 'lot' => $updatedLot]); + json_response(200, ['ok' => true, 'lot' => $updatedLot, 'balance' => get_stock_lot_balance($pdo, (int) ($data['lotId'] ?? 0))]); } json_response(422, ['ok' => false, 'error' => 'Unsupported action']); diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index b89894d..c88f836 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -192,7 +192,10 @@ function update_stock_lot_traceability( ?string $mfgDate, ?string $expiryDate, ?string $purchaseDate, - string $changedBy + string $changedBy, + float $receiptQty = 0.0, + float $adjustmentQty = 0.0, + float $outQty = 0.0 ): array { if ($lotId <= 0) { throw new InvalidArgumentException('Chargen-ID fehlt'); @@ -203,6 +206,16 @@ function update_stock_lot_traceability( $manufacturerLotNumber = null; } + if (!is_finite($receiptQty) || !is_finite($adjustmentQty) || !is_finite($outQty)) { + throw new InvalidArgumentException('Ungueltige Bewegungsmenge'); + } + if ($receiptQty < 0 || $outQty < 0) { + throw new InvalidArgumentException('Zugang und Abgang duerfen nicht negativ sein'); + } + if ($adjustmentQty !== 0.0 && abs($adjustmentQty) < 0.0001) { + $adjustmentQty = 0.0; + } + $dates = [ 'mfg_date' => $mfgDate, 'expiry_date' => $expiryDate, @@ -261,6 +274,9 @@ function update_stock_lot_traceability( if ($manufacturerPartyId !== null && $manufacturerLotNumber === null && (string) $before['status'] !== 'open') { throw new InvalidArgumentException('Hersteller-Chargennummer fehlt'); } + if (($receiptQty > 0 || $adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current') { + throw new InvalidArgumentException('Lagerbewegungen koennen nur auf der aktiven Charge gebucht werden'); + } $updateStmt = $pdo->prepare( "UPDATE stock_lot @@ -291,6 +307,10 @@ function update_stock_lot_traceability( 'module' => 'erp.lager', ]); + if ($receiptQty > 0 || $adjustmentQty !== 0.0 || $outQty > 0) { + book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty); + } + if ($startedTransaction) { $pdo->commit(); } @@ -333,6 +353,30 @@ function get_current_lot_balance_for_update(PDO $pdo, int $productId): array ]; } +function get_stock_lot_balance(PDO $pdo, int $lotId): array +{ + $stmt = $pdo->prepare( + "SELECT + COALESCE(v.qty_net, 0) AS qty_net, + COALESCE(v.qty_in, 0) AS qty_in, + COALESCE(v.qty_out, 0) AS qty_out + FROM stock_lot sl + LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id + WHERE sl.id = :lot_id" + ); + $stmt->execute([':lot_id' => $lotId]); + $row = $stmt->fetch(); + if (!is_array($row)) { + throw new RuntimeException('Charge nicht gefunden'); + } + + return [ + 'qty_net' => (float) $row['qty_net'], + 'qty_in' => (float) $row['qty_in'], + 'qty_out' => (float) $row['qty_out'], + ]; +} + function insert_stock_move_out( PDO $pdo, int $productId, @@ -401,6 +445,91 @@ function insert_stock_move_in( return (int) $id; } +function insert_stock_move_adjustment( + PDO $pdo, + int $productId, + int $lotId, + float $qty, + ?int $fromLocationId, + ?int $toLocationId, + string $note +): int { + $stmt = $pdo->prepare( + "INSERT INTO stock_move ( + product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at + ) VALUES ( + :product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'adjustment', NOW(), :note, NOW(), NOW() + ) + RETURNING id" + ); + $stmt->execute([ + ':product_id' => $productId, + ':lot_id' => $lotId, + ':from_location_id' => $fromLocationId, + ':to_location_id' => $toLocationId, + ':qty' => $qty, + ':note' => $note, + ]); + + $id = $stmt->fetchColumn(); + if ($id === false) { + throw new RuntimeException('Konnte Bestandskorrektur nicht schreiben'); + } + + return (int) $id; +} + +function book_stock_lot_movements( + PDO $pdo, + int $productId, + int $lotId, + float $receiptQty, + float $adjustmentQty, + float $outQty +): void { + $locations = get_default_location_ids($pdo); + $balanceStmt = $pdo->prepare( + "SELECT COALESCE( + SUM(CASE + WHEN move_type = 'in' THEN qty + WHEN move_type = 'out' THEN -qty + WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty + WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty + ELSE 0 + END), 0 + ) + FROM stock_move + WHERE lot_id = :lot_id" + ); + $balanceStmt->execute([':lot_id' => $lotId]); + $balance = (float) $balanceStmt->fetchColumn(); + + if ($receiptQty > 0) { + insert_stock_move_in($pdo, $productId, $lotId, $receiptQty, $locations['storage'], $locations['storage'], 'Manueller Lagerzugang via Chargenformular'); + $balance += $receiptQty; + } + + if ($adjustmentQty !== 0.0) { + if ($adjustmentQty > 0) { + insert_stock_move_adjustment($pdo, $productId, $lotId, $adjustmentQty, null, $locations['storage'], 'Manuelle Bestandskorrektur via Chargenformular'); + } else { + $correctionQty = abs($adjustmentQty); + if ($correctionQty > $balance) { + throw new InvalidArgumentException('Bestandskorrektur wuerde den Chargensaldo negativ machen'); + } + insert_stock_move_adjustment($pdo, $productId, $lotId, $correctionQty, $locations['storage'], null, 'Manuelle Bestandskorrektur via Chargenformular'); + } + $balance += $adjustmentQty; + } + + if ($outQty > $balance) { + throw new InvalidArgumentException('Lagerabgang ist groesser als der aktuelle Chargenbestand'); + } + if ($outQty > 0) { + insert_stock_move_out($pdo, $productId, $lotId, $outQty, $locations['storage'], $locations['dispatch'], 'Manueller Lagerabgang via Chargenformular'); + } +} + function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int { $closeStmt = $pdo->prepare( diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index 93c950f..8f5bf8c 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -353,6 +353,11 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar . '
' . '
' . '
' + . '

Lagerbewegungen

' + . '

Buchungen werden zusätzlich zur Rückverfolgbarkeit als Lagerbewegung gespeichert.

' + . '
' + . '
' + . '
' . '

' . '' . '' @@ -751,9 +756,11 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " const postLagerChange = async (payload) => { const response = await fetch('/api/lager-charge.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, csrfToken: contentRoot.querySelector('[data-lager-csrf-token]')?.dataset.lagerCsrfToken || '' }) }); const result = await response.json(); if (!response.ok || result.ok !== true) { throw new Error(result.error || 'Speichern fehlgeschlagen'); } return result; };"; echo " contentRoot.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-edit-open]'); if (editButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = false; } return; } const closeButton = event.target.closest('[data-lager-edit-close]'); if (closeButton) { const drawer = getDrawer(); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } } });"; echo " const setLagerEditMode = (editing) => { const drawer = getDrawer(); const readView = drawer?.querySelector('[data-lager-read-view]'); const editForm = drawer?.querySelector('[data-lager-edit-form]'); if (readView) { readView.hidden = editing; } if (editForm) { editForm.hidden = !editing; } };"; - echo " contentRoot.addEventListener('click', (event) => { if (event.target.closest('[data-lager-edit-open]')) { setLagerEditMode(true); } if (event.target.closest('[data-lager-edit-close]')) { setLagerEditMode(false); } });"; + echo " contentRoot.addEventListener('click', (event) => { if (event.target.closest('[data-lager-edit-open]')) { setLagerEditMode(true); } if (event.target.closest('[data-lager-edit-close]')) { clearLagerMovementInputs(); setLagerEditMode(false); } });"; echo " const syncLagerReadView = () => { const drawer = getDrawer(); if (!drawer) { return; } const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); const values = { '[data-lager-drawer-manufacturer-name]': manufacturer?.selectedOptions?.[0]?.textContent || 'Nicht hinterlegt', '[data-lager-drawer-manufacturer-lot-number]': drawer.querySelector('[data-lager-manufacturer-lot]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-mfg-date]': drawer.querySelector('[data-lager-mfg-date]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-expiry-date]': drawer.querySelector('[data-lager-expiry-date]')?.value || 'Nicht hinterlegt', '[data-lager-drawer-purchase-date]': drawer.querySelector('[data-lager-purchase-date]')?.value || 'Nicht hinterlegt' }; Object.entries(values).forEach(([selector, value]) => { const target = drawer.querySelector(selector); if (target) { target.textContent = value; } }); const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (trigger) { trigger.dataset.lagerDrawerManufacturerName = values['[data-lager-drawer-manufacturer-name]']; trigger.dataset.lagerDrawerManufacturerLotNumber = values['[data-lager-drawer-manufacturer-lot-number]']; trigger.dataset.lagerDrawerMfgDate = values['[data-lager-drawer-mfg-date]']; trigger.dataset.lagerDrawerExpiryDate = values['[data-lager-drawer-expiry-date]']; trigger.dataset.lagerDrawerPurchaseDate = values['[data-lager-drawer-purchase-date]']; } };"; - echo " contentRoot.addEventListener('submit', async (event) => { const editForm = event.target.closest('[data-lager-edit-form-element]'); if (!editForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { await postLagerChange({ action: 'update_lot', lotId: drawer?.dataset.lagerActiveLotId || '', manufacturerPartyId: drawer?.querySelector('[data-lager-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-purchase-date]')?.value || '' }); syncLagerReadView(); setLagerEditMode(false); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });"; + echo " const syncLagerBalanceView = (balance) => { const drawer = getDrawer(); if (!drawer || !balance) { return; } const currentQty = drawer.querySelector('[data-lager-drawer-current-qty]'); const qtyIn = drawer.querySelector('[data-lager-drawer-qty-in]'); if (currentQty) { currentQty.textContent = String(balance.qty_net ?? 0); } if (qtyIn) { qtyIn.textContent = String(balance.qty_in ?? 0); } const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]'); if (trigger) { trigger.dataset.lagerDrawerCurrentQty = String(balance.qty_net ?? 0); trigger.dataset.lagerDrawerQtyIn = String(balance.qty_in ?? 0); } };"; + echo " const clearLagerMovementInputs = () => { const drawer = getDrawer(); if (!drawer) { return; } drawer.querySelectorAll('[data-lager-receipt-qty], [data-lager-adjustment-qty], [data-lager-out-qty]').forEach((input) => { input.value = ''; }); };"; + echo " contentRoot.addEventListener('submit', async (event) => { const editForm = event.target.closest('[data-lager-edit-form-element]'); if (!editForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const result = await postLagerChange({ action: 'update_lot', lotId: drawer?.dataset.lagerActiveLotId || '', manufacturerPartyId: drawer?.querySelector('[data-lager-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-receipt-qty]')?.value || '', adjustmentQty: drawer?.querySelector('[data-lager-adjustment-qty]')?.value || '', outQty: drawer?.querySelector('[data-lager-out-qty]')?.value || '' }); syncLagerReadView(); syncLagerBalanceView(result.balance); clearLagerMovementInputs(); setLagerEditMode(false); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });"; echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-lager-detail-back]')) { event.preventDefault(); closeLagerMobileDetail(); } });"; echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeDrawer(); closeLagerMobileDetail(); } });"; echo " window.addEventListener('popstate', (event) => { if (lagerMobileDetailOpen && (!event.state || event.state.lagerView !== 'detail')) { restoreLagerMobileDetail(); } });";