'movementNumber', 'text' => 'Bewegungs-Nr.', 'required' => false, 'modal' => false, 'table' => ['priority' => 10]], ['key' => 'movementType', 'text' => 'Typ', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['priority' => 9, 'filter' => 'iconSelect', 'filterOptions' => [ ['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'fas fa-plus-circle text-success'], ['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'fas fa-minus-circle text-danger'], ['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'fas fa-edit text-warning'], ]]], ['key' => 'articleId', 'text' => 'Artikel', 'required' => true, 'modal' => ['type' => 'articleSelect'], 'table' => ['priority' => 8, 'sortable' => false, 'filter' => 'text']], ['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['priority' => 7, 'filter' => 'select']], ['key' => 'quantity', 'text' => 'Menge', 'required' => true, 'modal' => ['type' => 'number', 'step' => '0.01', 'min' => '0.01'], 'table' => ['priority' => 6, 'filter' => false]], ['key' => 'quantityBefore', 'text' => 'Bestand vorher', 'required' => false, 'modal' => false, 'table' => ['priority' => 5, 'filter' => false]], ['key' => 'quantityAfter', 'text' => 'Bestand nachher', 'required' => false, 'modal' => false, 'table' => ['priority' => 4, 'filter' => false]], ['key' => 'reasonCategory', 'text' => 'Grund', 'required' => true, 'modal' => ['type' => 'select', 'items' => [], 'dependsOn' => 'movementType'], 'table' => ['priority' => 3, 'filter' => false]], ['key' => 'note', 'text' => 'Notiz', 'required' => false, 'modal' => ['type' => 'textarea'], 'table' => ['priority' => 2, 'filter' => false]], ['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => false, 'table' => ['priority' => 1, 'filter' => 'dateRange']], ]; protected array $additionalActions = []; protected array $permissionCheck = ['WarehouseUser']; protected array $infoMessages = [ 'create' => 'Lagerbewegung wurde erstellt', 'update' => 'Lagerbewegung wurde aktualisiert', 'delete' => 'Lagerbewegung wurde gelöscht', 'noChanges' => 'Keine Änderungen', ]; public function prepareCrudConfig() { // Populate movement type dropdown $movementTypes = [ ['value' => 'IN', 'text' => 'Einbuchung'], ['value' => 'OUT', 'text' => 'Ausbuchung'], ['value' => 'ADJUSTMENT', 'text' => 'Korrektur'], ]; // Populate locations dropdown (Office + Außenlager only) $allLocations = WarehouseLocationModel::getAll(); $locations = []; foreach ($allLocations as $location) { $title = strtolower($location->title); if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') { $locations[] = ['value' => $location->id, 'text' => $location->title]; } } // Get all reason categories for initial load $allReasons = WarehouseMovementModel::getReasonCategories(); $reasonItems = []; foreach ($allReasons as $type => $categories) { foreach ($categories as $key => $label) { $reasonItems[] = ['value' => $key, 'text' => $label, 'group' => $type]; } } foreach ($this->columns as &$col) { if ($col['key'] === 'movementType') { $col['modal']['items'] = $movementTypes; } if ($col['key'] === 'warehouseLocationId') { $col['modal']['items'] = $locations; $col['table']['filterOptions'] = $locations; } if ($col['key'] === 'reasonCategory') { $col['modal']['items'] = $reasonItems; } } $this->additionalJSVariables['REASON_CATEGORIES'] = $allReasons; } protected function beforeCreate(): bool { // Validate required fields $movementType = $this->postData['movementType'] ?? ''; $articleId = intval($this->postData['articleId'] ?? 0); $locationId = intval($this->postData['warehouseLocationId'] ?? 0); $quantity = floatval($this->postData['quantity'] ?? 0); if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) { $this->returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']); return false; } if ($articleId <= 0) { $this->returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']); return false; } if ($locationId <= 0) { $this->returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']); return false; } if ($quantity <= 0) { $this->returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); return false; } // Find or create WarehouseItem for this article at this location $db = FronkDB::singleton(); $existingItems = WarehouseItemModel::getAll([ 'articleId' => $articleId, 'warehouseLocationId' => $locationId ]); $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0; // Calculate new quantity based on movement type // Note: Negative stock is allowed (items can be taken out even if stock is 0) switch ($movementType) { case 'IN': $newQty = $currentQty + $quantity; break; case 'OUT': $newQty = $currentQty - $quantity; // Negative stock is allowed - no validation needed break; case 'ADJUSTMENT': // For adjustment, quantity is the new absolute value $newQty = $quantity; break; default: $newQty = $currentQty; } // Store before/after quantities $this->postData['quantityBefore'] = $currentQty; $this->postData['quantityAfter'] = $newQty; $this->postData['userId'] = $this->user->id; // Update or create WarehouseItem if ($warehouseItem) { $db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}"); $this->postData['warehouseItemId'] = $warehouseItem->id; } else { $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`) VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")"); $this->postData['warehouseItemId'] = $db->insert_id(); } return true; } protected function afterCreate($postData) { // Generate movement number $movement = WarehouseMovementModel::get($postData['id']); if ($movement) { $movementNumber = WarehouseMovementModel::generateMovementNumber(); $db = FronkDB::singleton(); $db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movement->id}"); } } protected function customRowsHandler($rows) { return array_map(fn($row) => $this->formatRow((array)$row), $rows); } protected function formatRow($row) { $rawType = $row['movementType']; if (!empty($row['articleId'])) { $article = WarehouseArticleModel::get($row['articleId']); if ($article) { $row['articleId'] = "{$article->articleNumber}
{$article->title}"; } } $row['quantityBefore'] = $row['quantityBefore'] !== null ? number_format((float)$row['quantityBefore'], 2, ',', '.') : '-'; $row['quantityAfter'] = $row['quantityAfter'] !== null ? number_format((float)$row['quantityAfter'], 2, ',', '.') : '-'; $row['quantity'] = number_format((float)$row['quantity'], 2, ',', '.'); $allCategories = WarehouseMovementModel::getReasonCategories(); $row['reasonCategory'] = $allCategories[$rawType][$row['reasonCategory']] ?? $row['reasonCategory']; return $row; } /** * Get reason categories for a specific movement type */ protected function getReasonCategoriesAction() { $type = $this->request->type ?? null; $categories = WarehouseMovementModel::getReasonCategories($type); if ($type && is_array($categories)) { $items = []; foreach ($categories as $key => $label) { $items[] = ['value' => $key, 'text' => $label]; } self::returnJson(['success' => true, 'categories' => $items]); } else { self::returnJson(['success' => true, 'categories' => $categories]); } } /** * Get current stock for an article at a location */ protected function getCurrentStockAction() { $articleId = intval($this->request->articleId ?? 0); $locationId = intval($this->request->locationId ?? 0); if (!$articleId || !$locationId) { self::returnJson(['success' => false, 'currentStock' => 0]); return; } $existingItems = WarehouseItemModel::getAll([ 'articleId' => $articleId, 'warehouseLocationId' => $locationId ]); $currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0; self::returnJson(['success' => true, 'currentStock' => $currentStock]); } }