From 3000c9e2e70fc8fa8f9f648df96c66a564c14574 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 15 Dec 2025 23:47:16 +0100 Subject: [PATCH] implemented inventur and changed warehousearticle/category --- .../VueViews/WarehouseStocktakePWA.php | 615 ++++++++++++++++++ Layout/default/WarehouseArticle/LABEL.php | 40 ++ Layout/default/menu.php | 1 + .../WarehouseArticleController.php | 71 +- .../WarehouseCategory/WarehouseCategory.php | 2 +- .../WarehouseCategoryController.php | 36 +- .../WarehouseStocktakeController.php | 413 ++++++++++++ .../WarehouseStocktakeModel.php | 74 +++ .../WarehouseStocktakeItemController.php | 194 ++++++ .../WarehouseStocktakeItemModel.php | 38 ++ .../WarehouseStocktakeLogModel.php | 43 ++ .../WarehouseStocktakePWAController.php | 374 +++++++++++ ...0000_create_warehouse_stocktake_tables.php | 70 ++ ...150000_warehouse_category_set_prefixes.php | 55 ++ .../WarehouseArticle/WarehouseArticle.css | 66 +- .../WarehouseArticle/WarehouseArticle.js | 40 +- .../WarehouseStocktake/WarehouseStocktake.css | 224 +++++++ .../WarehouseStocktake/WarehouseStocktake.js | 368 +++++++++++ 18 files changed, 2711 insertions(+), 13 deletions(-) create mode 100644 Layout/default/VueViews/WarehouseStocktakePWA.php create mode 100644 Layout/default/WarehouseArticle/LABEL.php create mode 100644 application/WarehouseStocktake/WarehouseStocktakeController.php create mode 100644 application/WarehouseStocktake/WarehouseStocktakeModel.php create mode 100644 application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php create mode 100644 application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php create mode 100644 application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php create mode 100644 application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php create mode 100644 db/migrations/20251215120000_create_warehouse_stocktake_tables.php create mode 100644 db/migrations/20251215150000_warehouse_category_set_prefixes.php create mode 100644 public/js/pages/WarehouseStocktake/WarehouseStocktake.css create mode 100644 public/js/pages/WarehouseStocktake/WarehouseStocktake.js diff --git a/Layout/default/VueViews/WarehouseStocktakePWA.php b/Layout/default/VueViews/WarehouseStocktakePWA.php new file mode 100644 index 000000000..3b3ff2036 --- /dev/null +++ b/Layout/default/VueViews/WarehouseStocktakePWA.php @@ -0,0 +1,615 @@ + + + + + + + Inventur Scanner + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/Layout/default/WarehouseArticle/LABEL.php b/Layout/default/WarehouseArticle/LABEL.php new file mode 100644 index 000000000..4de88a8e9 --- /dev/null +++ b/Layout/default/WarehouseArticle/LABEL.php @@ -0,0 +1,40 @@ + QRCode::OUTPUT_IMAGE_PNG, + 'scale' => 10, + 'quietzoneSize' => 1, +]); + +// Generate QR code data - encode article ID for Inventur scanning +$qrData = "WA:" . $articleId . ":" . $articleNumber; +$qrCodeBase64 = (new QRCode($options))->render($qrData); +?> + + + + + + + + + + + +
+ + + +
+
+
+ + diff --git a/Layout/default/menu.php b/Layout/default/menu.php index a3dfc4035..baf61ddc7 100644 --- a/Layout/default/menu.php +++ b/Layout/default/menu.php @@ -184,6 +184,7 @@ can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Bestellwünsche
  • can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Lieferscheine
  • can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Projekte
  • + can("WarehouseAdmin")): ?>
  • "> Inventur
  • can("WarehouseAdmin")): ?>
  • "> Administration
  • diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php index e0c5f160e..fd3af534c 100644 --- a/application/WarehouseArticle/WarehouseArticleController.php +++ b/application/WarehouseArticle/WarehouseArticleController.php @@ -2,7 +2,7 @@ class WarehouseArticleController extends TTCrud { protected string $headerTitle = 'Artikel'; - protected $createText = 'Artikel erstellen'; + protected $createText = false; protected string $singleText = 'Artikel'; protected bool $reopenOnCreate = true; @@ -12,7 +12,7 @@ class WarehouseArticleController extends TTCrud { ['key' => 'articleNumber', 'text' => 'Nr.', 'required' => true], ['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]], ['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']], - ['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => false], + ['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]], ['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false], ['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']], ['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']], @@ -32,10 +32,13 @@ class WarehouseArticleController extends TTCrud { protected array $autocompleteColumns = ['articleNumber', 'title', 'description']; protected array $permissionCheck = ['WarehouseUser']; - protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']]; + protected array $additionalActions = [ + ['key' => 'printLabel','title' => 'Label drucken','class' => 'fas fa-print text-secondary'], + ['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary'] + ]; // @formatter:on - protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true]; + protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_PAGE_TITLE' => true]; protected function prepareCrudConfig() { $categories = array_map(fn($category) => ['value' => $category->id, 'text' => $category->name], WarehouseCategory::getAll()); @@ -131,6 +134,41 @@ class WarehouseArticleController extends TTCrud { self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); } + protected function getNextArticleNumberAction() { + $categoryId = intval($this->request->categoryId ?? 0); + if (!$categoryId) self::sendError("Kategorie nicht angegeben"); + + $category = WarehouseCategory::get($categoryId); + if (!$category) self::sendError("Kategorie nicht gefunden"); + if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix"); + + $prefix = $category->articleNumberPrefix; + $db = FronkDB::singleton(); + + // Get all existing article numbers with this prefix, sorted + $result = $db->query("SELECT CAST(articleNumber AS UNSIGNED) as num FROM WarehouseArticle WHERE articleNumber LIKE '{$prefix}%' ORDER BY num ASC"); + $existingNumbers = []; + while ($row = $db->fetch_array($result)) { + $existingNumbers[] = intval($row['num']); + } + + // Start from prefix * 10000 + 1 (e.g., 1800 -> 18000001) + $startNumber = intval($prefix) * 10000 + 1; + $nextNumber = $startNumber; + + // Find first gap + foreach ($existingNumbers as $num) { + if ($num == $nextNumber) { + $nextNumber++; + } else if ($num > $nextNumber) { + // Found a gap + break; + } + } + + self::returnJson(['success' => true, 'articleNumber' => str_pad($nextNumber, 8, '0', STR_PAD_LEFT)]); + } + protected function autocompleteAction() { $textKey = property_exists($this->model, 'name') ? 'name' : 'title'; if (strlen($this->request->searchedID) > 0) { @@ -163,4 +201,29 @@ class WarehouseArticleController extends TTCrud { return ['value' => $item->id, 'text' => $item->$textKey]; }, $data)); } + + protected function printLabelAction() { + $articleId = $this->request->id; + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::sendError("Artikel nicht gefunden", 404); + } + + $pdf_vars = [ + 'articleId' => $article->id, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title + ]; + + $pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars); + $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + + $filename = $pdf->render($wkhtmltopdfArgs); + + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="label-' . $article->articleNumber . '.pdf"'); + readfile($filename); + die(); + } } diff --git a/application/WarehouseCategory/WarehouseCategory.php b/application/WarehouseCategory/WarehouseCategory.php index aa6447a6f..129b19f18 100644 --- a/application/WarehouseCategory/WarehouseCategory.php +++ b/application/WarehouseCategory/WarehouseCategory.php @@ -3,7 +3,7 @@ class WarehouseCategory extends TTCrudBaseModel { public int $id; public string $name; public string $description; - public ?int $articleNumberPrefix; + public ?string $articleNumberPrefix; public int $create; public int $create_by; public ?int $edit; diff --git a/application/WarehouseCategory/WarehouseCategoryController.php b/application/WarehouseCategory/WarehouseCategoryController.php index d79cba66f..26d16ab83 100644 --- a/application/WarehouseCategory/WarehouseCategoryController.php +++ b/application/WarehouseCategory/WarehouseCategoryController.php @@ -9,7 +9,7 @@ class WarehouseCategoryController extends TTCrud { protected array $columns = [ ['key' => 'name', 'text' => 'Name', 'required' => true,], ['key' => 'description', 'text' => 'Beschreibung', 'required' => true], - ['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => true], + ['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']], ['key' => 'create', 'text' => 'Erstellt am', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], ['key' => 'create_by', 'text' => 'Erstellt von', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]], @@ -18,11 +18,45 @@ class WarehouseCategoryController extends TTCrud { protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']]; + protected function beforeCreate(): bool { + $this->postData['articleNumberPrefix'] = $this->getNextFreePrefix(); + return true; + } + protected function beforeUpdate($postData): bool { + // Preserve existing prefix - don't allow changes + $existing = WarehouseCategory::get($postData['id']); + if ($existing) { + $this->postData['articleNumberPrefix'] = $existing->articleNumberPrefix; + } (new WarehouseHistoryController)->create($postData, $this->mod); return true; } + private function getNextFreePrefix(): string { + $db = FronkDB::singleton(); + $result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1"); + $row = $db->fetch_array($result); + + if ($row && $row['articleNumberPrefix']) { + $lastPrefix = intval($row['articleNumberPrefix']); + // Skip special ranges (9900+) + if ($lastPrefix >= 9900) { + // Find highest non-special prefix + $result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL AND CAST(articleNumberPrefix AS UNSIGNED) < 9900 ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1"); + $row = $db->fetch_array($result); + $lastPrefix = $row ? intval($row['articleNumberPrefix']) : 1800; + } + $nextPrefix = $lastPrefix + 100; + // Skip 9900+ range + if ($nextPrefix >= 9900) $nextPrefix = 9900; + } else { + $nextPrefix = 1900; + } + + return str_pad($nextPrefix, 4, '0', STR_PAD_LEFT); + } + protected function getHistoryAction() { self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); } diff --git a/application/WarehouseStocktake/WarehouseStocktakeController.php b/application/WarehouseStocktake/WarehouseStocktakeController.php new file mode 100644 index 000000000..a3c113af8 --- /dev/null +++ b/application/WarehouseStocktake/WarehouseStocktakeController.php @@ -0,0 +1,413 @@ + 'stocktakeNumber', 'text' => 'Inventur-Nr.', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 10]], + ['key' => 'title', 'text' => 'Titel', 'required' => true, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 9]], + ['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true, + 'modal' => ['type' => 'select', 'items' => []], + 'table' => ['priority' => 8, 'filter' => 'select']], + ['key' => 'status', 'text' => 'Status', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 7, 'filter' => 'iconSelect', 'filterOptions' => [ + ['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary'], + ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success'], + ['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger'], + ]]], + ['key' => 'progress', 'text' => 'Fortschritt', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 6, 'sortable' => false, 'filter' => false]], + ['key' => 'startedAt', 'text' => 'Gestartet', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 5, 'filter' => false]], + ['key' => 'description', 'text' => 'Beschreibung', 'required' => false, + 'modal' => ['type' => 'textarea'], + 'table' => false], + ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, + 'modal' => false, + 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], + ]; + + protected array $additionalActions = [ + ['key' => 'startStocktake', 'title' => 'Inventur starten', 'class' => 'fas fa-play text-success'], + ['key' => 'viewProgress', 'title' => 'Fortschritt anzeigen', 'class' => 'fas fa-chart-line text-primary'], + ['key' => 'completeStocktake', 'title' => 'Inventur abschließen', 'class' => 'fas fa-check text-success'], + ['key' => 'applyToStock', 'title' => 'Auf Lager anwenden', 'class' => 'fas fa-boxes text-warning'], + ['key' => 'exportReport', 'title' => 'Excel Export', 'class' => 'fas fa-download text-secondary'], + ['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-secondary'], + ]; + + protected array $additionalJSVariables = []; + + protected array $statusOptions = [ + ['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary', 'color' => 'secondary'], + ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary', 'color' => 'primary'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success', 'color' => 'success'], + ['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger', 'color' => 'danger'], + ]; + + protected array $permissionCheck = ['WarehouseUser']; + + protected array $infoMessages = [ + 'create' => 'Inventur wurde erstellt', + 'update' => 'Inventur wurde aktualisiert', + 'delete' => 'Inventur wurde gelöscht', + 'noChanges' => 'Keine Änderungen', + ]; + + public function prepareCrudConfig() { + // Populate locations dropdown + $locations = array_map(function($location) { + return ['value' => $location->id, 'text' => $location->title]; + }, WarehouseLocationModel::getAll()); + + foreach ($this->columns as &$col) { + if ($col['key'] === 'warehouseLocationId') { + $col['modal']['items'] = $locations; + $col['table']['filterOptions'] = $locations; + } + } + + $this->additionalJSVariables['STATUS_ITEMS'] = $this->statusOptions; + } + + protected function beforeCreate(): bool { + // Set default values + $this->postData['status'] = 'planned'; + $this->postData['totalItems'] = 0; + $this->postData['totalScannedItems'] = 0; + return true; + } + + protected function afterCreate($postData) { + // Generate stocktake number + $stocktake = WarehouseStocktakeModel::get($postData['id']); + if ($stocktake) { + $stocktakeNumber = WarehouseStocktakeModel::generateStocktakeNumber(); + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET stocktakeNumber = '{$stocktakeNumber}' WHERE id = {$stocktake->id}"); + + // Log creation + WarehouseStocktakeLogModel::log($stocktake->id, 'created', null, ['title' => $stocktake->title]); + } + } + + protected function beforeUpdate($postData): bool { + (new WarehouseHistoryController)->create($postData, $this->mod); + return true; + } + + protected function customRowsHandler($rows) { + return array_map(fn($row) => $this->formatRow((array)$row), $rows); + } + + protected function formatRow($row) { + // Keep raw status for frontend conditional logic (don't modify 'status' - table needs raw value for filter) + $row['rawStatus'] = $row['status']; + + // Don't modify warehouseLocationId - table uses items to display the text + // Don't modify status - table uses filterOptions to display + + // Format progress (no filter on this column) + $row['progress'] = "{$row['totalScannedItems']} Artikel gescannt"; + + // Format startedAt (no filter on this column) + if ($row['startedAt']) { + $row['startedAt'] = date('d.m.Y H:i', $row['startedAt']); + } else { + $row['startedAt'] = '-'; + } + + return $row; + } + + /** + * Start a stocktake - changes status to in_progress + */ + protected function startStocktakeAction() { + $id = intval($this->postData['id'] ?? 0); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'planned') { + self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "Geplant" gestartet werden']); + return; + } + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET + status = 'in_progress', + startedAt = " . time() . ", + startedBy = {$this->user->id} + WHERE id = {$id}"); + + WarehouseStocktakeLogModel::log($id, 'started', null, ['startedBy' => $this->user->name]); + + self::returnJson(['success' => true, 'message' => 'Inventur wurde gestartet']); + } + + /** + * Complete a stocktake - changes status to completed + */ + protected function completeStocktakeAction() { + $id = intval($this->postData['id'] ?? 0); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "In Bearbeitung" abgeschlossen werden']); + return; + } + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET + status = 'completed', + completedAt = " . time() . ", + completedBy = {$this->user->id} + WHERE id = {$id}"); + + WarehouseStocktakeLogModel::log($id, 'completed', null, ['completedBy' => $this->user->name]); + + self::returnJson(['success' => true, 'message' => 'Inventur wurde abgeschlossen']); + } + + /** + * Get progress data for live updates + */ + protected function getProgressAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + // Get items via direct SQL to avoid any ORM issues + $db = FronkDB::singleton(); + $result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, w.name as scannedByName + FROM WarehouseStocktakeItem si + LEFT JOIN WarehouseArticle a ON si.articleId = a.id + LEFT JOIN Worker w ON si.scannedBy = w.id + WHERE si.stocktakeId = {$id} + ORDER BY si.`create` DESC"); + + $formattedItems = []; + while ($row = $result->fetch_assoc()) { + $formattedItems[] = [ + 'id' => (int)$row['id'], + 'articleId' => (int)$row['articleId'], + 'articleNumber' => $row['articleNumber'] ?? '', + 'articleTitle' => $row['articleTitle'] ?? 'Unbekannt', + 'countedQuantity' => (float)$row['countedQuantity'], + 'rack' => $row['rack'], + 'shelf' => $row['shelf'], + 'note' => $row['note'], + 'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null, + 'scannedBy' => $row['scannedByName'], + ]; + } + + $location = $stocktake->getLocation(); + + self::returnJson([ + 'success' => true, + 'stocktake' => [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'status' => $stocktake->status, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ], + 'items' => $formattedItems, + ]); + } + + /** + * Apply stocktake results to actual warehouse stock + */ + protected function applyToStockAction() { + $id = intval($this->postData['id'] ?? 0); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'completed') { + self::returnJson(['success' => false, 'message' => 'Inventur muss abgeschlossen sein, um die Bestände anzupassen']); + return; + } + + $db = FronkDB::singleton(); + $items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]); + $appliedCount = 0; + $createdCount = 0; + + foreach ($items as $item) { + // Check if a WarehouseItem already exists for this article at this location + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $item->articleId, + 'warehouseLocationId' => $stocktake->warehouseLocationId + ]); + + if (count($existingItems) > 0) { + // Update existing item + $existingItem = $existingItems[0]; + $oldQuantity = $existingItem->quantity; + + $db->query("UPDATE WarehouseItem SET + quantity = {$item->countedQuantity}, + rack = " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ", + shelf = " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . " + WHERE id = {$existingItem->id}"); + + // Log history + (new WarehouseHistoryController)->create([ + 'id' => $existingItem->id, + 'quantity' => $item->countedQuantity, + 'rack' => $item->rack, + 'shelf' => $item->shelf, + ], 'WarehouseItem'); + + $appliedCount++; + } else { + // Create new WarehouseItem + $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, rack, shelf, createBy, `create`) + VALUES ({$item->articleId}, {$stocktake->warehouseLocationId}, {$item->countedQuantity}, + " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ", + " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . ", + {$this->user->id}, " . time() . ")"); + + $createdCount++; + } + } + + WarehouseStocktakeLogModel::log($id, 'applied_to_stock', null, [ + 'appliedCount' => $appliedCount, + 'createdCount' => $createdCount, + 'appliedBy' => $this->user->name + ]); + + self::returnJson([ + 'success' => true, + 'message' => "Bestände angepasst: {$appliedCount} aktualisiert, {$createdCount} neu erstellt" + ]); + } + + /** + * Export stocktake report to Excel + */ + protected function exportReportAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]); + $rows = []; + + foreach ($items as $item) { + $article = $item->getArticle(); + $scannedBy = $item->getScannedByUser(); + + $rows[] = [ + 'Artikel-Nr.' => $article ? $article->articleNumber : '', + 'Artikel' => $article ? $article->title : 'Unbekannt', + 'Menge' => $item->countedQuantity, + 'Regal' => $item->rack ?? '', + 'Fach' => $item->shelf ?? '', + 'Notiz' => $item->note ?? '', + 'Gescannt am' => $item->scannedAt ? date('d.m.Y H:i', $item->scannedAt) : '', + 'Gescannt von' => $scannedBy ? $scannedBy->name : '', + ]; + } + + $filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv"; + $csv = Helper::arrayToCsv($rows); + + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + echo "\xEF\xBB\xBF"; // UTF-8 BOM + echo $csv; + exit; + } + + /** + * Get history for a stocktake + */ + protected function getHistoryAction() { + $this->prepareCrudConfig(); + self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); + } + + /** + * Get logs for a stocktake + */ + protected function getLogsAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $logs = WarehouseStocktakeLogModel::getLogsForStocktake($id); + $formattedLogs = []; + + foreach ($logs as $log) { + $user = UserModel::get($log->userId); + $formattedLogs[] = [ + 'id' => $log->id, + 'action' => $log->action, + 'details' => $log->details ? json_decode($log->details, true) : null, + 'userName' => $user ? $user->name : 'Unbekannt', + 'create' => date('d.m.Y H:i:s', $log->create), + ]; + } + + self::returnJson(['success' => true, 'logs' => $formattedLogs]); + } +} diff --git a/application/WarehouseStocktake/WarehouseStocktakeModel.php b/application/WarehouseStocktake/WarehouseStocktakeModel.php new file mode 100644 index 000000000..1821dc6f0 --- /dev/null +++ b/application/WarehouseStocktake/WarehouseStocktakeModel.php @@ -0,0 +1,74 @@ +query("SELECT stocktakeNumber FROM WarehouseStocktake + WHERE stocktakeNumber LIKE '{$prefix}%' + ORDER BY stocktakeNumber DESC LIMIT 1"); + + if ($row = $result->fetch_assoc()) { + $lastNumber = intval(substr($row['stocktakeNumber'], -6)); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT); + } + + /** + * Get location object + */ + public function getLocation(): ?WarehouseLocationModel { + return WarehouseLocationModel::get($this->warehouseLocationId); + } + + /** + * Get user who started the stocktake + */ + public function getStartedByUser(): ?UserModel { + if (!$this->startedBy) return null; + return UserModel::get($this->startedBy); + } + + /** + * Get items for this stocktake + */ + public function getItems(): array { + return WarehouseStocktakeItemModel::getAll(['stocktakeId' => $this->id]); + } + + /** + * Update progress counters + */ + public function updateProgress(): void { + $items = $this->getItems(); + $this->totalScannedItems = count($items); + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET totalScannedItems = {$this->totalScannedItems} WHERE id = {$this->id}"); + } +} diff --git a/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php b/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php new file mode 100644 index 000000000..f72f6ba98 --- /dev/null +++ b/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php @@ -0,0 +1,194 @@ + 'articleId', 'text' => 'Artikel', 'required' => true, + 'modal' => ['type' => 'autocomplete', 'apiUrl' => '/WarehouseArticle/autocomplete'], + 'table' => ['priority' => 10]], + ['key' => 'countedQuantity', 'text' => 'Menge', 'required' => true, + 'modal' => ['type' => 'number'], + 'table' => ['priority' => 9]], + ['key' => 'rack', 'text' => 'Regal', 'required' => false, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 8]], + ['key' => 'shelf', 'text' => 'Fach', 'required' => false, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 7]], + ['key' => 'note', 'text' => 'Notiz', 'required' => false, + 'modal' => ['type' => 'textarea'], + 'table' => ['priority' => 6]], + ['key' => 'scannedAt', 'text' => 'Gescannt am', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 5]], + ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, + 'modal' => false, + 'table' => ['filter' => false, 'sortable' => false]], + ]; + + protected array $permissionCheck = ['WarehouseUser']; + + protected function formatRow($row) { + // Format article + if ($row['articleId']) { + $article = WarehouseArticleModel::get($row['articleId']); + $row['articleId'] = $article ? "[{$article->articleNumber}] {$article->title}" : 'Unbekannt'; + } + + // Format scannedAt + if ($row['scannedAt']) { + $row['scannedAt'] = date('d.m.Y H:i', $row['scannedAt']); + } else { + $row['scannedAt'] = '-'; + } + + return $row; + } + + /** + * Add item via scan (used by PWA) + */ + protected function scanItemAction() { + $stocktakeId = intval($this->request->stocktakeId); + $articleId = intval($this->request->articleId); + $quantity = floatval($this->request->quantity); + $rack = $this->request->rack ?? null; + $shelf = $this->request->shelf ?? null; + $note = $this->request->note ?? null; + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + // Verify stocktake exists and is in progress + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']); + return; + } + + // Verify article exists + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + // Check if this article was already scanned in this stocktake + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId + ]); + + $db = FronkDB::singleton(); + + if ($existing) { + // Update existing entry - add to quantity + $newQuantity = $existing->countedQuantity + $quantity; + $db->query("UPDATE WarehouseStocktakeItem SET + countedQuantity = {$newQuantity}, + rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ", + shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ", + scannedAt = " . time() . ", + scannedBy = {$this->me->id} + WHERE id = {$existing->id}"); + + $itemId = $existing->id; + $message = "Artikel aktualisiert: {$article->title} (Neue Menge: {$newQuantity})"; + } else { + // Create new entry + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->me->id}, {$this->me->id}, " . time() . ")"); + + $itemId = $db->insert_id; + $message = "Artikel hinzugefügt: {$article->title} (Menge: {$quantity})"; + } + + // Update stocktake progress + $stocktake->updateProgress(); + + // Log the scan + WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'rack' => $rack, + 'shelf' => $shelf, + ]); + + self::returnJson([ + 'success' => true, + 'message' => $message, + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $existing ? ($existing->countedQuantity + $quantity) : $quantity, + 'rack' => $rack, + 'shelf' => $shelf, + ], + 'totalScanned' => $stocktake->totalScannedItems + 1, + ]); + } + + /** + * Get article info by QR code or article number + */ + protected function getArticleByCodeAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + // Try to parse QR code format: WH:articleId:articleNumber + $articleId = null; + if (preg_match('/^WH:(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + // Try to find by article number + $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]); + if ($article) { + $articleId = $article->id; + } + } + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'description' => $article->description ?? '', + 'unit' => $article->unit ?? 'Stk.', + ] + ]); + } +} diff --git a/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php b/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php new file mode 100644 index 000000000..34efb0e44 --- /dev/null +++ b/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php @@ -0,0 +1,38 @@ +articleId); + } + + /** + * Get the stocktake object + */ + public function getStocktake(): ?WarehouseStocktakeModel { + return WarehouseStocktakeModel::get($this->stocktakeId); + } + + /** + * Get user who scanned this item + */ + public function getScannedByUser(): ?UserModel { + if (!$this->scannedBy) return null; + return UserModel::get($this->scannedBy); + } +} diff --git a/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php b/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php new file mode 100644 index 000000000..2a09602c6 --- /dev/null +++ b/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php @@ -0,0 +1,43 @@ +get("me"); + $logUserId = $userId ?? ($me ? $me->id : 0); + + $log = new self(); + $log->stocktakeId = $stocktakeId; + $log->stocktakeItemId = $stocktakeItemId; + $log->action = $action; + $log->details = $details ? json_encode($details) : null; + $log->userId = $logUserId; + $log->create = time(); + + $db = FronkDB::singleton(); + $db->query("INSERT INTO WarehouseStocktakeLog (stocktakeId, stocktakeItemId, action, details, userId, `create`) + VALUES ({$log->stocktakeId}, " . ($log->stocktakeItemId ? $log->stocktakeItemId : "NULL") . ", + '{$db->escape($log->action)}', " . ($log->details ? "'{$db->escape($log->details)}'" : "NULL") . ", + {$log->userId}, {$log->create})"); + + $log->id = $db->insert_id; + return $log; + } + + /** + * Get logs for a stocktake + */ + public static function getLogsForStocktake(int $stocktakeId): array { + return self::getAll(['stocktakeId' => $stocktakeId], 0, 0, ['create' => 'DESC']); + } +} diff --git a/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php b/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php new file mode 100644 index 000000000..7bf72fefd --- /dev/null +++ b/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php @@ -0,0 +1,374 @@ +needlogin = true; + + $me = mfValuecache::singleton()->get("me"); + if (!$me) { + $me = new User(); + $me->loadMe(); + mfValuecache::singleton()->set("me", $me); + } + $this->me = $me; + $this->user = $me; + $this->layout()->set("me", $me); + + // Check permission + if (!$me->can('WarehouseUser')) { + $this->redirect("Dashboard"); + } + } + + /** + * Main PWA View + */ + public function indexAction() { + $this->layout()->setTemplate("VueViews/WarehouseStocktakePWA"); + $this->layout()->set("JSGlobals", [ + 'BASE_PATH' => '/WarehouseStocktakePWA', + 'USER_ID' => $this->user->id, + 'USER_NAME' => $this->user->name, + ]); + } + + /** + * Logout + */ + protected function logoutAction() { + mfLoginController::staticLogout(); + $this->redirect('/WarehouseStocktakePWA'); + } + + /** + * Get active stocktakes that user can participate in + */ + protected function getActiveStocktakesAction() { + $stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']); + + $result = []; + foreach ($stocktakes as $stocktake) { + $location = $stocktake->getLocation(); + $result[] = [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ]; + } + + self::returnJson(['success' => true, 'stocktakes' => $result]); + } + + /** + * Get stocktake details + */ + protected function getStocktakeAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $location = $stocktake->getLocation(); + + self::returnJson([ + 'success' => true, + 'stocktake' => [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'status' => $stocktake->status, + 'locationId' => $stocktake->warehouseLocationId, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ] + ]); + } + + /** + * Get article by QR code or article number + */ + protected function getArticleAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + $articleId = null; + + // Try to parse QR code format: WH:articleId:articleNumber + if (preg_match('/^WH:(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + // Try to find by article number + $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]); + if ($article) { + $articleId = $article->id; + } + } + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + // Get category name + $category = WarehouseCategoryModel::get($article->category_id); + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'description' => $article->description ?? '', + 'unit' => $article->unit ?? 'Stk.', + 'categoryName' => $category ? $category->name : '', + ] + ]); + } + + /** + * Search articles by text + */ + protected function searchArticlesAction() { + $query = $this->request->query; + + if (!$query || strlen($query) < 2) { + self::returnJson(['success' => true, 'articles' => []]); + return; + } + + $db = FronkDB::singleton(); + $escapedQuery = $db->escape($query); + + $result = $db->query("SELECT id, articleNumber, title, unit + FROM WarehouseArticle + WHERE (articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%') + AND (isEndOfLife IS NULL OR isEndOfLife = 0) + LIMIT 20"); + + $articles = []; + while ($row = $result->fetch_assoc()) { + $articles[] = [ + 'id' => intval($row['id']), + 'articleNumber' => $row['articleNumber'], + 'title' => $row['title'], + 'unit' => $row['unit'] ?? 'Stk.', + ]; + } + + self::returnJson(['success' => true, 'articles' => $articles]); + } + + /** + * Submit a scanned item + */ + protected function submitScanAction() { + $postData = json_decode(file_get_contents('php://input'), true) ?? []; + + $stocktakeId = intval($postData['stocktakeId'] ?? 0); + $articleId = intval($postData['articleId'] ?? 0); + $quantity = floatval($postData['quantity'] ?? 0); + $rack = $postData['rack'] ?? null; + $shelf = $postData['shelf'] ?? null; + $note = $postData['note'] ?? null; + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + if ($quantity <= 0) { + self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); + return; + } + + // Verify stocktake exists and is in progress + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']); + return; + } + + // Verify article exists + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + // Check if this article was already scanned in this stocktake + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId + ]); + + $db = FronkDB::singleton(); + + if ($existing) { + // Update existing entry - add to quantity + $newQuantity = $existing->countedQuantity + $quantity; + $db->query("UPDATE WarehouseStocktakeItem SET + countedQuantity = {$newQuantity}, + rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ", + shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ", + scannedAt = " . time() . ", + scannedBy = {$this->user->id} + WHERE id = {$existing->id}"); + + $itemId = $existing->id; + $finalQuantity = $newQuantity; + $isUpdate = true; + } else { + // Create new entry + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $itemId = $db->insert_id; + $finalQuantity = $quantity; + $isUpdate = false; + } + + // Update stocktake progress + $stocktake->updateProgress(); + + // Log the scan + WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'totalQuantity' => $finalQuantity, + 'rack' => $rack, + 'shelf' => $shelf, + 'isUpdate' => $isUpdate, + ]); + + self::returnJson([ + 'success' => true, + 'message' => $isUpdate + ? "Menge für '{$article->title}' erhöht auf {$finalQuantity}" + : "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})", + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $finalQuantity, + 'unit' => $article->unit ?? 'Stk.', + 'rack' => $rack, + 'shelf' => $shelf, + 'isUpdate' => $isUpdate, + ] + ]); + } + + /** + * Get recent scans for current user in a stocktake + */ + protected function getMyScansAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $db = FronkDB::singleton(); + $result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit + FROM WarehouseStocktakeItem si + JOIN WarehouseArticle wa ON wa.id = si.articleId + WHERE si.stocktakeId = {$stocktakeId} + AND si.scannedBy = {$this->user->id} + ORDER BY si.scannedAt DESC + LIMIT 50"); + + $items = []; + while ($row = $result->fetch_assoc()) { + $items[] = [ + 'id' => intval($row['id']), + 'articleId' => intval($row['articleId']), + 'articleNumber' => $row['articleNumber'], + 'articleTitle' => $row['articleTitle'], + 'countedQuantity' => floatval($row['countedQuantity']), + 'unit' => $row['unit'] ?? 'Stk.', + 'rack' => $row['rack'], + 'shelf' => $row['shelf'], + 'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null, + ]; + } + + self::returnJson(['success' => true, 'items' => $items]); + } + + /** + * Get progress stats + */ + protected function getProgressAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $db = FronkDB::singleton(); + + // Total scanned items + $totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}"); + $totalRow = $totalResult->fetch_assoc(); + $totalScanned = intval($totalRow['count']); + + // My scanned items + $myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}"); + $myRow = $myResult->fetch_assoc(); + $myScanned = intval($myRow['count']); + + self::returnJson([ + 'success' => true, + 'progress' => [ + 'totalScanned' => $totalScanned, + 'myScanned' => $myScanned, + 'status' => $stocktake->status, + ] + ]); + } +} diff --git a/db/migrations/20251215120000_create_warehouse_stocktake_tables.php b/db/migrations/20251215120000_create_warehouse_stocktake_tables.php new file mode 100644 index 000000000..c8e6f6faa --- /dev/null +++ b/db/migrations/20251215120000_create_warehouse_stocktake_tables.php @@ -0,0 +1,70 @@ +getEnvironment() == "thetool") { + // 1. Main Stocktake Session Table + $stocktake = $this->table('WarehouseStocktake'); + $stocktake->addColumn('stocktakeNumber', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('title', 'string', ['limit' => 255]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('warehouseLocationId', 'integer', ['signed' => true]) + ->addColumn('status', 'enum', ['values' => ['planned', 'in_progress', 'completed', 'cancelled'], 'default' => 'planned']) + ->addColumn('startedAt', 'integer', ['null' => true]) + ->addColumn('completedAt', 'integer', ['null' => true]) + ->addColumn('startedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('completedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('totalItems', 'integer', ['default' => 0]) + ->addColumn('totalScannedItems', 'integer', ['default' => 0]) + ->addColumn('notes', 'text', ['null' => true]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeNumber'], ['unique' => true]) + ->addIndex(['status']) + ->addIndex(['warehouseLocationId']) + ->create(); + + // 2. Individual Stocktake Items + $stocktakeItem = $this->table('WarehouseStocktakeItem'); + $stocktakeItem->addColumn('stocktakeId', 'integer', ['signed' => true]) + ->addColumn('articleId', 'integer', ['signed' => false]) + ->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('countedQuantity', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => 0]) + ->addColumn('rack', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('shelf', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('note', 'text', ['null' => true]) + ->addColumn('scannedAt', 'integer', ['null' => true]) + ->addColumn('scannedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeId']) + ->addIndex(['articleId']) + ->create(); + + // 3. Activity Log + $stocktakeLog = $this->table('WarehouseStocktakeLog'); + $stocktakeLog->addColumn('stocktakeId', 'integer', ['signed' => true]) + ->addColumn('stocktakeItemId', 'integer', ['null' => true, 'signed' => true]) + ->addColumn('action', 'string', ['limit' => 50]) + ->addColumn('details', 'text', ['null' => true]) + ->addColumn('userId', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeId']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WarehouseStocktakeLog')->drop()->save(); + $this->table('WarehouseStocktakeItem')->drop()->save(); + $this->table('WarehouseStocktake')->drop()->save(); + } + } +} \ No newline at end of file diff --git a/db/migrations/20251215150000_warehouse_category_set_prefixes.php b/db/migrations/20251215150000_warehouse_category_set_prefixes.php new file mode 100644 index 000000000..bbba8f3c4 --- /dev/null +++ b/db/migrations/20251215150000_warehouse_category_set_prefixes.php @@ -0,0 +1,55 @@ +table('WarehouseCategory'); + if (!$table->hasColumn('articleNumberPrefix')) { + $table->addColumn('articleNumberPrefix', 'string', ['limit' => 4, 'null' => true, 'after' => 'description']) + ->update(); + } + + if ($this->getEnvironment() == "thetool") { + $prefixes = [ + 1 => '1901', // Dienstleistungen + 3 => '9980', // EStmk Shop + 4 => '1400', // GPON OLTs und Bridges + 21 => '9990', // Import nicht erfolgreich + 5 => '1700', // Kabel-TV und Zubehör + 6 => '0700', // Kupferverkabelung und Schränke + 7 => '0400', // LWL Aussen- und Universalkabel + 8 => '0600', // LWL Boxen, Muffen und Gehäuse + 9 => '0900', // LWL Leitungsbau + 10 => '0500', // LWL Pigtails und Kupplungen + 11 => '0800', // LWL Splitter, Filter und Dämpfer + 12 => '1600', // Netzteile, USV, Akkus + 13 => '0300', // Patchkabel Kupfer + 14 => '0200', // Patchkabel LWL Multimode + 15 => '0100', // Patchkabel LWL Singlemode + 16 => '1000', // Richtfunk und WLAN + 17 => '1100', // Router und Zubehör + 18 => '1300', // SFP und Konverter + 19 => '1200', // Switches und Zubehör + 20 => '1500', // Telefonie und Zubehör + 2 => '1800', // Elektromaterial etc. (no articles, assign next free) + ]; + + foreach ($prefixes as $categoryId => $prefix) { + $this->execute("UPDATE WarehouseCategory SET articleNumberPrefix = '{$prefix}' WHERE id = {$categoryId}"); + } + } + } + + public function down(): void + { + $table = $this->table('WarehouseCategory'); + + if ($table->hasColumn('articleNumberPrefix')) { + $table->removeColumn('articleNumberPrefix')->update(); + } + } +} diff --git a/public/js/pages/WarehouseArticle/WarehouseArticle.css b/public/js/pages/WarehouseArticle/WarehouseArticle.css index 2dd411fa9..e61cde973 100644 --- a/public/js/pages/WarehouseArticle/WarehouseArticle.css +++ b/public/js/pages/WarehouseArticle/WarehouseArticle.css @@ -1,3 +1,13 @@ +/* Main card margin */ +#app > .card { + margin-top: 1rem; +} + +/* Reduce button margin */ +#app > .card > .card-body > .mb-3 { + margin-bottom: 0.5rem !important; +} + /* End of Life Row Highlighting */ .end-of-life { background-color: #f8d7da !important; @@ -6,8 +16,62 @@ /* * Modal Layout */ -.modal-body { +.modal-dialog.modal-xl .modal-body { overflow-x: hidden; + overflow-y: auto; + max-height: calc(100vh - 200px); +} + +.modal-dialog.modal-xl .modal-content { + max-height: calc(100vh - 50px); +} + +/* Disabled checkbox styling */ +.wa-checkbox-item.disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; +} + +/* Disabled form controls styling */ +.wa-modal-content .form-control:disabled, +.wa-modal-content .form-control[disabled], +.wa-modal-content textarea:disabled, +.wa-modal-content textarea[disabled] { + background-color: #e9ecef !important; + cursor: not-allowed !important; + opacity: 0.7; +} + +.wa-modal-content .tt-select-modern.disabled .tt-select-trigger, +.wa-modal-content .tt-select-trigger[disabled], +.wa-modal-content .tt-select-trigger.disabled { + background-color: #e9ecef !important; + cursor: not-allowed !important; + opacity: 0.7; + pointer-events: none; +} + +.wa-modal-content .form-group.disabled { + opacity: 0.7; + pointer-events: none; +} + +/* Disabled field styling */ +.wa-field-disabled { + opacity: 0.5; + position: relative; +} + +.wa-field-disabled::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + cursor: not-allowed; + z-index: 5; } .wa-modal-content { diff --git a/public/js/pages/WarehouseArticle/WarehouseArticle.js b/public/js/pages/WarehouseArticle/WarehouseArticle.js index 457fdf0f1..38572c325 100644 --- a/public/js/pages/WarehouseArticle/WarehouseArticle.js +++ b/public/js/pages/WarehouseArticle/WarehouseArticle.js @@ -331,7 +331,7 @@ Vue.component('warehouse-article-modal', {
    -
    +
    -
    +
    @@ -358,12 +359,13 @@ Vue.component('warehouse-article-modal', {
    -
    +
    -
    +
    -