diff --git a/db/README.md b/db/README.md index d220baf..7073e8c 100644 --- a/db/README.md +++ b/db/README.md @@ -14,6 +14,7 @@ 10. `db/migrations/0014_require_human_lot_activation.sql` 11. `db/migrations/0015_sales_order_payments.sql` 12. `db/migrations/0016_stock_lot_forecast_history.sql` +13. `db/migrations/0017_manufacturer_lot_traceability.sql` ## Beispielausfuehrung diff --git a/db/migrations/0017_manufacturer_lot_traceability.sql b/db/migrations/0017_manufacturer_lot_traceability.sql new file mode 100644 index 0000000..4046faf --- /dev/null +++ b/db/migrations/0017_manufacturer_lot_traceability.sql @@ -0,0 +1,53 @@ +BEGIN; + +CREATE TABLE party_role ( + party_id BIGINT NOT NULL REFERENCES party(id) ON DELETE CASCADE, + role_code TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT pk_party_role PRIMARY KEY (party_id, role_code), + CONSTRAINT chk_party_role_code CHECK (role_code IN ('manufacturer')) +); + +CREATE INDEX idx_party_role_code ON party_role(role_code); + +ALTER TABLE stock_lot + RENAME COLUMN supplier_lot_number TO manufacturer_lot_number; + +ALTER TABLE stock_lot + ADD COLUMN manufacturer_party_id BIGINT REFERENCES party(id) ON DELETE RESTRICT; + +INSERT INTO party (type, name, status, created_at, updated_at) +SELECT 'supplier', MIN(TRIM(supplier_name)), 'active', NOW(), NOW() +FROM stock_lot +WHERE supplier_name IS NOT NULL + AND TRIM(supplier_name) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM party p + WHERE LOWER(TRIM(p.name)) = LOWER(TRIM(stock_lot.supplier_name)) + ) +GROUP BY LOWER(TRIM(supplier_name)); + +INSERT INTO party_role (party_id, role_code) +SELECT p.id, 'manufacturer' +FROM party p +WHERE EXISTS ( + SELECT 1 + FROM stock_lot sl + WHERE sl.supplier_name IS NOT NULL + AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name)) + ) +ON CONFLICT (party_id, role_code) DO NOTHING; + +UPDATE stock_lot sl +SET manufacturer_party_id = p.id +FROM party p +WHERE sl.supplier_name IS NOT NULL + AND LOWER(TRIM(sl.supplier_name)) = LOWER(TRIM(p.name)); + +ALTER TABLE stock_lot + DROP COLUMN supplier_name; + +CREATE INDEX idx_stock_lot_manufacturer_party ON stock_lot(manufacturer_party_id); + +COMMIT; diff --git a/docs/modules/erp/lager/README.md b/docs/modules/erp/lager/README.md index a7ff902..e3c402b 100644 --- a/docs/modules/erp/lager/README.md +++ b/docs/modules/erp/lager/README.md @@ -18,6 +18,7 @@ Bestand, Charge, MHD und Bewegungen. - Warenabgang - Umlagerung - Chargenpflege +- Verknüpfung von Naurua- und Hersteller-Chargen - Virtuelle Fehlbestandschargen und menschlich bestaetigte Nachallokation ## Reads @@ -42,4 +43,6 @@ Bestand, Charge, MHD und Bewegungen. - `modules/erp/lager/service.php` +Hersteller-Stammdaten gehören zum Modul `kontakte`. Eine reale Lagercharge kann dort einen Hersteller und dessen Hersteller-Chargennummer referenzieren; die interne Naurua-Chargennummer bleibt Eigentum des Moduls `lager`. Virtuelle Fehlbestandschargen haben keinen Herstellerbezug. + Virtuelle Fehlbestandschargen tragen den Status `virtual_shortage`, sind nicht bestandswirksam und werden bei einer menschlich bestaetigten Nachallokation durch eine neue reale Chargenzuordnung ersetzt. Die virtuelle Ausgangszuordnung bleibt als Historie erhalten. diff --git a/modules/erp/kontakte/service.php b/modules/erp/kontakte/service.php index a606dce..14b101e 100644 --- a/modules/erp/kontakte/service.php +++ b/modules/erp/kontakte/service.php @@ -103,3 +103,81 @@ function upsert_addresses(PDO $pdo, int $partyId, array $data): void ]); } } + +function get_manufacturer_options(PDO $pdo): array +{ + $stmt = $pdo->query( + "SELECT p.id, p.name, p.email, p.phone + FROM party p + INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer' + WHERE p.status = 'active' + ORDER BY p.name ASC, p.id ASC" + ); + + return array_map( + static fn (array $row): array => [ + 'id' => (int) $row['id'], + 'name' => (string) $row['name'], + 'email' => (string) ($row['email'] ?? ''), + 'phone' => (string) ($row['phone'] ?? ''), + ], + $stmt->fetchAll() + ); +} + +function create_manufacturer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array +{ + $name = trim($name); + $email = $email !== null ? trim($email) : null; + $phone = $phone !== null ? trim($phone) : null; + + if ($name === '') { + throw new InvalidArgumentException('Herstellername fehlt'); + } + + $existingStmt = $pdo->prepare( + "SELECT p.id, p.name, p.email, p.phone + FROM party p + INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer' + WHERE LOWER(TRIM(p.name)) = LOWER(:name) + LIMIT 1 + FOR UPDATE" + ); + $existingStmt->execute([':name' => $name]); + $existing = $existingStmt->fetch(); + if (is_array($existing)) { + return [ + 'id' => (int) $existing['id'], + 'name' => (string) $existing['name'], + 'email' => (string) ($existing['email'] ?? ''), + 'phone' => (string) ($existing['phone'] ?? ''), + ]; + } + + $stmt = $pdo->prepare( + "INSERT INTO party (type, name, email, phone, status, created_at, updated_at) + VALUES ('supplier', :name, :email, :phone, 'active', NOW(), NOW()) + RETURNING id, name, email, phone" + ); + $stmt->execute([ + ':name' => $name, + ':email' => $email !== '' ? $email : null, + ':phone' => $phone !== '' ? $phone : null, + ]); + $party = $stmt->fetch(); + if (!is_array($party)) { + throw new RuntimeException('Hersteller konnte nicht angelegt werden'); + } + + $roleStmt = $pdo->prepare( + "INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'manufacturer')" + ); + $roleStmt->execute([':party_id' => (int) $party['id']]); + + return [ + 'id' => (int) $party['id'], + 'name' => (string) $party['name'], + 'email' => (string) ($party['email'] ?? ''), + 'phone' => (string) ($party['phone'] ?? ''), + ]; +} diff --git a/modules/erp/lager/api/lot.php b/modules/erp/lager/api/lot.php new file mode 100644 index 0000000..b1351af --- /dev/null +++ b/modules/erp/lager/api/lot.php @@ -0,0 +1,69 @@ + false, 'error' => 'Unauthorized']); + } + + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']); + } + + $jsonInput = file_get_contents('php://input'); + if ($jsonInput === false || trim($jsonInput) === '') { + json_response(400, ['ok' => false, 'error' => 'Empty payload']); + } + + $data = json_decode($jsonInput, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || !auth_validate_csrf_token($data['csrfToken'] ?? null)) { + json_response(403, ['ok' => false, 'error' => 'Invalid security token']); + } + + $action = trim((string) ($data['action'] ?? '')); + if ($action === 'create_manufacturer') { + $manufacturer = create_manufacturer( + $pdo, + (string) ($data['name'] ?? ''), + isset($data['email']) ? (string) $data['email'] : null, + isset($data['phone']) ? (string) $data['phone'] : null + ); + json_response(201, ['ok' => true, 'manufacturer' => $manufacturer]); + } + + if ($action === 'update_lot') { + $manufacturerPartyId = $data['manufacturerPartyId'] ?? null; + $manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' + ? null + : (int) $manufacturerPartyId; + $updatedLot = update_stock_lot_traceability( + $pdo, + (int) ($data['lotId'] ?? 0), + $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, + (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown') + ); + json_response(200, ['ok' => true, 'lot' => $updatedLot]); + } + + json_response(422, ['ok' => false, 'error' => 'Unsupported action']); +} catch (JsonException) { + json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']); +} catch (InvalidArgumentException $error) { + json_response(422, ['ok' => false, 'error' => $error->getMessage()]); +} catch (Throwable $error) { + json_response(500, ['ok' => false, 'error' => 'Internal server error: ' . $error->getMessage()]); +} diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index 4097c21..f151734 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -2,6 +2,7 @@ declare(strict_types=1); require_once __DIR__ . '/forecast.php'; +require_once __DIR__ . '/../../system/audit.php'; function get_default_location_ids(PDO $pdo): array { @@ -76,8 +77,13 @@ function get_lager_product_overview(PDO $pdo): array p.uom, COALESCE(SUM(v.qty_net), 0) AS available_qty, sl.lot_number, - sl.supplier_lot_number, + sl.id AS lot_id, + sl.manufacturer_party_id, + sl.manufacturer_lot_number, + mp.name AS manufacturer_name, + sl.mfg_date, sl.expiry_date, + sl.purchase_date, sl.sellout_date, sl.sellout_forecast_status, ( @@ -92,11 +98,13 @@ function get_lager_product_overview(PDO $pdo): array LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current' + LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id WHERE p.status = 'active' - GROUP BY p.id, p.name, p.sku, p.uom, sl.lot_number, sl.supplier_lot_number, - sl.expiry_date, sl.sellout_date, sl.sellout_forecast_status + GROUP BY p.id, p.name, p.sku, p.uom, sl.id, sl.lot_number, sl.manufacturer_party_id, + sl.manufacturer_lot_number, mp.name, sl.mfg_date, sl.expiry_date, sl.purchase_date, + sl.sellout_date, sl.sellout_forecast_status ORDER BY p.name ASC, p.id ASC" ); @@ -107,9 +115,14 @@ function get_lager_product_overview(PDO $pdo): array 'sku' => (string) $row['sku'], 'uom' => (string) $row['uom'], 'available_qty' => (float) $row['available_qty'], + 'lot_id' => $row['lot_id'] === null ? null : (int) $row['lot_id'], 'lot_number' => $row['lot_number'], - 'supplier_lot_number' => $row['supplier_lot_number'], + 'manufacturer_party_id' => $row['manufacturer_party_id'] === null ? null : (int) $row['manufacturer_party_id'], + 'manufacturer_lot_number' => $row['manufacturer_lot_number'], + 'manufacturer_name' => $row['manufacturer_name'], + 'mfg_date' => $row['mfg_date'], 'expiry_date' => $row['expiry_date'], + 'purchase_date' => $row['purchase_date'], 'sellout_date' => $row['sellout_date'], 'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'), 'virtual_lot_number' => $row['virtual_lot_number'], @@ -118,6 +131,126 @@ function get_lager_product_overview(PDO $pdo): array ); } +function update_stock_lot_traceability( + PDO $pdo, + int $lotId, + ?int $manufacturerPartyId, + ?string $manufacturerLotNumber, + ?string $mfgDate, + ?string $expiryDate, + ?string $purchaseDate, + string $changedBy +): array { + if ($lotId <= 0) { + throw new InvalidArgumentException('Chargen-ID fehlt'); + } + + $manufacturerLotNumber = $manufacturerLotNumber !== null ? trim($manufacturerLotNumber) : null; + if ($manufacturerLotNumber === '') { + $manufacturerLotNumber = null; + } + + $dates = [ + 'mfg_date' => $mfgDate, + 'expiry_date' => $expiryDate, + 'purchase_date' => $purchaseDate, + ]; + foreach ($dates as $field => $value) { + if ($value !== null && $value !== '') { + $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + $errors = DateTimeImmutable::getLastErrors(); + if ($parsed === false || (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0))) { + throw new InvalidArgumentException("Ungueltiges Datum fuer {$field}"); + } + } + $dates[$field] = $value !== null && $value !== '' ? $value : null; + } + + $startedTransaction = !$pdo->inTransaction(); + if ($startedTransaction) { + $pdo->beginTransaction(); + } + + try { + $lotStmt = $pdo->prepare( + "SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number, + mfg_date, expiry_date, purchase_date, status + FROM stock_lot + WHERE id = :id + FOR UPDATE" + ); + $lotStmt->execute([':id' => $lotId]); + $before = $lotStmt->fetch(); + if (!is_array($before)) { + throw new RuntimeException('Charge nicht gefunden'); + } + if ((string) $before['status'] === 'virtual_shortage') { + throw new InvalidArgumentException('Virtuelle Fehlbestandschargen koennen nicht gepflegt werden'); + } + + if ($manufacturerPartyId !== null) { + $manufacturerStmt = $pdo->prepare( + "SELECT p.id + FROM party p + INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer' + WHERE p.id = :id AND p.status = 'active' + LIMIT 1" + ); + $manufacturerStmt->execute([':id' => $manufacturerPartyId]); + if ($manufacturerStmt->fetchColumn() === false) { + throw new InvalidArgumentException('Hersteller ist nicht aktiv oder nicht als Hersteller gepflegt'); + } + } + + if ($manufacturerPartyId === null && $manufacturerLotNumber !== null) { + throw new InvalidArgumentException('Hersteller-Chargennummer benoetigt einen Hersteller'); + } + if ($manufacturerPartyId !== null && $manufacturerLotNumber === null && (string) $before['status'] !== 'open') { + throw new InvalidArgumentException('Hersteller-Chargennummer fehlt'); + } + + $updateStmt = $pdo->prepare( + "UPDATE stock_lot + SET manufacturer_party_id = :manufacturer_party_id, + manufacturer_lot_number = :manufacturer_lot_number, + mfg_date = :mfg_date, + expiry_date = :expiry_date, + purchase_date = :purchase_date, + updated_at = NOW() + WHERE id = :id" + ); + $updateStmt->execute([ + ':manufacturer_party_id' => $manufacturerPartyId, + ':manufacturer_lot_number' => $manufacturerLotNumber, + ':mfg_date' => $dates['mfg_date'], + ':expiry_date' => $dates['expiry_date'], + ':purchase_date' => $dates['purchase_date'], + ':id' => $lotId, + ]); + + $after = $before; + $after['manufacturer_party_id'] = $manufacturerPartyId; + $after['manufacturer_lot_number'] = $manufacturerLotNumber; + $after['mfg_date'] = $dates['mfg_date']; + $after['expiry_date'] = $dates['expiry_date']; + $after['purchase_date'] = $dates['purchase_date']; + system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'traceability_updated', $changedBy, $before, $after, [ + 'module' => 'erp.lager', + ]); + + if ($startedTransaction) { + $pdo->commit(); + } + + return $after; + } 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( diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index d31c9a2..9264a20 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -256,9 +256,14 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str $detailData = ' data-lager-drawer-forecast="' . auth_escape_html($forecastText) . '"' . ' data-lager-drawer-forecast-warning="' . ($forecastWarning ? 'true' : 'false') . '"' . ' data-lager-drawer-forecast-mhd-warning="' . ($forecastMhdWarning ? 'true' : 'false') . '"' + . ' data-lager-drawer-lot-id="' . auth_escape_html((string) ($row['lot_id'] ?? '')) . '"' . ' data-lager-drawer-lot-number="' . auth_escape_html((string) ($row['lot_number'] ?? '')) . '"' - . ' data-lager-drawer-supplier-lot-number="' . auth_escape_html((string) ($row['supplier_lot_number'] ?? '')) . '"' + . ' data-lager-drawer-manufacturer-party-id="' . auth_escape_html((string) ($row['manufacturer_party_id'] ?? '')) . '"' + . ' data-lager-drawer-manufacturer-name="' . auth_escape_html((string) ($row['manufacturer_name'] ?? '')) . '"' + . ' data-lager-drawer-manufacturer-lot-number="' . auth_escape_html((string) ($row['manufacturer_lot_number'] ?? '')) . '"' + . ' data-lager-drawer-mfg-date="' . auth_escape_html((string) ($row['mfg_date'] ?? '')) . '"' . ' data-lager-drawer-expiry-date="' . auth_escape_html((string) ($row['expiry_date'] ?? '')) . '"' + . ' data-lager-drawer-purchase-date="' . auth_escape_html((string) ($row['purchase_date'] ?? '')) . '"' . ' data-lager-drawer-virtual-lot-number="' . auth_escape_html((string) ($row['virtual_lot_number'] ?? '')) . '"'; } @@ -278,9 +283,14 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str return implode('', $html); } -function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): string +function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, array $lagerManufacturers, string $csrfToken): string { - return '
' + $manufacturerOptions = ''; + foreach ($lagerManufacturers as $manufacturer) { + $manufacturerOptions .= ''; + } + + return '
' . '
' . auth_render_lager_table('Produkte', $lagerProducts, ['Produktname', 'Anzahl im Lager', 'Verbraucht bis'], 'products') . auth_render_lager_table('Artikel', $lagerArtikel, ['Artikel-Name', 'Artikelnummer'], 'articles') @@ -299,7 +309,22 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): s . '' . '
' . '
Lagercharge
' - . '

