initial commit of mobile app

This commit is contained in:
Luca Haid
2026-01-13 12:44:45 +01:00
parent a513507b8f
commit 51b63acbde
32 changed files with 8025 additions and 0 deletions

View File

@@ -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,
]
]);
}
}

View File

@@ -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);
}
}

View File

@@ -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,
]
]);
}
}

View File

@@ -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]);
}
}

View File

@@ -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();
}
}