From 2bec9347325a796e1499789adf1aafc5e086f8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Tue, 8 Sep 2026 11:38:10 +0200 Subject: [PATCH] Add documents to stock lots --- db/migrations/0021_stock_lot_documents.sql | 18 +++ modules/erp/lager/api/lot.php | 36 +++++ modules/erp/lager/service.php | 158 +++++++++++++++++++++ modules/shared/auth/ui/home.php | 54 ++++++- public/api/lager-document.php | 30 ++++ 5 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 db/migrations/0021_stock_lot_documents.sql create mode 100644 public/api/lager-document.php diff --git a/db/migrations/0021_stock_lot_documents.sql b/db/migrations/0021_stock_lot_documents.sql new file mode 100644 index 0000000..5810b55 --- /dev/null +++ b/db/migrations/0021_stock_lot_documents.sql @@ -0,0 +1,18 @@ +BEGIN; + +CREATE TABLE stock_lot_document ( + id BIGSERIAL PRIMARY KEY, + stock_lot_id BIGINT NOT NULL REFERENCES stock_lot(id) ON DELETE CASCADE, + original_filename TEXT NOT NULL, + stored_filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + comment TEXT, + uploaded_by TEXT NOT NULL, + uploaded_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT chk_stock_lot_document_size CHECK (size_bytes > 0 AND size_bytes <= 52428800) +); + +CREATE INDEX idx_stock_lot_document_lot ON stock_lot_document(stock_lot_id, id); + +COMMIT; diff --git a/modules/erp/lager/api/lot.php b/modules/erp/lager/api/lot.php index f699b6f..91da6e7 100644 --- a/modules/erp/lager/api/lot.php +++ b/modules/erp/lager/api/lot.php @@ -20,6 +20,32 @@ try { json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']); } + $contentType = (string) ($_SERVER['CONTENT_TYPE'] ?? ''); + if (str_starts_with($contentType, 'multipart/form-data')) { + if (!auth_validate_csrf_token($_POST['csrfToken'] ?? null)) { + json_response(403, ['ok' => false, 'error' => 'Invalid security token']); + } + if (($_POST['action'] ?? '') !== 'upload_document') { + json_response(422, ['ok' => false, 'error' => 'Unsupported action']); + } + $file = $_FILES['document'] ?? null; + if (!is_array($file)) { + throw new InvalidArgumentException('Dokumentdatei fehlt'); + } + $document = upload_stock_lot_document( + $pdo, + (int) ($_POST['lotId'] ?? 0), + $file, + (string) ($_POST['comment'] ?? ''), + (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown') + ); + json_response(201, ['ok' => true, 'document' => [ + 'id' => (int) $document['id'], + 'original_filename' => (string) $document['original_filename'], + 'comment' => $document['comment'] === null ? '' : (string) $document['comment'], + ]]); + } + $jsonInput = file_get_contents('php://input'); if ($jsonInput === false || trim($jsonInput) === '') { json_response(400, ['ok' => false, 'error' => 'Empty payload']); @@ -77,6 +103,16 @@ try { json_response(200, ['ok' => true, 'productId' => $productId, 'availableQty' => get_lager_product_available_qty($pdo, $productId)]); } + if ($action === 'update_document_comment') { + update_stock_lot_document_comment($pdo, (int) ($data['documentId'] ?? 0), (string) ($data['comment'] ?? '')); + json_response(200, ['ok' => true]); + } + + if ($action === 'delete_document') { + delete_stock_lot_document($pdo, (int) ($data['documentId'] ?? 0)); + json_response(200, ['ok' => true]); + } + if ($action === 'prepare_lot') { $productId = (int) ($data['productId'] ?? 0); $lotNumber = trim((string) ($data['lotNumber'] ?? '')); diff --git a/modules/erp/lager/service.php b/modules/erp/lager/service.php index bdc612e..6db2d65 100644 --- a/modules/erp/lager/service.php +++ b/modules/erp/lager/service.php @@ -235,6 +235,32 @@ function get_lager_product_overview(PDO $pdo): array } unset($product); + $lotIds = []; + foreach ($products as $product) { + if (!empty($product['lot_id'])) { + $lotIds[] = (int) $product['lot_id']; + } + foreach ($product['historical_lots'] as $lot) { + $lotIds[] = (int) ($lot['lot_id'] ?? 0); + } + foreach ($product['open_lots'] as $lot) { + $lotIds[] = (int) ($lot['lot_id'] ?? 0); + } + } + $documentsByLot = get_stock_lot_documents_by_lot_ids($pdo, $lotIds); + foreach ($products as &$product) { + $product['documents'] = $documentsByLot[(int) ($product['lot_id'] ?? 0)] ?? []; + foreach ($product['historical_lots'] as &$lot) { + $lot['documents'] = $documentsByLot[(int) ($lot['lot_id'] ?? 0)] ?? []; + } + unset($lot); + foreach ($product['open_lots'] as &$lot) { + $lot['documents'] = $documentsByLot[(int) ($lot['lot_id'] ?? 0)] ?? []; + } + unset($lot); + } + unset($product); + return $products; } @@ -251,6 +277,138 @@ function get_lager_product_available_qty(PDO $pdo, int $productId): float return (float) $stmt->fetchColumn(); } +function get_stock_lot_documents_by_lot_ids(PDO $pdo, array $lotIds): array +{ + $lotIds = array_values(array_unique(array_filter(array_map('intval', $lotIds), static fn (int $id): bool => $id > 0))); + if ($lotIds === []) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($lotIds), '?')); + $stmt = $pdo->prepare( + "SELECT id, stock_lot_id, original_filename, mime_type, size_bytes, comment, uploaded_at + FROM stock_lot_document + WHERE stock_lot_id IN ({$placeholders}) + ORDER BY stock_lot_id, id" + ); + $stmt->execute($lotIds); + $documents = []; + foreach ($stmt->fetchAll() as $row) { + $documents[(int) $row['stock_lot_id']][] = [ + 'id' => (int) $row['id'], + 'original_filename' => (string) $row['original_filename'], + 'mime_type' => (string) $row['mime_type'], + 'size_bytes' => (int) $row['size_bytes'], + 'comment' => $row['comment'] === null ? '' : (string) $row['comment'], + 'uploaded_at' => (string) $row['uploaded_at'], + ]; + } + return $documents; +} + +function get_stock_lot_document(PDO $pdo, int $documentId): array +{ + $stmt = $pdo->prepare( + 'SELECT id, stock_lot_id, original_filename, stored_filename, mime_type, size_bytes, comment + FROM stock_lot_document WHERE id = :id' + ); + $stmt->execute([':id' => $documentId]); + $document = $stmt->fetch(); + if (!is_array($document)) { + throw new RuntimeException('Dokument nicht gefunden'); + } + return $document; +} + +function update_stock_lot_document_comment(PDO $pdo, int $documentId, string $comment): void +{ + $document = get_stock_lot_document($pdo, $documentId); + $statusStmt = $pdo->prepare('SELECT status FROM stock_lot WHERE id = :id'); + $statusStmt->execute([':id' => (int) $document['stock_lot_id']]); + $status = $statusStmt->fetchColumn(); + if ($status !== 'current' && $status !== 'open') { + throw new InvalidArgumentException('Dokumente vergangener Chargen können nicht bearbeitet werden'); + } + $stmt = $pdo->prepare('UPDATE stock_lot_document SET comment = :comment WHERE id = :id'); + $stmt->execute([':comment' => trim($comment) === '' ? null : trim($comment), ':id' => $documentId]); + if ($stmt->rowCount() !== 1) { + throw new RuntimeException('Dokument nicht gefunden'); + } +} + +function delete_stock_lot_document(PDO $pdo, int $documentId): void +{ + $document = get_stock_lot_document($pdo, $documentId); + $statusStmt = $pdo->prepare('SELECT status FROM stock_lot WHERE id = :id'); + $statusStmt->execute([':id' => (int) $document['stock_lot_id']]); + $status = $statusStmt->fetchColumn(); + if ($status !== 'current' && $status !== 'open') { + throw new InvalidArgumentException('Dokumente vergangener Chargen können nicht gelöscht werden'); + } + $stmt = $pdo->prepare('DELETE FROM stock_lot_document WHERE id = :id'); + $stmt->execute([':id' => $documentId]); + $path = dirname(__DIR__, 3) . '/runtime/uploads/lager/' . basename((string) $document['stored_filename']); + if (is_file($path) && !unlink($path)) { + throw new RuntimeException('Dokumentdatei konnte nicht gelöscht werden'); + } +} + +function upload_stock_lot_document(PDO $pdo, int $lotId, array $file, string $comment, string $uploadedBy): array +{ + if ($lotId <= 0 || !is_uploaded_file((string) ($file['tmp_name'] ?? ''))) { + throw new InvalidArgumentException('Ungültige Dokumentdatei'); + } + $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); + if ($error !== UPLOAD_ERR_OK) { + throw new InvalidArgumentException('Dokument konnte nicht hochgeladen werden'); + } + $size = (int) ($file['size'] ?? 0); + if ($size <= 0 || $size > 52428800) { + throw new InvalidArgumentException('Dokument darf maximal 50 MB groß sein'); + } + $originalFilename = basename((string) ($file['name'] ?? '')); + if ($originalFilename === '') { + throw new InvalidArgumentException('Dateiname fehlt'); + } + $lotStmt = $pdo->prepare("SELECT id FROM stock_lot WHERE id = :id AND status IN ('current', 'open')"); + $lotStmt->execute([':id' => $lotId]); + if ($lotStmt->fetchColumn() === false) { + throw new InvalidArgumentException('Charge nicht gefunden'); + } + + $directory = dirname(__DIR__, 3) . '/runtime/uploads/lager'; + if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) { + throw new RuntimeException('Dokumentenverzeichnis konnte nicht erstellt werden'); + } + $storedFilename = bin2hex(random_bytes(16)); + $targetPath = $directory . '/' . $storedFilename; + if (!move_uploaded_file((string) $file['tmp_name'], $targetPath)) { + throw new RuntimeException('Dokument konnte nicht gespeichert werden'); + } + $mimeType = (new finfo(FILEINFO_MIME_TYPE))->file($targetPath) ?: 'application/octet-stream'; + try { + $stmt = $pdo->prepare( + 'INSERT INTO stock_lot_document + (stock_lot_id, original_filename, stored_filename, mime_type, size_bytes, comment, uploaded_by) + VALUES (:lot_id, :original_filename, :stored_filename, :mime_type, :size_bytes, :comment, :uploaded_by) + RETURNING id' + ); + $stmt->execute([ + ':lot_id' => $lotId, + ':original_filename' => $originalFilename, + ':stored_filename' => $storedFilename, + ':mime_type' => $mimeType, + ':size_bytes' => $size, + ':comment' => trim($comment) === '' ? null : trim($comment), + ':uploaded_by' => $uploadedBy, + ]); + return get_stock_lot_document($pdo, (int) $stmt->fetchColumn()); + } catch (Throwable $error) { + unlink($targetPath); + throw $error; + } +} + function get_lager_product_forecast(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 3e9df45..243b943 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -254,6 +254,7 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str . ' 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-documents="' . auth_escape_html((string) json_encode($row['documents'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '"' . ' data-lager-drawer-virtual-lot-number="' . auth_escape_html((string) ($row['virtual_lot_number'] ?? '')) . '"'; $openLot = $row['open_lots'][0] ?? []; $detailData .= ' data-lager-open-lot-id="' . auth_escape_html((string) ($openLot['lot_id'] ?? '')) . '"' @@ -297,6 +298,39 @@ function auth_format_lager_quantity(float $value): string : number_format($value, 2, ',', '.'); } +function auth_render_lager_documents(array $documents, bool $editable, string $scope): string +{ + $html = '

Dokumente

'; + $html .= '
'; + if ($documents === []) { + $html .= '

Keine Dokumente vorhanden.

'; + } else { + foreach ($documents as $document) { + $documentId = (int) ($document['id'] ?? 0); + $filename = (string) ($document['original_filename'] ?? 'Dokument'); + $comment = trim((string) ($document['comment'] ?? '')); + $html .= '

' . auth_escape_html($filename) . ''; + if ($comment !== '') { + $html .= '
Kommentar: ' . auth_escape_html($comment) . ''; + } + $html .= '

'; + } + } + $html .= '
'; + if ($editable) { + $html .= '
'; + foreach ($documents as $document) { + $documentId = (int) ($document['id'] ?? 0); + $filename = (string) ($document['original_filename'] ?? 'Dokument'); + $comment = (string) ($document['comment'] ?? ''); + $html .= '
'; + } + $html .= '

Noch keine Dateien ausgewählt.

'; + $html .= '
'; + } + return $html . '
'; +} + function auth_render_lager_historical_lots(array $lagerProducts): string { $html = []; @@ -318,6 +352,7 @@ function auth_render_lager_historical_lots(array $lagerProducts): string . 'MHD: ' . auth_format_lager_date($lot['expiry_date'] ?? null) . '
' . 'Hersteller: ' . auth_escape_html((string) ($lot['manufacturer_name'] ?? 'Nicht hinterlegt')) . '
' . 'Hersteller-Chargennummer: ' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '

' + . auth_render_lager_documents($lot['documents'] ?? [], false, 'historical-' . (int) ($lot['lot_id'] ?? 0)) . ''; } $html[] = ''; @@ -345,6 +380,7 @@ function auth_render_lager_open_lots(array $lagerProducts, string $manufacturerO . 'Herstellungsdatum: ' . auth_format_lager_date($lot['mfg_date'] ?? null) . '
' . 'MHD: ' . auth_format_lager_date($lot['expiry_date'] ?? null) . '
' . 'Einkaufsdatum: ' . auth_format_lager_date($lot['purchase_date'] ?? null) . '

' + . auth_render_lager_documents($lot['documents'] ?? [], false, 'open-read-' . $lotId) . '' . '' . ''; @@ -408,7 +445,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar . '
' . auth_render_lager_open_lots($lagerProducts, $manufacturerOptions) . '
' . '
' . '
Aktive Charge
' - . '

Naurua-Chargennummer:
Aktueller Bestand:
Eingangsmenge:
Hersteller:
Hersteller-Chargennummer:
Herstellungsdatum:
MHD:
Einkaufsdatum:

' + . '

Naurua-Chargennummer:
Aktueller Bestand:
Eingangsmenge:
Hersteller:
Hersteller-Chargennummer:
Herstellungsdatum:
MHD:
Einkaufsdatum:

' . auth_render_lager_documents([], false, 'current-read') . '
' . '