'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) {
// Format movement type with badge
$typeLabels = [
'IN' => 'Einbuchung',
'OUT' => 'Ausbuchung',
'ADJUSTMENT' => 'Korrektur',
];
$row['movementType'] = $typeLabels[$row['movementType']] ?? $row['movementType'];
// Format article
if (!empty($row['articleId'])) {
$article = ArticleModel::get($row['articleId']);
if ($article) {
$row['articleId'] = "{$article->articleNumber}
{$article->title}";
}
}
// Format quantities
$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, ',', '.');
// Format reason category
$row['reasonCategory'] = WarehouseMovementModel::getReasonCategories()[$row['movementType']][$row['reasonCategory']] ?? $row['reasonCategory'];
// Format create date
if (!empty($row['create'])) {
$row['create'] = date('d.m.Y H:i', $row['create']);
}
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]);
}
}