Files
thetool/application/MobileApp/Modules/Lager/Movement/MovementHandler.php
2026-01-13 12:44:45 +01:00

347 lines
13 KiB
PHP

<?php
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
/**
* Movement (Stock Movement) Handler
*
* Handles all endpoints for the Lager > Movement module.
* API Base: /MobileApp/Lager/Movement/{action}
*/
class MovementHandler extends MobileAppBaseHandler {
protected $requiredPermission = 'WarehouseUser';
/**
* Get available locations (Office + Außenlager only)
* GET /MobileApp/Lager/Movement/getLocations
*/
public function getLocationsAction() {
$allLocations = WarehouseLocationModel::getAll();
$locations = [];
foreach ($allLocations as $location) {
$title = strtolower($location->title);
if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') {
$locations[] = [
'id' => $location->id,
'title' => $location->title,
];
}
}
self::returnJson(['success' => true, 'locations' => $locations]);
}
/**
* Get article by QR code or article number
* GET /MobileApp/Lager/Movement/getArticle?code=X
*/
public function getArticleAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
$articleId = null;
// Check for QR code format WA:ID: or WH:ID:
if (preg_match('/^(?:WA|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;
}
$category = WarehouseCategory::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
* GET /MobileApp/Lager/Movement/searchArticles?query=X
*/
public function searchArticlesAction() {
$query = $this->request->query ?? '';
$db = $this->db();
$conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
if ($query && strlen($query) >= 2) {
$escapedQuery = $db->escape($query);
$conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
} else {
self::returnJson(['success' => true, 'articles' => []]);
return;
}
$whereClause = implode(' AND ', $conditions);
$result = $db->query("SELECT id, articleNumber, title, unit, category_id
FROM WarehouseArticle
WHERE {$whereClause}
ORDER BY title ASC
LIMIT 50");
$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]);
}
/**
* Get reason categories for a movement type
* GET /MobileApp/Lager/Movement/getReasonCategories?type=IN|OUT|ADJUSTMENT
*/
public 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
* GET /MobileApp/Lager/Movement/getCurrentStock?articleId=X&locationId=X
*/
public function getCurrentStockAction() {
$articleId = intval($this->request->articleId ?? 0);
$locationId = intval($this->request->locationId ?? 0);
if (!$articleId || !$locationId) {
self::returnJson(['success' => true, '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]);
}
/**
* Submit a stock movement
* POST /MobileApp/Lager/Movement/submitMovement
*/
public function submitMovementAction() {
$postData = $this->getPostData();
$movementType = $postData['movementType'] ?? '';
$articleId = intval($postData['articleId'] ?? 0);
$locationId = intval($postData['locationId'] ?? 0);
$quantity = floatval($postData['quantity'] ?? 0);
$reasonCategory = $postData['reasonCategory'] ?? '';
$note = $postData['note'] ?? null;
// Validate required fields
if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) {
self::returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']);
return;
}
if ($articleId <= 0) {
self::returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']);
return;
}
if ($locationId <= 0) {
self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
return;
}
if ($quantity <= 0) {
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
return;
}
if (empty($reasonCategory)) {
self::returnJson(['success' => false, 'message' => 'Bitte Grund auswählen']);
return;
}
// Get article info
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$db = $this->db();
// Find or create WarehouseItem for this article at this location
$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;
}
// Update or create WarehouseItem
$warehouseItemId = null;
if ($warehouseItem) {
$db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
$warehouseItemId = $warehouseItem->id;
} else {
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
$warehouseItemId = $db->insert_id;
}
// Create the movement record
$noteEscaped = $note ? "'" . $db->escape($note) . "'" : "NULL";
$db->query("INSERT INTO WarehouseMovement
(movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, note, userId, createBy, `create`)
VALUES ('{$movementType}', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, '{$db->escape($reasonCategory)}', {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")");
$movementId = $db->insert_id;
// Generate movement number
$movementNumber = WarehouseMovementModel::generateMovementNumber();
$db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}");
// Get type label for message
$typeLabels = ['IN' => 'Einbuchung', 'OUT' => 'Ausbuchung', 'ADJUSTMENT' => 'Korrektur'];
$typeLabel = $typeLabels[$movementType] ?? $movementType;
self::returnJson([
'success' => true,
'message' => "{$typeLabel} erfolgreich: {$quantity} x {$article->title}",
'movement' => [
'id' => $movementId,
'movementNumber' => $movementNumber,
'movementType' => $movementType,
'articleId' => $articleId,
'articleTitle' => $article->title,
'quantity' => $quantity,
'quantityBefore' => $currentQty,
'quantityAfter' => $newQty,
]
]);
}
/**
* Get recent movements by current user
* GET /MobileApp/Lager/Movement/getMyMovements
*/
public function getMyMovementsAction() {
$locationId = intval($this->request->locationId ?? 0);
$limit = intval($this->request->limit ?? 20);
$db = $this->db();
$whereClause = "m.userId = {$this->user->id}";
if ($locationId > 0) {
$whereClause .= " AND m.warehouseLocationId = {$locationId}";
}
$result = $db->query("SELECT m.*, wa.articleNumber, wa.title as articleTitle, wa.unit, wl.title as locationTitle
FROM WarehouseMovement m
LEFT JOIN WarehouseArticle wa ON wa.id = m.articleId
LEFT JOIN WarehouseLocation wl ON wl.id = m.warehouseLocationId
WHERE {$whereClause}
ORDER BY m.`create` DESC
LIMIT {$limit}");
$movements = [];
while ($row = $result->fetch_assoc()) {
$movements[] = [
'id' => intval($row['id']),
'movementNumber' => $row['movementNumber'],
'movementType' => $row['movementType'],
'articleId' => intval($row['articleId']),
'articleNumber' => $row['articleNumber'],
'articleTitle' => $row['articleTitle'],
'unit' => $row['unit'] ?? 'Stk.',
'locationTitle' => $row['locationTitle'],
'quantity' => floatval($row['quantity']),
'quantityBefore' => floatval($row['quantityBefore']),
'quantityAfter' => floatval($row['quantityAfter']),
'reasonCategory' => $row['reasonCategory'],
'note' => $row['note'],
'create' => date('d.m.Y H:i', $row['create']),
];
}
self::returnJson(['success' => true, 'movements' => $movements]);
}
/**
* Get movement types with labels
* GET /MobileApp/Lager/Movement/getMovementTypes
*/
public function getMovementTypesAction() {
$types = [
['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'plus-circle', 'color' => 'green'],
['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'minus-circle', 'color' => 'red'],
['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'edit', 'color' => 'yellow'],
];
self::returnJson(['success' => true, 'types' => $types]);
}
}