initial commit of mobile app
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
|
||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||
|
||||
/**
|
||||
* Warehouse Stocktake Handler
|
||||
*
|
||||
* Handles all endpoints for the Warehouse Stocktake PWA.
|
||||
* Migrated from WarehouseStocktakePWAController with new structure.
|
||||
*/
|
||||
class WarehouseStocktakeHandler extends MobileAppBaseHandler {
|
||||
|
||||
protected $requiredPermission = 'WarehouseUser';
|
||||
protected $appName = 'WarehouseStocktake';
|
||||
protected $viewTemplate = 'MobileApp/WarehouseStocktake';
|
||||
|
||||
/**
|
||||
* Get active stocktakes that user can participate in
|
||||
* GET /MobileApp/WarehouseStocktake/getActiveStocktakes
|
||||
*/
|
||||
public 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
|
||||
* GET /MobileApp/WarehouseStocktake/getStocktake?id=X
|
||||
*/
|
||||
public 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
|
||||
* GET /MobileApp/WarehouseStocktake/getArticle?code=X
|
||||
*/
|
||||
public 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: WA:articleId:articleNumber (Warehouse Article)
|
||||
// Also accept WH: for backwards compatibility
|
||||
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;
|
||||
}
|
||||
|
||||
// Get category name
|
||||
$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 by text with optional category filter
|
||||
* GET /MobileApp/WarehouseStocktake/searchArticles?query=X&categoryId=Y
|
||||
*/
|
||||
public function searchArticlesAction() {
|
||||
$query = $this->request->query ?? '';
|
||||
$categoryId = intval($this->request->categoryId ?? 0);
|
||||
|
||||
$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}%')";
|
||||
}
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$conditions[] = "category_id = {$categoryId}";
|
||||
}
|
||||
|
||||
if (count($conditions) === 1 && !$categoryId) {
|
||||
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.',
|
||||
'categoryId' => intval($row['category_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'articles' => $articles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories for browsing
|
||||
* GET /MobileApp/WarehouseStocktake/getCategories
|
||||
*/
|
||||
public function getCategoriesAction() {
|
||||
$db = $this->db();
|
||||
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
|
||||
|
||||
$categories = [];
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$categories[] = [
|
||||
'id' => intval($row['id']),
|
||||
'name' => $row['name'],
|
||||
];
|
||||
}
|
||||
self::returnJson(['success' => true, 'categories' => $categories]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if article is already scanned in stocktake
|
||||
* GET /MobileApp/WarehouseStocktake/checkAlreadyScanned?stocktakeId=X&articleId=Y
|
||||
*/
|
||||
public function checkAlreadyScannedAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
$articleId = intval($this->request->articleId);
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$db = $this->db();
|
||||
$scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
|
||||
$scannedByRow = $scannedByResult->fetch_assoc();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'alreadyScanned' => true,
|
||||
'existingItem' => [
|
||||
'id' => $existing->id,
|
||||
'countedQuantity' => $existing->countedQuantity,
|
||||
'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
|
||||
'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
self::returnJson(['success' => true, 'alreadyScanned' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a scanned item
|
||||
* POST /MobileApp/WarehouseStocktake/submitScan
|
||||
*/
|
||||
public function submitScanAction() {
|
||||
$postData = $this->getPostData();
|
||||
|
||||
$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;
|
||||
$overwrite = boolval($postData['overwrite'] ?? false);
|
||||
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$db = $this->db();
|
||||
|
||||
// If overwrite mode is enabled, mark existing item as overwritten
|
||||
if ($overwrite && $overwriteItemId) {
|
||||
// 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;
|
||||
|
||||
// Mark old item as overwritten by new item
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
|
||||
|
||||
$finalQuantity = $quantity;
|
||||
|
||||
// Log the overwrite
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'overwrittenItemId' => $overwriteItemId,
|
||||
]);
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isOverwrite' => true,
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this article was already scanned in this stocktake (non-overwritten)
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
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
|
||||
* GET /MobileApp/WarehouseStocktake/getMyScans?stocktakeId=X
|
||||
*/
|
||||
public function getMyScansAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
|
||||
if (!$stocktakeId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->db();
|
||||
$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
|
||||
* GET /MobileApp/WarehouseStocktake/getProgress?stocktakeId=X
|
||||
*/
|
||||
public 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 = $this->db();
|
||||
|
||||
// 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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* MobileApp Controller
|
||||
*
|
||||
* Main dispatcher for the Mobile PWA application.
|
||||
*
|
||||
* URL Structure:
|
||||
* - /MobileApp → Main app (Vue SPA)
|
||||
* - /MobileApp/auth/{action} → Auth endpoints (login/logout/check)
|
||||
* - /MobileApp/{module}/{submodule} → Module view (handled by Vue SPA)
|
||||
* - /MobileApp/{module}/{submodule}/{action} → API endpoints
|
||||
*
|
||||
* Example:
|
||||
* - /MobileApp → Shows main menu
|
||||
* - /MobileApp/Lager/Inventur → Shows stocktake (handled by Vue)
|
||||
* - /MobileApp/Lager/Inventur/getActiveStocktakes → API call
|
||||
*/
|
||||
class MobileAppController extends mfBaseController {
|
||||
|
||||
protected $user;
|
||||
|
||||
protected function init() {
|
||||
// We handle auth ourselves
|
||||
$this->needlogin = false;
|
||||
|
||||
// Try to load user if session exists
|
||||
$me = mfValuecache::singleton()->get("me");
|
||||
if (!$me) {
|
||||
if (mfLoginController::isLoggedIn()) {
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
mfValuecache::singleton()->set("me", $me);
|
||||
}
|
||||
}
|
||||
$this->user = $me;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main dispatcher
|
||||
*/
|
||||
public function indexAction() {
|
||||
$module = $this->request->module ?? null;
|
||||
$submodule = $this->request->submodule ?? null;
|
||||
$endpoint = $this->request->endpoint ?? null;
|
||||
|
||||
// Auth endpoints: /MobileApp/auth/{action}
|
||||
if (strtolower($module) === 'auth') {
|
||||
return $this->handleAuth($submodule ?? 'check');
|
||||
}
|
||||
|
||||
// API call: /MobileApp/{module}/{submodule}/{endpoint}
|
||||
if ($module && $submodule && $endpoint) {
|
||||
return $this->handleApiCall($module, $submodule, $endpoint);
|
||||
}
|
||||
|
||||
// Everything else: render the main Vue SPA
|
||||
// The Vue app handles internal routing for /MobileApp, /MobileApp/Lager, /MobileApp/Lager/Inventur, etc.
|
||||
return $this->renderApp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the main Vue SPA
|
||||
*/
|
||||
protected function renderApp() {
|
||||
$this->layout()->setTemplate("MobileApp/App");
|
||||
$this->layout()->set("JSGlobals", [
|
||||
'BASE_PATH' => '/MobileApp',
|
||||
'USER' => $this->user ? [
|
||||
'id' => $this->user->id,
|
||||
'name' => $this->user->name,
|
||||
'username' => $this->user->username,
|
||||
] : null,
|
||||
'INITIAL_PATH' => $_SERVER['REQUEST_URI'] ?? '/MobileApp',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication endpoints
|
||||
*/
|
||||
protected function handleAuth($action) {
|
||||
switch (strtolower($action)) {
|
||||
case 'login':
|
||||
return $this->authLogin();
|
||||
case 'verify2fa':
|
||||
return $this->authVerify2FA();
|
||||
case 'resend2fa':
|
||||
return $this->authResend2FA();
|
||||
case 'logout':
|
||||
return $this->authLogout();
|
||||
case 'check':
|
||||
return $this->authCheck();
|
||||
default:
|
||||
self::returnJson(['success' => false, 'error' => 'Unknown auth endpoint'], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /MobileApp/auth/login
|
||||
*
|
||||
* Step 1 of authentication. If 2FA is required, returns requires2FA: true
|
||||
* and the frontend should proceed to verify2fa endpoint.
|
||||
*/
|
||||
protected function authLogin() {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
return;
|
||||
}
|
||||
|
||||
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$username = $postData['username'] ?? '';
|
||||
$password = $postData['password'] ?? '';
|
||||
$rememberMe = $postData['rememberMe'] ?? false;
|
||||
|
||||
if (!$username || !$password) {
|
||||
self::returnJson(['success' => false, 'message' => 'Benutzername und Passwort erforderlich']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$escapedUsername = $db->escape($username);
|
||||
|
||||
$res = $db->select(MFUSERTABLE, "*", "username='$escapedUsername'");
|
||||
if (!$db->num_rows($res)) {
|
||||
sleep(1);
|
||||
self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
|
||||
return;
|
||||
}
|
||||
|
||||
$userRow = $db->fetch_object($res);
|
||||
|
||||
if ($userRow->active == 0) {
|
||||
self::returnJson(['success' => false, 'message' => 'Benutzer ist deaktiviert']);
|
||||
return;
|
||||
}
|
||||
|
||||
$hash = $userRow->password;
|
||||
$salt = substr($hash, 0, 16);
|
||||
$passhash = mfLoginController::generatePasswordHash($password, $salt);
|
||||
|
||||
if ($passhash !== $hash) {
|
||||
sleep(1);
|
||||
self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
if ($userRow->twofactor !== "0") {
|
||||
// Generate and send 2FA code
|
||||
$twoFactor = new UserTwofactor($userRow->id);
|
||||
$twoFactor->sendCode();
|
||||
|
||||
// Store pending auth in session for 2FA verification
|
||||
$_SESSION['mobileapp_2fa_pending'] = [
|
||||
'user_id' => $userRow->id,
|
||||
'username' => $userRow->username,
|
||||
'remember_me' => $rememberMe,
|
||||
'timestamp' => time()
|
||||
];
|
||||
|
||||
// Determine delivery method for UI feedback
|
||||
$deliveryMethod = $userRow->twofactor == 1 ? 'email' : 'sms';
|
||||
$maskedTarget = $deliveryMethod === 'email'
|
||||
? $this->maskEmail($userRow->email)
|
||||
: $this->maskPhone($userRow->mobile);
|
||||
|
||||
self::returnJson([
|
||||
'success' => false,
|
||||
'requires2FA' => true,
|
||||
'deliveryMethod' => $deliveryMethod,
|
||||
'maskedTarget' => $maskedTarget,
|
||||
'message' => 'Verifizierungscode wurde gesendet'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// No 2FA - complete login directly
|
||||
$this->completeLogin($userRow, $rememberMe);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /MobileApp/auth/verify2fa
|
||||
*
|
||||
* Step 2 of authentication - verify the 2FA code
|
||||
*/
|
||||
protected function authVerify2FA() {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
return;
|
||||
}
|
||||
|
||||
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$code = $postData['code'] ?? '';
|
||||
|
||||
// Check for pending 2FA session
|
||||
if (!isset($_SESSION['mobileapp_2fa_pending'])) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
|
||||
return;
|
||||
}
|
||||
|
||||
$pending = $_SESSION['mobileapp_2fa_pending'];
|
||||
|
||||
// Check if pending session is expired (10 minutes max)
|
||||
if (time() - $pending['timestamp'] > 600) {
|
||||
unset($_SESSION['mobileapp_2fa_pending']);
|
||||
self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$code || strlen($code) !== 5) {
|
||||
self::returnJson(['success' => false, 'message' => 'Bitte gib den 5-stelligen Code ein']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$userId = intval($pending['user_id']);
|
||||
|
||||
// Get user's 2FA code and timestamp
|
||||
$res = $db->select(MFUSERTABLE, "twofactorcode, twofactortimestamp, username", "id = {$userId}");
|
||||
if (!$db->num_rows($res)) {
|
||||
unset($_SESSION['mobileapp_2fa_pending']);
|
||||
self::returnJson(['success' => false, 'message' => 'Benutzer nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$userRow = $db->fetch_object($res);
|
||||
$storedCode = $userRow->twofactorcode;
|
||||
$codeTimestamp = intval($userRow->twofactortimestamp);
|
||||
|
||||
// Check if code is expired (5 minutes)
|
||||
if (time() - $codeTimestamp > 300) {
|
||||
self::returnJson(['success' => false, 'message' => 'Code abgelaufen. Bitte neuen Code anfordern.', 'codeExpired' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify code
|
||||
if ($code !== $storedCode) {
|
||||
sleep(1); // Rate limiting
|
||||
self::returnJson(['success' => false, 'message' => 'Ungültiger Code']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the 2FA code
|
||||
$twoFactor = new UserTwofactor($userId);
|
||||
$twoFactor->removeCode();
|
||||
|
||||
// Clear pending session
|
||||
unset($_SESSION['mobileapp_2fa_pending']);
|
||||
|
||||
// Get full user row for login completion
|
||||
$res = $db->select(MFUSERTABLE, "*", "id = {$userId}");
|
||||
$userRow = $db->fetch_object($res);
|
||||
|
||||
// Complete login
|
||||
$this->completeLogin($userRow, $pending['remember_me']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /MobileApp/auth/resend2fa
|
||||
*
|
||||
* Resend the 2FA code
|
||||
*/
|
||||
protected function authResend2FA() {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for pending 2FA session
|
||||
if (!isset($_SESSION['mobileapp_2fa_pending'])) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
|
||||
return;
|
||||
}
|
||||
|
||||
$pending = $_SESSION['mobileapp_2fa_pending'];
|
||||
|
||||
// Check if pending session is expired (10 minutes max)
|
||||
if (time() - $pending['timestamp'] > 600) {
|
||||
unset($_SESSION['mobileapp_2fa_pending']);
|
||||
self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resend 2FA code
|
||||
$twoFactor = new UserTwofactor($pending['user_id']);
|
||||
$twoFactor->sendCode();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => 'Neuer Code wurde gesendet'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the login process after password (and optionally 2FA) verification
|
||||
*/
|
||||
protected function completeLogin($userRow, $rememberMe) {
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$db->update(MFUSERTABLE, [
|
||||
'ip' => $_SERVER['REMOTE_ADDR'],
|
||||
'sessionid' => session_id()
|
||||
], "id = {$userRow->id}");
|
||||
|
||||
$_SESSION[MFAPPNAME . '_username'] = $userRow->username;
|
||||
$_SESSION[MFAPPNAME . '_ip'] = $_SERVER['REMOTE_ADDR'];
|
||||
|
||||
if ($rememberMe) {
|
||||
UserToken::generateToken($userRow->id);
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->loadMe();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask email address for privacy (e.g., j***@example.com)
|
||||
*/
|
||||
protected function maskEmail($email) {
|
||||
if (!$email) return '***';
|
||||
$parts = explode('@', $email);
|
||||
if (count($parts) !== 2) return '***';
|
||||
$local = $parts[0];
|
||||
$domain = $parts[1];
|
||||
$masked = strlen($local) > 1 ? $local[0] . str_repeat('*', min(5, strlen($local) - 1)) : '*';
|
||||
return $masked . '@' . $domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask phone number for privacy (e.g., +43***123)
|
||||
*/
|
||||
protected function maskPhone($phone) {
|
||||
if (!$phone) return '***';
|
||||
$phone = preg_replace('/\s+/', '', $phone);
|
||||
if (strlen($phone) < 6) return '***';
|
||||
return substr($phone, 0, 3) . str_repeat('*', strlen($phone) - 6) . substr($phone, -3);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /MobileApp/auth/logout
|
||||
*/
|
||||
protected function authLogout() {
|
||||
mfLoginController::staticLogout();
|
||||
self::returnJson(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /MobileApp/auth/check
|
||||
*/
|
||||
protected function authCheck() {
|
||||
if (mfLoginController::isLoggedIn()) {
|
||||
$user = new User();
|
||||
$user->loadMe();
|
||||
|
||||
if ($user->id) {
|
||||
self::returnJson([
|
||||
'authenticated' => true,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
UserToken::checkToken();
|
||||
|
||||
if (isset($_SESSION[MFAPPNAME . '_username']) && $_SESSION[MFAPPNAME . '_username']) {
|
||||
$user = new User();
|
||||
$user->loadMe();
|
||||
|
||||
if ($user->id) {
|
||||
self::returnJson([
|
||||
'authenticated' => true,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson(['authenticated' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API calls to module endpoints
|
||||
* /MobileApp/{module}/{submodule}/{endpoint}
|
||||
*/
|
||||
protected function handleApiCall($module, $submodule, $endpoint) {
|
||||
// Normalize names
|
||||
$moduleName = ucfirst(strtolower($module));
|
||||
$submoduleName = ucfirst(strtolower($submodule));
|
||||
|
||||
// Check authentication for API calls
|
||||
if (!$this->user || !$this->user->id) {
|
||||
self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build handler path
|
||||
$handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php";
|
||||
|
||||
if (!file_exists($handlerFile)) {
|
||||
self::returnJson(['success' => false, 'error' => "Module not found: {$moduleName}/{$submoduleName}"], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
require_once $handlerFile;
|
||||
|
||||
$handlerClass = "{$submoduleName}Handler";
|
||||
|
||||
if (!class_exists($handlerClass)) {
|
||||
self::returnJson(['success' => false, 'error' => "Handler class not found"], 500);
|
||||
return;
|
||||
}
|
||||
|
||||
$handler = new $handlerClass($this->request, $this->user, $this);
|
||||
|
||||
// Check permissions
|
||||
if (!$handler->checkPermission()) {
|
||||
self::returnJson(['success' => false, 'error' => 'Permission denied'], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to method
|
||||
$method = $endpoint . 'Action';
|
||||
if (method_exists($handler, $method)) {
|
||||
return $handler->$method();
|
||||
}
|
||||
|
||||
if (method_exists($handler, $endpoint)) {
|
||||
return $handler->$endpoint();
|
||||
}
|
||||
|
||||
self::returnJson(['success' => false, 'error' => "Endpoint not found: {$endpoint}"], 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
<?php
|
||||
|
||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||
|
||||
/**
|
||||
* Inventur (Stocktake) Handler
|
||||
*
|
||||
* Handles all endpoints for the Lager > Inventur module.
|
||||
* API Base: /MobileApp/Lager/Inventur/{action}
|
||||
*/
|
||||
class InventurHandler extends MobileAppBaseHandler {
|
||||
|
||||
protected $requiredPermission = 'WarehouseUser';
|
||||
|
||||
/**
|
||||
* Get active stocktakes
|
||||
* GET /MobileApp/Lager/Inventur/getActiveStocktakes
|
||||
*/
|
||||
public 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
|
||||
*/
|
||||
public 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
|
||||
*/
|
||||
public function getArticleAction() {
|
||||
$code = $this->request->code;
|
||||
|
||||
if (!$code) {
|
||||
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = null;
|
||||
|
||||
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
||||
$articleId = intval($matches[1]);
|
||||
} else {
|
||||
$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
|
||||
*/
|
||||
public function searchArticlesAction() {
|
||||
$query = $this->request->query ?? '';
|
||||
$categoryId = intval($this->request->categoryId ?? 0);
|
||||
|
||||
$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}%')";
|
||||
}
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$conditions[] = "category_id = {$categoryId}";
|
||||
}
|
||||
|
||||
if (count($conditions) === 1 && !$categoryId) {
|
||||
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.',
|
||||
'categoryId' => intval($row['category_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'articles' => $articles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories
|
||||
*/
|
||||
public function getCategoriesAction() {
|
||||
$db = $this->db();
|
||||
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
|
||||
|
||||
$categories = [];
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$categories[] = [
|
||||
'id' => intval($row['id']),
|
||||
'name' => $row['name'],
|
||||
];
|
||||
}
|
||||
self::returnJson(['success' => true, 'categories' => $categories]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if already scanned
|
||||
*/
|
||||
public function checkAlreadyScannedAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
$articleId = intval($this->request->articleId);
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$db = $this->db();
|
||||
$scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
|
||||
$scannedByRow = $scannedByResult->fetch_assoc();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'alreadyScanned' => true,
|
||||
'existingItem' => [
|
||||
'id' => $existing->id,
|
||||
'countedQuantity' => $existing->countedQuantity,
|
||||
'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
|
||||
'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
self::returnJson(['success' => true, 'alreadyScanned' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit scan
|
||||
*/
|
||||
public function submitScanAction() {
|
||||
$postData = $this->getPostData();
|
||||
|
||||
$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;
|
||||
$overwrite = boolval($postData['overwrite'] ?? false);
|
||||
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->db();
|
||||
|
||||
if ($overwrite && $overwriteItemId) {
|
||||
$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;
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
|
||||
$finalQuantity = $quantity;
|
||||
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'overwrittenItemId' => $overwriteItemId,
|
||||
]);
|
||||
|
||||
$stocktake->updateProgress();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isOverwrite' => true,
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$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 {
|
||||
$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;
|
||||
}
|
||||
|
||||
$stocktake->updateProgress();
|
||||
|
||||
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 my scans
|
||||
*/
|
||||
public function getMyScansAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
|
||||
if (!$stocktakeId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->db();
|
||||
$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
|
||||
*/
|
||||
public 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 = $this->db();
|
||||
|
||||
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
|
||||
$totalRow = $totalResult->fetch_assoc();
|
||||
$totalScanned = intval($totalRow['count']);
|
||||
|
||||
$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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Base Handler for Mobile App endpoints
|
||||
*
|
||||
* All app handlers should extend this class.
|
||||
* Provides common functionality for authentication, permissions, and responses.
|
||||
*/
|
||||
abstract class MobileAppBaseHandler {
|
||||
|
||||
/** @var object Request object */
|
||||
protected $request;
|
||||
|
||||
/** @var User|null Current user */
|
||||
protected $user;
|
||||
|
||||
/** @var MobileAppController Parent controller */
|
||||
protected $controller;
|
||||
|
||||
/** @var string Required permission for this app (override in subclass) */
|
||||
protected $requiredPermission = null;
|
||||
|
||||
/** @var string App name (used for view rendering) */
|
||||
protected $appName = '';
|
||||
|
||||
/** @var string View template path */
|
||||
protected $viewTemplate = '';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct($request, $user, $controller) {
|
||||
$this->request = $request;
|
||||
$this->user = $user;
|
||||
$this->controller = $controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has required permission
|
||||
* @return bool
|
||||
*/
|
||||
public function checkPermission() {
|
||||
// If no permission required, allow access
|
||||
if (!$this->requiredPermission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no user, deny access
|
||||
if (!$this->user || !$this->user->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check permission
|
||||
return $this->user->can($this->requiredPermission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the app view
|
||||
* Override in subclass if custom rendering needed
|
||||
*/
|
||||
public function renderView() {
|
||||
$layout = $this->controller->layout();
|
||||
|
||||
// Set template
|
||||
if ($this->viewTemplate) {
|
||||
$layout->setTemplate($this->viewTemplate);
|
||||
} else {
|
||||
$layout->setTemplate("MobileApp/{$this->appName}");
|
||||
}
|
||||
|
||||
// Set default JS globals
|
||||
$layout->set("JSGlobals", $this->getJSGlobals());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JS globals to pass to frontend
|
||||
* Override in subclass to add app-specific globals
|
||||
*/
|
||||
protected function getJSGlobals() {
|
||||
$globals = [
|
||||
'BASE_PATH' => '/MobileApp/' . $this->appName,
|
||||
'APP_NAME' => $this->appName,
|
||||
];
|
||||
|
||||
if ($this->user && $this->user->id) {
|
||||
$globals['USER_ID'] = $this->user->id;
|
||||
$globals['USER_NAME'] = $this->user->name;
|
||||
}
|
||||
|
||||
return $globals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return JSON response (shorthand)
|
||||
*/
|
||||
protected static function returnJson($data, $statusCode = 200) {
|
||||
mfBaseController::returnJson($data, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get POST data from JSON body
|
||||
*/
|
||||
protected function getPostData() {
|
||||
return json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database instance
|
||||
*/
|
||||
protected function db() {
|
||||
return FronkDB::singleton();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
class WarehouseMovementController extends TTCrud {
|
||||
protected string $headerTitle = 'Lagerbewegung';
|
||||
protected string $createText = 'Bewegung erstellen';
|
||||
protected bool $reopenOnCreate = true;
|
||||
|
||||
protected array $columns = [
|
||||
['key' => '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' => '<span class="badge bg-success">Einbuchung</span>',
|
||||
'OUT' => '<span class="badge bg-danger">Ausbuchung</span>',
|
||||
'ADJUSTMENT' => '<span class="badge bg-warning">Korrektur</span>',
|
||||
];
|
||||
$row['movementType'] = $typeLabels[$row['movementType']] ?? $row['movementType'];
|
||||
|
||||
// Format article
|
||||
if (!empty($row['articleId'])) {
|
||||
$article = ArticleModel::get($row['articleId']);
|
||||
if ($article) {
|
||||
$row['articleId'] = "<strong>{$article->articleNumber}</strong><br><small class='text-muted'>{$article->title}</small>";
|
||||
}
|
||||
}
|
||||
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
class WarehouseMovementModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?string $movementNumber = null;
|
||||
public string $movementType;
|
||||
public int $articleId;
|
||||
public int $warehouseLocationId;
|
||||
public ?int $warehouseItemId = null;
|
||||
public float $quantity;
|
||||
public ?float $quantityBefore = null;
|
||||
public ?float $quantityAfter = null;
|
||||
public string $reasonCategory;
|
||||
public ?string $note = null;
|
||||
public int $userId;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
/**
|
||||
* Generate next movement number (WM-YYYY-X000001)
|
||||
*/
|
||||
public static function generateMovementNumber(): string {
|
||||
$year = date('Y');
|
||||
$prefix = "WM-{$year}-X";
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT movementNumber FROM WarehouseMovement
|
||||
WHERE movementNumber LIKE '{$prefix}%'
|
||||
ORDER BY movementNumber DESC LIMIT 1");
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
$lastNumber = intval(substr($row['movementNumber'], -6));
|
||||
$nextNumber = $lastNumber + 1;
|
||||
} else {
|
||||
$nextNumber = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reason categories for a movement type
|
||||
*/
|
||||
public static function getReasonCategories(?string $type = null): array {
|
||||
$categories = [
|
||||
'IN' => [
|
||||
'Warenlieferung' => 'Warenlieferung',
|
||||
'Rueckgabe' => 'Rückgabe',
|
||||
'Gefunden' => 'Gefunden/Inventurdifferenz',
|
||||
'UmlagerungEingang' => 'Umlagerung (Eingang)',
|
||||
'Erstbestand' => 'Erstbestand',
|
||||
'Sonstiges' => 'Sonstiges'
|
||||
],
|
||||
'OUT' => [
|
||||
'Verbrauch' => 'Verbrauch',
|
||||
'Beschaedigung' => 'Beschädigung/Defekt',
|
||||
'Verlust' => 'Verlust/Schwund',
|
||||
'UmlagerungAusgang' => 'Umlagerung (Ausgang)',
|
||||
'Entsorgung' => 'Entsorgung',
|
||||
'Sonstiges' => 'Sonstiges'
|
||||
],
|
||||
'ADJUSTMENT' => [
|
||||
'Inventurkorrektur' => 'Inventurkorrektur',
|
||||
'Buchungsfehler' => 'Buchungsfehler',
|
||||
'Systemkorrektur' => 'Systemkorrektur',
|
||||
'SonstigeKorrektur' => 'Sonstige Korrektur'
|
||||
]
|
||||
];
|
||||
|
||||
if ($type && isset($categories[$type])) {
|
||||
return $categories[$type];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get movement type labels
|
||||
*/
|
||||
public static function getMovementTypes(): array {
|
||||
return [
|
||||
'IN' => 'Einbuchung',
|
||||
'OUT' => 'Ausbuchung',
|
||||
'ADJUSTMENT' => 'Korrektur'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article object
|
||||
*/
|
||||
public function getArticle(): ?ArticleModel {
|
||||
return ArticleModel::get($this->articleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location object
|
||||
*/
|
||||
public function getLocation(): ?WarehouseLocationModel {
|
||||
return WarehouseLocationModel::get($this->warehouseLocationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who made the movement
|
||||
*/
|
||||
public function getUser(): ?UserModel {
|
||||
return UserModel::get($this->userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get warehouse item if linked
|
||||
*/
|
||||
public function getWarehouseItem(): ?WarehouseItemModel {
|
||||
if (!$this->warehouseItemId) return null;
|
||||
return WarehouseItemModel::get($this->warehouseItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted movement type label
|
||||
*/
|
||||
public function getMovementTypeLabel(): string {
|
||||
$types = self::getMovementTypes();
|
||||
return $types[$this->movementType] ?? $this->movementType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted reason category label
|
||||
*/
|
||||
public function getReasonCategoryLabel(): string {
|
||||
$allCategories = self::getReasonCategories();
|
||||
foreach ($allCategories as $typeCategories) {
|
||||
if (isset($typeCategories[$this->reasonCategory])) {
|
||||
return $typeCategories[$this->reasonCategory];
|
||||
}
|
||||
}
|
||||
return $this->reasonCategory;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user