Naurua-Chargennummer:
Hersteller-Chargennummer:
MHD:

' + . '

Naurua-Chargennummer:
Hersteller:
Hersteller-Chargennummer:
Herstellungsdatum:
MHD:
Einkaufsdatum:

' + . '
' . '' . '
' . '
Virtuelle Charge
' @@ -310,7 +335,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel): s . '
'; } -function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = []): void +function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = []): void { $otcProductRows = ''; foreach ($otcProducts as $product) { @@ -344,7 +369,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); $bestellungenContent = auth_render_bestellungen_content($bestellungenTable); - $lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel); + $lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel, $lagerManufacturers, auth_csrf_token()); $moduleContentCards = json_encode( [ 'Bestellungen' => $bestellungenContent, @@ -655,10 +680,13 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true';"; echo " const warningText = warning ? forecast : 'Keine akute Warnung.';"; echo " const lotNumber = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt';"; - echo " const supplierLotNumber = trigger.dataset.lagerDrawerSupplierLotNumber || 'Nicht hinterlegt';"; + echo " const manufacturerName = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt';"; + echo " const manufacturerLotNumber = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt';"; + echo " const mfgDate = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt';"; echo " const expiryDate = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt';"; + echo " const purchaseDate = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt';"; echo " const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || '';"; - echo " return ['
', '
', '
', '
', '
', '
', '', '
', '
', '
' + escapeLagerHtml(type) + ': ' + escapeLagerHtml(name) + '
', '

Name: ' + escapeLagerHtml(name) + '

' + (isProduct ? 'Anzahl im Lager:' : 'Artikelnummer:') + ' ' + escapeLagerHtml(value) + '

' + (isProduct ? '

Warnungen: ' + escapeLagerHtml(warningText) + '

Naurua-Chargennummer: ' + escapeLagerHtml(lotNumber) + '
Hersteller-Chargennummer: ' + escapeLagerHtml(supplierLotNumber) + '
MHD: ' + escapeLagerHtml(expiryDate) + '

Virtuelle Charge: ' + escapeLagerHtml(virtualLot || 'Keine virtuelle Fehlbestandscharge vorhanden.') + '

' : '') + '
'].join('');"; + echo " return ['
', '
', '
', '
', '
', '
', '', '
', '
', '
' + escapeLagerHtml(type) + ': ' + escapeLagerHtml(name) + '
', '

Name: ' + escapeLagerHtml(name) + '

' + (isProduct ? 'Anzahl im Lager:' : 'Artikelnummer:') + ' ' + escapeLagerHtml(value) + '

' + (isProduct ? '

Warnungen: ' + escapeLagerHtml(warningText) + '

Naurua-Chargennummer: ' + escapeLagerHtml(lotNumber) + '
Hersteller: ' + escapeLagerHtml(manufacturerName) + '
Hersteller-Chargennummer: ' + escapeLagerHtml(manufacturerLotNumber) + '
Herstellungsdatum: ' + escapeLagerHtml(mfgDate) + '
MHD: ' + escapeLagerHtml(expiryDate) + '
Einkaufsdatum: ' + escapeLagerHtml(purchaseDate) + '

Virtuelle Charge: ' + escapeLagerHtml(virtualLot || 'Keine virtuelle Fehlbestandscharge vorhanden.') + '

' : '') + '
'].join('');"; echo " };"; echo " const restoreLagerMobileDetail = () => {"; echo " if (lagerMobileDetailHost && lagerMobileDetailHost.parentNode) { lagerMobileDetailHost.parentNode.removeChild(lagerMobileDetailHost); }"; @@ -683,9 +711,12 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " const closeDrawer = () => { setDrawerState(false); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((trigger) => trigger.setAttribute('aria-expanded', 'false')); };"; echo " contentRoot.addEventListener('click', (event) => {"; echo " const trigger = event.target.closest('[data-lager-drawer-open]');"; - echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-supplier-lot-number]').textContent = trigger.dataset.lagerDrawerSupplierLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }"; + echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); if (manufacturer) { manufacturer.value = trigger.dataset.lagerDrawerManufacturerPartyId || ''; } const manufacturerLot = drawer.querySelector('[data-lager-manufacturer-lot]'); if (manufacturerLot) { manufacturerLot.value = trigger.dataset.lagerDrawerManufacturerLotNumber || ''; } const mfgDate = drawer.querySelector('[data-lager-mfg-date]'); if (mfgDate) { mfgDate.value = trigger.dataset.lagerDrawerMfgDate || ''; } const expiryDate = drawer.querySelector('[data-lager-expiry-date]'); if (expiryDate) { expiryDate.value = trigger.dataset.lagerDrawerExpiryDate || ''; } const purchaseDate = drawer.querySelector('[data-lager-purchase-date]'); if (purchaseDate) { purchaseDate.value = trigger.dataset.lagerDrawerPurchaseDate || ''; } const editForm = drawer.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } drawer.dataset.lagerActiveLotId = trigger.dataset.lagerDrawerLotId || ''; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }"; echo " if (event.target.closest('[data-lager-drawer-close]') || event.target.closest('[data-lager-drawer-backdrop]')) { closeDrawer(); }"; echo " });"; + 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 " contentRoot.addEventListener('submit', async (event) => { const editForm = event.target.closest('[data-lager-edit-form-element]'); const manufacturerForm = event.target.closest('[data-lager-manufacturer-form]'); if (!editForm && !manufacturerForm) { return; } event.preventDefault(); const drawer = getDrawer(); const status = drawer?.querySelector('[data-lager-edit-status]'); if (status) { status.textContent = 'Speichern läuft …'; } try { if (manufacturerForm) { const result = await postLagerChange({ action: 'create_manufacturer', name: manufacturerForm.querySelector('[data-lager-new-manufacturer-name]')?.value || '', email: manufacturerForm.querySelector('[data-lager-new-manufacturer-email]')?.value || '', phone: manufacturerForm.querySelector('[data-lager-new-manufacturer-phone]')?.value || '' }); const select = drawer?.querySelector('[data-lager-manufacturer]'); if (select && result.manufacturer) { const option = document.createElement('option'); option.value = String(result.manufacturer.id); option.textContent = result.manufacturer.name; select.appendChild(option); select.value = String(result.manufacturer.id); } manufacturerForm.reset(); if (status) { status.textContent = 'Hersteller angelegt. Bitte Charge speichern.'; } return; } 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 || '' }); window.location.reload(); } 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(); } });"; diff --git a/modules/system/audit.php b/modules/system/audit.php new file mode 100644 index 0000000..82e3a1e --- /dev/null +++ b/modules/system/audit.php @@ -0,0 +1,30 @@ +prepare( + "INSERT INTO audit_log ( + entity_name, entity_id, action, changed_by, before_data, after_data, context + ) VALUES ( + :entity_name, :entity_id, :action, :changed_by, :before_data::jsonb, :after_data::jsonb, :context::jsonb + )" + ); + $stmt->execute([ + ':entity_name' => $entityName, + ':entity_id' => $entityId, + ':action' => $action, + ':changed_by' => $changedBy, + ':before_data' => json_encode($beforeData, JSON_THROW_ON_ERROR), + ':after_data' => json_encode($afterData, JSON_THROW_ON_ERROR), + ':context' => json_encode($context, JSON_THROW_ON_ERROR), + ]); +} diff --git a/public/api/lager-charge.php b/public/api/lager-charge.php new file mode 100644 index 0000000..bba1b6e --- /dev/null +++ b/public/api/lager-charge.php @@ -0,0 +1,4 @@ + (string) ($_GET['bestellungen_search'] ?? ''), @@ -60,7 +62,7 @@ if ($currentUser !== null) { echo auth_render_bestellungen_content($bestellungenTable); exit; } - render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel); + render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers); exit; }