diff --git a/db/README.md b/db/README.md index 70f534e..77729ed 100644 --- a/db/README.md +++ b/db/README.md @@ -18,6 +18,7 @@ 14. `db/migrations/0018_shopify_delta_sync_state.sql` 15. `db/migrations/0019_allow_multiple_open_stock_lots.sql` 16. `db/migrations/0020_include_stock_adjustments_in_balance.sql` +17. `db/migrations/0021_stock_lot_activation_selection.sql` ## Beispielausfuehrung diff --git a/db/migrations/0021_stock_lot_activation_selection.sql b/db/migrations/0021_stock_lot_activation_selection.sql new file mode 100644 index 0000000..3ffac74 --- /dev/null +++ b/db/migrations/0021_stock_lot_activation_selection.sql @@ -0,0 +1,119 @@ +BEGIN; + +ALTER TABLE stock_lot + ADD COLUMN IF NOT EXISTS next_active BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_stock_lot_one_next_active_per_product + ON stock_lot(product_id) + WHERE status = 'open' AND next_active = TRUE; + +CREATE OR REPLACE FUNCTION fn_auto_switch_lot_when_depleted() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_product_id BIGINT; + v_open_lot_id BIGINT; + v_qualified_count INTEGER; + v_current_net NUMERIC; + v_event_key TEXT; +BEGIN + SELECT product_id + INTO v_product_id + FROM stock_lot + WHERE id = NEW.lot_id; + + IF NOT EXISTS ( + SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current' + ) THEN + RETURN NEW; + END IF; + + SELECT qty_net + INTO v_current_net + FROM v_stock_lot_balance + WHERE stock_lot_id = NEW.lot_id; + + IF COALESCE(v_current_net, 0) > 0 THEN + RETURN NEW; + END IF; + + PERFORM 1 + FROM stock_lot + WHERE product_id = v_product_id + FOR UPDATE; + + IF NOT EXISTS ( + SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current' + ) THEN + RETURN NEW; + END IF; + + SELECT sl.id + INTO v_open_lot_id + FROM stock_lot sl + INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id + WHERE sl.product_id = v_product_id + AND sl.status = 'open' + AND sl.next_active = TRUE + AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL + AND sl.manufacturer_party_id IS NOT NULL + AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL + AND sl.mfg_date IS NOT NULL + AND sl.expiry_date IS NOT NULL + AND sl.purchase_date IS NOT NULL + AND v.qty_net > 0 + LIMIT 1; + + IF v_open_lot_id IS NULL THEN + SELECT COUNT(*), MIN(sl.id) + INTO v_qualified_count, v_open_lot_id + FROM stock_lot sl + INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id + WHERE sl.product_id = v_product_id + AND sl.status = 'open' + AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL + AND sl.manufacturer_party_id IS NOT NULL + AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL + AND sl.mfg_date IS NOT NULL + AND sl.expiry_date IS NOT NULL + AND sl.purchase_date IS NOT NULL + AND v.qty_net > 0; + + IF v_qualified_count <> 1 THEN + RETURN NEW; + END IF; + + UPDATE stock_lot + SET next_active = TRUE, updated_at = NOW() + WHERE id = v_open_lot_id; + END IF; + + UPDATE stock_lot + SET status = 'closed', updated_at = NOW() + WHERE id = NEW.lot_id; + + UPDATE stock_lot + SET status = 'current', next_active = FALSE, updated_at = NOW() + WHERE id = v_open_lot_id; + + v_event_key := format('lot.auto_switched:%s:%s', v_product_id, txid_current()); + + PERFORM fn_enqueue_event( + 'lot.auto_switched', + v_event_key, + 'stock_lot', + NEW.lot_id::TEXT, + jsonb_build_object( + 'productId', v_product_id, + 'closedLotId', NEW.lot_id, + 'newCurrentLotId', v_open_lot_id, + 'occurredAt', NOW() + ) + ); + + RETURN NEW; +END; +$$; + +COMMIT; diff --git a/modules/erp/lager/api/lot.php b/modules/erp/lager/api/lot.php index f699b6f..710d21b 100644 --- a/modules/erp/lager/api/lot.php +++ b/modules/erp/lager/api/lot.php @@ -74,6 +74,7 @@ try { (int) ($data['lotId'] ?? 0), (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown') ); + sync_open_lot_activation_selection($pdo, $productId); json_response(200, ['ok' => true, 'productId' => $productId, 'availableQty' => get_lager_product_available_qty($pdo, $productId)]); } @@ -81,8 +82,22 @@ try { $productId = (int) ($data['productId'] ?? 0); $lotNumber = trim((string) ($data['lotNumber'] ?? '')); $lotId = (int) ($data['lotId'] ?? 0); - if ($lotNumber === '' && $lotId <= 0) { - throw new InvalidArgumentException('Naurua-Chargennummer fehlt'); + $manufacturerPartyId = $data['manufacturerPartyId'] ?? null; + $manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId; + $manufacturerLotNumber = trim((string) ($data['manufacturerLotNumber'] ?? '')); + $mfgDate = trim((string) ($data['mfgDate'] ?? '')); + $expiryDate = trim((string) ($data['expiryDate'] ?? '')); + $purchaseDate = trim((string) ($data['purchaseDate'] ?? '')); + $receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0; + $targetQty = isset($data['targetQty']) && $data['targetQty'] !== '' ? (float) $data['targetQty'] : null; + if ($lotNumber === '' || $manufacturerPartyId === null || $manufacturerLotNumber === '' || $mfgDate === '' || $expiryDate === '' || $purchaseDate === '') { + throw new InvalidArgumentException('Alle Chargenfelder sind Pflichtfelder'); + } + if ($lotId <= 0 && $receiptQty <= 0) { + throw new InvalidArgumentException('Eingangsmenge muss groesser als 0 sein'); + } + if ($lotId > 0 && $targetQty !== null && $targetQty <= 0) { + throw new InvalidArgumentException('Menge muss groesser als 0 sein'); } if ($lotId > 0) { $lotStmt = $pdo->prepare( @@ -97,20 +112,16 @@ try { throw new InvalidArgumentException('Offene Charge nicht gefunden'); } } else { - $lotId = get_or_create_open_lot($pdo, $productId); + $lotId = create_open_lot($pdo, $productId); } - $manufacturerPartyId = $data['manufacturerPartyId'] ?? null; - $manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId; - $receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0; - $targetQty = isset($data['targetQty']) && $data['targetQty'] !== '' ? (float) $data['targetQty'] : null; $preparedLot = update_stock_lot_traceability( $pdo, $lotId, $manufacturerPartyId, - isset($data['manufacturerLotNumber']) ? (string) $data['manufacturerLotNumber'] : null, - isset($data['mfgDate']) ? (string) $data['mfgDate'] : null, - isset($data['expiryDate']) ? (string) $data['expiryDate'] : null, - isset($data['purchaseDate']) ? (string) $data['purchaseDate'] : null, + $manufacturerLotNumber, + $mfgDate, + $expiryDate, + $purchaseDate, (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'), $receiptQty, 0.0, @@ -119,11 +130,19 @@ try { $targetQty, $lotId > 0 && $lotNumber === '' ); + sync_open_lot_activation_selection($pdo, $productId); refresh_lager_stock_lot_forecast_for_product($pdo, $productId); $forecast = get_lager_product_forecast($pdo, $productId); json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $preparedLot, 'balance' => get_stock_lot_balance($pdo, $lotId), 'availableQty' => get_lager_product_available_qty($pdo, $productId), 'forecast' => $forecast]); } + if ($action === 'select_open_lot') { + $productId = (int) ($data['productId'] ?? 0); + $lotId = (int) ($data['lotId'] ?? 0); + select_open_lot_for_activation($pdo, $productId, $lotId); + json_response(200, ['ok' => true, 'productId' => $productId, 'selectedLotId' => $lotId]); + } + json_response(422, ['ok' => false, 'error' => 'Unsupported action']); } catch (JsonException) { json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']); diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index 17789a2..ebbdd36 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -205,6 +205,7 @@ function get_lager_product_overview(PDO $pdo): array sl.mfg_date, sl.expiry_date, sl.purchase_date, + sl.next_active, COALESCE(v.qty_in, 0) AS qty_in, COALESCE(v.qty_net, 0) AS qty_net FROM stock_lot sl @@ -223,9 +224,10 @@ function get_lager_product_overview(PDO $pdo): array 'manufacturer_name' => $openLot['manufacturer_name'], 'mfg_date' => $openLot['mfg_date'], 'expiry_date' => $openLot['expiry_date'], - 'purchase_date' => $openLot['purchase_date'], - 'qty_in' => (float) $openLot['qty_net'], - 'qty_net' => (float) $openLot['qty_net'], + 'purchase_date' => $openLot['purchase_date'], + 'qty_in' => (float) $openLot['qty_net'], + 'qty_net' => (float) $openLot['qty_net'], + 'next_active' => (bool) $openLot['next_active'], ]; } @@ -466,6 +468,145 @@ function update_stock_lot_traceability( } } +function is_qualified_open_lot(PDO $pdo, int $lotId): bool +{ + $stmt = $pdo->prepare( + "SELECT EXISTS ( + SELECT 1 + FROM stock_lot sl + INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id + WHERE sl.id = :lot_id + AND sl.status = 'open' + AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL + AND sl.manufacturer_party_id IS NOT NULL + AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL + AND sl.mfg_date IS NOT NULL + AND sl.expiry_date IS NOT NULL + AND sl.purchase_date IS NOT NULL + AND v.qty_net > 0 + )" + ); + $stmt->execute([':lot_id' => $lotId]); + return (bool) $stmt->fetchColumn(); +} + +function sync_open_lot_activation_selection(PDO $pdo, int $productId): void +{ + $qualifiedStmt = $pdo->prepare( + "SELECT sl.id + FROM stock_lot sl + INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id + WHERE sl.product_id = :product_id + AND sl.status = 'open' + AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL + AND sl.manufacturer_party_id IS NOT NULL + AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL + AND sl.mfg_date IS NOT NULL + AND sl.expiry_date IS NOT NULL + AND sl.purchase_date IS NOT NULL + AND v.qty_net > 0 + ORDER BY sl.id + FOR UPDATE" + ); + $qualifiedStmt->execute([':product_id' => $productId]); + $qualifiedIds = array_map('intval', $qualifiedStmt->fetchAll(PDO::FETCH_COLUMN)); + + if (count($qualifiedIds) === 1) { + $selectedId = $qualifiedIds[0]; + $clearStmt = $pdo->prepare( + "UPDATE stock_lot + SET next_active = FALSE, updated_at = NOW() + WHERE product_id = :product_id AND status = 'open'" + ); + $clearStmt->execute([':product_id' => $productId]); + $selectStmt = $pdo->prepare( + "UPDATE stock_lot + SET next_active = TRUE, updated_at = NOW() + WHERE id = :lot_id" + ); + $selectStmt->execute([':lot_id' => $selectedId]); + return; + } + + if ($qualifiedIds === []) { + $clearStmt = $pdo->prepare( + "UPDATE stock_lot + SET next_active = FALSE, updated_at = NOW() + WHERE product_id = :product_id AND status = 'open'" + ); + $clearStmt->execute([':product_id' => $productId]); + return; + } + + $selectedStmt = $pdo->prepare( + "SELECT id + FROM stock_lot + WHERE product_id = :product_id + AND status = 'open' + AND next_active = TRUE + LIMIT 1 + FOR UPDATE" + ); + $selectedStmt->execute([':product_id' => $productId]); + $selectedId = $selectedStmt->fetchColumn(); + if ($selectedId !== false && in_array((int) $selectedId, $qualifiedIds, true)) { + return; + } + + $clearStmt = $pdo->prepare( + "UPDATE stock_lot + SET next_active = FALSE, updated_at = NOW() + WHERE product_id = :product_id AND status = 'open'" + ); + $clearStmt->execute([':product_id' => $productId]); +} + +function select_open_lot_for_activation(PDO $pdo, int $productId, int $lotId): void +{ + if ($productId <= 0 || $lotId <= 0 || !is_qualified_open_lot($pdo, $lotId)) { + throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden'); + } + + $lotStmt = $pdo->prepare( + "SELECT product_id, status + FROM stock_lot + WHERE id = :lot_id + FOR UPDATE" + ); + $lotStmt->execute([':lot_id' => $lotId]); + $lot = $lotStmt->fetch(); + if (!is_array($lot) || (int) $lot['product_id'] !== $productId || (string) $lot['status'] !== 'open') { + throw new InvalidArgumentException('Offene Charge nicht gefunden'); + } + + $startedTransaction = !$pdo->inTransaction(); + if ($startedTransaction) { + $pdo->beginTransaction(); + } + try { + $clearStmt = $pdo->prepare( + "UPDATE stock_lot + SET next_active = FALSE, updated_at = NOW() + WHERE product_id = :product_id AND status = 'open'" + ); + $clearStmt->execute([':product_id' => $productId]); + $selectStmt = $pdo->prepare( + "UPDATE stock_lot + SET next_active = TRUE, updated_at = NOW() + WHERE id = :lot_id" + ); + $selectStmt->execute([':lot_id' => $lotId]); + if ($startedTransaction) { + $pdo->commit(); + } + } catch (Throwable $error) { + if ($startedTransaction && $pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $error; + } +} + function get_current_lot_balance_for_update(PDO $pdo, int $productId): array { $stmt = $pdo->prepare( @@ -820,23 +961,8 @@ function get_or_create_virtual_shortage_lot(PDO $pdo, int $productId): int return (int) $newLotId; } -function get_or_create_open_lot(PDO $pdo, int $productId): int +function create_open_lot(PDO $pdo, int $productId): int { - $select = $pdo->prepare( - "SELECT id - FROM stock_lot - WHERE product_id = :product_id - AND status = 'open' - ORDER BY id - LIMIT 1 - FOR UPDATE" - ); - $select->execute([':product_id' => $productId]); - $lotId = $select->fetchColumn(); - if ($lotId !== false) { - return (int) $lotId; - } - $insert = $pdo->prepare( "INSERT INTO stock_lot (product_id, status, created_at, updated_at) VALUES (:product_id, 'open', NOW(), NOW()) diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index cbfdeef..352593f 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -334,9 +334,19 @@ function auth_render_lager_open_lots(array $lagerProducts, string $manufacturerO $html[] = '' . '' . '
' . auth_render_lager_open_lots($lagerProducts, $manufacturerOptions) . '
' @@ -829,14 +839,15 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best 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 " const syncLagerOpenLotCard = (lot, balance) => { const drawer = getDrawer(); const card = drawer?.querySelector('[data-lager-open-card=\"' + (lot?.id || '') + '\"]'); if (!card) { return; } const values = { '[data-lager-open-card-lot-number]': lot?.lot_number || 'Nicht hinterlegt', '[data-lager-open-card-qty-net]': String(balance?.qty_net ?? 0), '[data-lager-open-card-qty-in]': String(balance?.qty_net ?? 0), '[data-lager-open-card-manufacturer]': drawer.querySelector('[data-lager-new-manufacturer]')?.selectedOptions?.[0]?.textContent || 'Nicht hinterlegt', '[data-lager-open-card-manufacturer-lot]': lot?.manufacturer_lot_number || 'Nicht hinterlegt', '[data-lager-open-card-mfg-date]': lot?.mfg_date || 'Nicht hinterlegt', '[data-lager-open-card-expiry-date]': lot?.expiry_date || 'Nicht hinterlegt', '[data-lager-open-card-purchase-date]': lot?.purchase_date || 'Nicht hinterlegt' }; Object.entries(values).forEach(([selector, value]) => { const target = card.querySelector(selector); if (target) { target.textContent = value; } }); };"; - echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const drawer = getDrawer(); const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; const openProduct = drawer?.querySelector('[data-lager-open-product=\"' + productId + '\"]'); drawer?.querySelectorAll('[data-lager-open-product]').forEach((product) => { product.hidden = product !== openProduct; product.querySelectorAll('[data-lager-open-edit-form]').forEach((form) => { form.hidden = true; }); product.querySelectorAll('[data-lager-open-card]').forEach((readView) => { readView.hidden = false; }); }); const newForm = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const newAction = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (newForm) { newForm.hidden = true; } if (newAction) { newAction.hidden = false; } const notificationWrapper = drawer?.querySelector('[data-lager-new-lot-notification-wrapper]'); const notification = drawer?.querySelector('[data-lager-new-lot-notification]'); const shortageQty = trigger.dataset.lagerDrawerVirtualShortageQty || '0'; const hasShortage = shortageQty !== '0' && shortageQty !== '0,00'; if (notificationWrapper) { notificationWrapper.hidden = !hasShortage; } if (notification && hasShortage) { notification.textContent = 'Für dieses Produkt bestehen virtuelle Fehlbestände in Höhe von ' + shortageQty + '. Die Eingangsmenge wird nach Aktivierung der Charge zur Nachallokation verwendet.'; } const setValue = (selector, value) => { const field = drawer?.querySelector(selector); if (field) { field.value = value || ''; } }; setValue('[data-lager-new-lot-number]', trigger.dataset.lagerOpenLotNumber); setValue('[data-lager-new-manufacturer]', trigger.dataset.lagerOpenManufacturerPartyId); setValue('[data-lager-new-manufacturer-lot]', trigger.dataset.lagerOpenManufacturerLotNumber); setValue('[data-lager-new-mfg-date]', trigger.dataset.lagerOpenMfgDate); setValue('[data-lager-new-expiry-date]', trigger.dataset.lagerOpenExpiryDate); setValue('[data-lager-new-purchase-date]', trigger.dataset.lagerOpenPurchaseDate); if (drawer) { drawer.dataset.lagerOpenLotId = trigger.dataset.lagerOpenLotId || ''; drawer.dataset.lagerProductId = productId; } });"; + echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const drawer = getDrawer(); const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; const openProduct = drawer?.querySelector('[data-lager-open-product=\"' + productId + '\"]'); drawer?.querySelectorAll('[data-lager-open-product]').forEach((product) => { product.hidden = product !== openProduct; product.querySelectorAll('[data-lager-open-edit-form]').forEach((form) => { form.hidden = true; }); product.querySelectorAll('[data-lager-open-card]').forEach((readView) => { readView.hidden = false; }); }); const newForm = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const newAction = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (newForm) { newForm.hidden = true; } if (newAction) { newAction.hidden = false; } const notificationWrapper = drawer?.querySelector('[data-lager-new-lot-notification-wrapper]'); const notification = drawer?.querySelector('[data-lager-new-lot-notification]'); const shortageQty = trigger.dataset.lagerDrawerVirtualShortageQty || '0'; const hasShortage = shortageQty !== '0' && shortageQty !== '0,00'; if (notificationWrapper) { notificationWrapper.hidden = !hasShortage; } if (notification && hasShortage) { notification.textContent = 'Für dieses Produkt bestehen virtuelle Fehlbestände in Höhe von ' + shortageQty + '. Die Eingangsmenge wird nach Aktivierung der Charge zur Nachallokation verwendet.'; } const setValue = (selector, value) => { const field = drawer?.querySelector(selector); if (field) { field.value = value || ''; } }; setValue('[data-lager-new-lot-number]', ''); setValue('[data-lager-new-manufacturer]', ''); setValue('[data-lager-new-manufacturer-lot]', ''); setValue('[data-lager-new-mfg-date]', ''); setValue('[data-lager-new-expiry-date]', ''); setValue('[data-lager-new-purchase-date]', ''); setValue('[data-lager-new-receipt-qty]', ''); if (drawer) { drawer.dataset.lagerOpenLotId = ''; drawer.dataset.lagerProductId = productId; } });"; echo " document.addEventListener('click', (event) => { const openButton = event.target.closest('[data-lager-new-lot-open]'); if (openButton) { const drawer = getDrawer(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = false; } if (action) { action.hidden = true; } return; } if (event.target.closest('[data-lager-new-lot-close]')) { const drawer = getDrawer(); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = true; } if (action) { action.hidden = false; } } });"; echo " document.addEventListener('click', (event) => { const editButton = event.target.closest('[data-lager-open-edit-open]'); if (editButton) { const card = editButton.closest('[data-lager-open-card-wrapper]'); const form = card?.querySelector('[data-lager-open-edit-form]'); const readView = card?.querySelector('[data-lager-open-card]'); if (form) { form.hidden = false; } if (readView) { readView.hidden = true; } return; } const closeButton = event.target.closest('[data-lager-open-edit-close]'); if (closeButton) { const card = closeButton.closest('[data-lager-open-card-wrapper]'); const form = card?.querySelector('[data-lager-open-edit-form]'); const readView = card?.querySelector('[data-lager-open-card]'); if (form) { form.hidden = true; } if (readView) { readView.hidden = false; } } });"; echo " document.addEventListener('input', (event) => { const input = event.target.closest('[data-lager-open-delete-confirmation-input]'); if (!input) { return; } const card = input.closest('[data-lager-open-card-wrapper]'); const submit = card?.querySelector('[data-lager-open-delete-confirmation-submit]'); const valid = input.value === 'DELETE'; if (submit) { submit.disabled = !valid; submit.setAttribute('aria-disabled', String(!valid)); submit.classList.toggle('sg-button--process-inactive', !valid); } });"; + echo " document.addEventListener('click', async (event) => { const selectButton = event.target.closest('[data-lager-open-select]'); if (!selectButton) { return; } const card = selectButton.closest('[data-lager-open-card-wrapper]'); const product = card?.closest('[data-lager-open-product]'); const drawer = getDrawer(); const productId = product?.dataset.lagerOpenProduct || drawer?.dataset.lagerProductId || ''; const lotId = card?.dataset.lagerOpenCardWrapper || ''; if (!productId || !lotId) { return; } selectButton.disabled = true; try { await postLagerChange({ action: 'select_open_lot', productId, lotId }); product.querySelectorAll('[data-lager-open-card-wrapper]').forEach((candidate) => { const selected = candidate === card; candidate.classList.toggle('sg-lager-open-lot--next-active', selected); const label = candidate.querySelector('[data-lager-open-card-next-active]'); if (label) { label.textContent = selected ? 'Als nächste aktive Charge ausgewählt' : 'Nicht ausgewählt'; } }); } catch (error) { const status = card?.querySelector('[data-lager-open-edit-status]'); if (status) { status.textContent = error instanceof Error ? error.message : 'Auswahl fehlgeschlagen'; } } finally { selectButton.disabled = false; } });"; echo " document.addEventListener('click', async (event) => { const deleteButton = event.target.closest('[data-lager-open-delete]'); if (deleteButton) { const card = deleteButton.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); const input = card?.querySelector('[data-lager-open-delete-confirmation-input]'); const submit = card?.querySelector('[data-lager-open-delete-confirmation-submit]'); if (input) { input.value = ''; } if (submit) { submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); submit.classList.add('sg-button--process-inactive'); } if (confirmation) { confirmation.hidden = false; } if (card) { card.dataset.dialogOpen = 'true'; } return; } const cancel = event.target.closest('[data-lager-open-delete-confirmation-cancel]'); if (cancel) { const card = cancel.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } return; } const submit = event.target.closest('[data-lager-open-delete-confirmation-submit]'); if (!submit || submit.disabled) { return; } const card = submit.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-delete-confirmation]'); const status = card?.querySelector('[data-lager-open-delete-status]'); if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; try { await postLagerChange({ action: 'delete_open_lot', lotId: card?.dataset.lagerOpenCardWrapper || '' }); const productId = card?.closest('[data-lager-open-product]')?.dataset.lagerOpenProduct || ''; contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((trigger) => { if (trigger.closest('[data-lager-row]')?.dataset.lagerId === productId) { trigger.dataset.lagerOpenLotId = ''; } }); const drawer = getDrawer(); if (drawer) { drawer.dataset.lagerOpenLotId = ''; } card?.remove(); } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Löschen fehlgeschlagen'; } submit.disabled = false; } });"; echo " document.addEventListener('click', (event) => { const cancel = event.target.closest('[data-lager-open-confirmation-cancel]'); if (cancel) { const card = cancel.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } const form = card?.querySelector('[data-lager-open-edit-form-element]'); if (form) { form.dataset.confirmationAccepted = 'false'; } return; } const confirm = event.target.closest('[data-lager-open-confirmation-submit]'); if (confirm) { const card = confirm.closest('[data-lager-open-card-wrapper]'); const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); const form = card?.querySelector('[data-lager-open-edit-form-element]'); if (confirmation) { confirmation.hidden = true; } if (card) { card.dataset.dialogOpen = 'false'; } if (form) { form.dataset.confirmationAccepted = 'true'; form.requestSubmit(); } } });"; echo " document.addEventListener('submit', async (event) => { const openLotForm = event.target.closest('[data-lager-open-edit-form-element]'); if (!openLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const card = openLotForm.closest('[data-lager-open-card-wrapper]'); const editSection = card?.querySelector('[data-lager-open-edit-form]'); const fields = [{ label: 'Naurua-Chargennummer', input: openLotForm.querySelector('[data-lager-open-lot-number]') }, { label: 'Hersteller', input: openLotForm.querySelector('[data-lager-open-manufacturer]') }, { label: 'Hersteller-Chargennummer', input: openLotForm.querySelector('[data-lager-open-manufacturer-lot]') }, { label: 'Herstellungsdatum', input: openLotForm.querySelector('[data-lager-open-mfg-date]') }, { label: 'MHD', input: openLotForm.querySelector('[data-lager-open-expiry-date]') }, { label: 'Einkaufsdatum', input: openLotForm.querySelector('[data-lager-open-purchase-date]') }, { label: 'Menge', input: openLotForm.querySelector('[data-lager-open-target-qty]') }]; const emptyFields = fields.filter((field) => (field.input?.value || '').trim() === ''); if (emptyFields.length > 0 && openLotForm.dataset.confirmationAccepted !== 'true') { const confirmation = card?.querySelector('[data-lager-open-confirmation-card]'); const emptyFieldsList = card?.querySelector('[data-lager-open-confirmation-empty-fields]'); if (emptyFieldsList) { emptyFieldsList.replaceChildren(...emptyFields.map((field) => { const item = document.createElement('div'); item.className = 'sg-body'; item.textContent = field.label; return item; })); } if (confirmation) { confirmation.hidden = false; } if (card) { card.dataset.dialogOpen = 'true'; } return; } openLotForm.dataset.confirmationAccepted = 'false'; const status = openLotForm.querySelector('[data-lager-open-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const targetQtyInput = openLotForm.querySelector('[data-lager-open-target-qty]'); const result = await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId: card?.dataset.lagerOpenCardWrapper || '', lotNumber: openLotForm.querySelector('[data-lager-open-lot-number]')?.value || '', manufacturerPartyId: openLotForm.querySelector('[data-lager-open-manufacturer]')?.value || null, manufacturerLotNumber: openLotForm.querySelector('[data-lager-open-manufacturer-lot]')?.value || '', mfgDate: openLotForm.querySelector('[data-lager-open-mfg-date]')?.value || '', expiryDate: openLotForm.querySelector('[data-lager-open-expiry-date]')?.value || '', purchaseDate: openLotForm.querySelector('[data-lager-open-purchase-date]')?.value || '', targetQty: targetQtyInput?.value.trim() === '' ? '0' : targetQtyInput?.value || '' }); syncLagerOpenLotCard(result.lot, result.balance); if (editSection) { editSection.hidden = true; } card?.querySelector('[data-lager-open-card]')?.removeAttribute('hidden'); if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });"; - echo " document.addEventListener('submit', async (event) => { const newLotForm = event.target.closest('[data-lager-new-lot-form-element]'); if (!newLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-new-lot-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { const result = await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId: drawer?.dataset.lagerOpenLotId || '', lotNumber: drawer?.querySelector('[data-lager-new-lot-number]')?.value || '', manufacturerPartyId: drawer?.querySelector('[data-lager-new-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-new-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-new-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-new-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-new-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-new-receipt-qty]')?.value || '' }); syncLagerOpenLotCard(result.lot, result.balance); const form = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); const action = drawer?.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (form) { form.hidden = true; } if (action) { action.hidden = false; } if (status) { status.textContent = ''; } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });"; + echo " document.addEventListener('submit', async (event) => { const newLotForm = event.target.closest('[data-lager-new-lot-form-element]'); if (!newLotForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-new-lot-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { await postLagerChange({ action: 'prepare_lot', productId: drawer?.dataset.lagerProductId || '', lotId: '', lotNumber: drawer?.querySelector('[data-lager-new-lot-number]')?.value || '', manufacturerPartyId: drawer?.querySelector('[data-lager-new-manufacturer]')?.value || null, manufacturerLotNumber: drawer?.querySelector('[data-lager-new-manufacturer-lot]')?.value || '', mfgDate: drawer?.querySelector('[data-lager-new-mfg-date]')?.value || '', expiryDate: drawer?.querySelector('[data-lager-new-expiry-date]')?.value || '', purchaseDate: drawer?.querySelector('[data-lager-new-purchase-date]')?.value || '', receiptQty: drawer?.querySelector('[data-lager-new-receipt-qty]')?.value || '' }); window.location.reload(); } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen'; } } });"; echo " document.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(); } });";