From 17efaee53f7db8adef1f4c96ef0ac159fee4b3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Tue, 8 Sep 2026 15:28:01 +0200 Subject: [PATCH] Finalize open lot qualification and selection --- modules/erp/lager/api/lot.php | 4 + modules/erp/lager/service.php | 179 +++++++++++++++++--------------- modules/shared/auth/ui/home.php | 2 +- 3 files changed, 100 insertions(+), 85 deletions(-) diff --git a/modules/erp/lager/api/lot.php b/modules/erp/lager/api/lot.php index b955489..3a2d6b7 100644 --- a/modules/erp/lager/api/lot.php +++ b/modules/erp/lager/api/lot.php @@ -63,6 +63,10 @@ try { $outQty ); $productId = (int) $updatedLot['product_id']; + if (($updatedLot['status'] ?? '') === 'open') { + sync_open_lot_activation_selection($pdo, $productId); + $updatedLot['next_active'] = is_open_lot_selected_for_activation($pdo, (int) $updatedLot['id']); + } refresh_lager_stock_lot_forecast_for_product($pdo, $productId); $forecast = get_lager_product_forecast($pdo, $productId); json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $updatedLot, 'balance' => get_stock_lot_balance($pdo, (int) ($data['lotId'] ?? 0)), 'forecast' => $forecast]); diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index 853c363..f9a506e 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -504,106 +504,117 @@ function is_open_lot_selected_for_activation(PDO $pdo, int $lotId): bool function sync_open_lot_activation_selection(PDO $pdo, int $productId): void { - $lockStmt = $pdo->prepare( - "SELECT id - FROM stock_lot - WHERE product_id = :product_id - AND status = 'open' - FOR UPDATE" - ); - $lockStmt->execute([':product_id' => $productId]); - - $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" - ); - $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; + $startedTransaction = !$pdo->inTransaction(); + if ($startedTransaction) { + $pdo->beginTransaction(); } - if ($qualifiedIds === []) { - $clearStmt = $pdo->prepare( - "UPDATE stock_lot - SET next_active = FALSE, updated_at = NOW() - WHERE product_id = :product_id AND status = 'open'" + try { + $lockStmt = $pdo->prepare( + "SELECT id + FROM stock_lot + WHERE product_id = :product_id + AND status = 'open' + FOR UPDATE" ); - $clearStmt->execute([':product_id' => $productId]); - return; - } + $lockStmt->execute([':product_id' => $productId]); - $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; - } + $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" + ); + $qualifiedStmt->execute([':product_id' => $productId]); + $qualifiedIds = array_map('intval', $qualifiedStmt->fetchAll(PDO::FETCH_COLUMN)); - $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]); + if (count($qualifiedIds) === 1) { + $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' => $qualifiedIds[0]]); + } elseif ($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]); + } else { + $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)) { + $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]); + } + } + + if ($startedTransaction) { + $pdo->commit(); + } + } catch (Throwable $error) { + if ($startedTransaction && $pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $error; + } } function select_open_lot_for_activation(PDO $pdo, int $productId, int $lotId): void { - if ($productId <= 0 || $lotId <= 0 || !is_qualified_open_lot($pdo, $lotId)) { + if ($productId <= 0 || $lotId <= 0) { 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 { + $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'); + } + if (!is_qualified_open_lot($pdo, $lotId)) { + throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden'); + } + $clearStmt = $pdo->prepare( "UPDATE stock_lot SET next_active = FALSE, updated_at = NOW() diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index 5c252d9..e3a3495 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -856,7 +856,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best 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 " 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, exactCard = null) => { const drawer = getDrawer(); const lotId = lot?.id || lot?.lot_id || ''; const card = exactCard || drawer?.querySelector('[data-lager-open-card=\"' + lotId + '\"]'); 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 " const syncLagerOpenLotCard = (lot, balance, exactCard = null) => { const drawer = getDrawer(); const lotId = lot?.id || lot?.lot_id || ''; const card = exactCard || drawer?.querySelector('[data-lager-open-card=\"' + lotId + '\"]'); if (!card) { return; } const manufacturerSelect = card.querySelector('[data-lager-open-manufacturer]') || drawer?.querySelector('[data-lager-new-manufacturer]'); const qualified = Boolean(lot?.lot_number && lot?.manufacturer_party_id && lot?.manufacturer_lot_number && lot?.mfg_date && lot?.expiry_date && lot?.purchase_date && Number(balance?.qty_net || 0) > 0); const selected = lot?.next_active === true; 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_in ?? 0), '[data-lager-open-card-manufacturer]': manufacturerSelect?.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; } }); card.classList.toggle('sg-lager-open-lot--next-active', selected); const selectedLabel = card.querySelector('[data-lager-open-card-next-active]'); if (selectedLabel) { selectedLabel.textContent = selected ? 'Als nächste aktive Charge ausgewählt' : 'Nicht ausgewählt'; } const selectButton = card.querySelector('[data-lager-open-select]'); if (selectButton) { selectButton.dataset.lagerOpenSelectable = String(qualified); selectButton.disabled = !qualified || selected; selectButton.setAttribute('aria-disabled', String(!qualified || selected)); } };"; echo " const appendLagerOpenLotCard = (lot, balance) => { const drawer = getDrawer(); const product = drawer?.querySelector('[data-lager-open-product=\"' + (drawer?.dataset.lagerProductId || '') + '\"]'); const list = product?.querySelector('[data-lager-open-lot-list]'); const templateProduct = drawer?.querySelector('[data-lager-open-lot-template]')?.content.querySelector('[data-lager-open-product]'); const card = templateProduct?.querySelector('[data-lager-open-card-wrapper]')?.cloneNode(true); const lotId = String(lot?.id || lot?.lot_id || ''); if (!list || !card || !lotId) { return null; } card.dataset.lagerOpenCardWrapper = lotId; const readView = card.querySelector('[data-lager-open-card]'); if (readView) { readView.dataset.lagerOpenCard = lotId; } const editTitle = card.querySelector('[id^=\"lager-open-charge-data-\"]'); if (editTitle) { editTitle.id = 'lager-open-charge-data-' + lotId; editTitle.closest('section')?.setAttribute('aria-labelledby', editTitle.id); } const confirmationTitle = card.querySelector('[id^=\"lager-open-confirmation-title-\"]'); if (confirmationTitle) { confirmationTitle.id = 'lager-open-confirmation-title-' + lotId; confirmationTitle.closest('[data-lager-open-confirmation-card]')?.setAttribute('aria-labelledby', confirmationTitle.id); } const deleteTitle = card.querySelector('[id^=\"lager-open-delete-title-\"]'); if (deleteTitle) { deleteTitle.id = 'lager-open-delete-title-' + lotId; deleteTitle.closest('[data-lager-open-delete-confirmation]')?.setAttribute('aria-labelledby', deleteTitle.id); } const setValue = (selector, value) => { const input = card.querySelector(selector); if (input) { input.value = value ?? ''; } }; setValue('[data-lager-open-lot-number]', lot.lot_number); setValue('[data-lager-open-manufacturer]', lot.manufacturer_party_id); setValue('[data-lager-open-manufacturer-lot]', lot.manufacturer_lot_number); setValue('[data-lager-open-mfg-date]', lot.mfg_date); setValue('[data-lager-open-expiry-date]', lot.expiry_date); setValue('[data-lager-open-purchase-date]', lot.purchase_date); setValue('[data-lager-open-target-qty]', balance?.qty_net ?? 0); const qualified = Boolean(lot.lot_number && lot.manufacturer_party_id && lot.manufacturer_lot_number && lot.mfg_date && lot.expiry_date && lot.purchase_date && Number(balance?.qty_net || 0) > 0); const selected = lot.next_active === true; card.classList.toggle('sg-lager-open-lot--next-active', selected); const selectedLabel = card.querySelector('[data-lager-open-card-next-active]'); if (selectedLabel) { selectedLabel.textContent = selected ? 'Als nächste aktive Charge ausgewählt' : 'Nicht ausgewählt'; } const selectButton = card.querySelector('[data-lager-open-select]'); if (selectButton) { selectButton.dataset.lagerOpenSelectable = String(qualified); selectButton.disabled = !qualified || selected; selectButton.setAttribute('aria-disabled', String(!qualified || selected)); } list.append(card); syncLagerOpenLotCard(lot, balance, card); return card; };"; echo " const resetLagerNewLotConfirmation = () => { const drawer = getDrawer(); const confirmation = drawer?.querySelector('[data-lager-new-confirmation-card]'); const list = drawer?.querySelector('[data-lager-new-confirmation-empty-fields]'); const form = drawer?.querySelector('[data-lager-new-lot-form-element]'); if (confirmation) { confirmation.hidden = true; } if (list) { list.replaceChildren(); } if (form) { form.dataset.confirmationAccepted = 'false'; } if (drawer) { drawer.dataset.dialogOpen = 'false'; } };"; echo " const resetLagerOpenLotConfirmations = () => { const drawer = getDrawer(); drawer?.querySelectorAll('[data-lager-open-card-wrapper]').forEach((card) => { const confirmation = card.querySelector('[data-lager-open-confirmation-card]'); const list = card.querySelector('[data-lager-open-confirmation-empty-fields]'); const form = card.querySelector('[data-lager-open-edit-form-element]'); if (confirmation) { confirmation.hidden = true; } if (list) { list.replaceChildren(); } if (form) { form.dataset.confirmationAccepted = 'false'; } card.dataset.dialogOpen = 'false'; }); };";