Dokument löschen?
Bestätige durch Eingabe von DELETE.
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 = ' Keine Dokumente vorhanden. ' . auth_escape_html($filename) . '';
+ if ($comment !== '') {
+ $html .= ' Dokument löschen? Bestätige durch Eingabe von DELETE.Dokumente
';
+ $html .= '
Kommentar: ' . auth_escape_html($comment) . '';
+ }
+ $html .= '
'
. 'Hersteller: ' . auth_escape_html((string) ($lot['manufacturer_name'] ?? 'Nicht hinterlegt')) . '
'
. 'Hersteller-Chargennummer: ' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '
Leere Felder speichern?
Die folgenden Felder werden leer gespeichert:
Möchtest du diese offene Charge wirklich löschen?
Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von DELETE.
Naurua-Chargennummer:
Aktueller Bestand:
Eingangsmenge:
Hersteller:
Hersteller-Chargennummer:
Herstellungsdatum:
MHD:
Einkaufsdatum:
Naurua-Chargennummer:
Aktueller Bestand:
Eingangsmenge:
Hersteller:
Hersteller-Chargennummer:
Herstellungsdatum:
MHD:
Einkaufsdatum: