needlogin = true; $me = mfValuecache::singleton()->get("me"); if (!$me) { $me = new User(); $me->loadMe(); mfValuecache::singleton()->set("me", $me); } $this->me = $me; $this->user = $me; $this->layout()->set("me", $me); // Check permission if (!$me->can('WarehouseUser')) { $this->redirect("Dashboard"); } } /** * Main PWA View */ public function indexAction() { $this->layout()->setTemplate("VueViews/WarehouseStocktakePWA"); $this->layout()->set("JSGlobals", [ 'BASE_PATH' => '/WarehouseStocktakePWA', 'USER_ID' => $this->user->id, 'USER_NAME' => $this->user->name, ]); } /** * Logout */ protected function logoutAction() { mfLoginController::staticLogout(); $this->redirect('/WarehouseStocktakePWA'); } /** * Get active stocktakes that user can participate in */ protected 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 */ protected 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 */ protected 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 */ protected function searchArticlesAction() { $query = $this->request->query ?? ''; $categoryId = intval($this->request->categoryId ?? 0); $db = FronkDB::singleton(); $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 */ protected function getCategoriesAction() { $db = FronkDB::singleton(); $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 */ protected 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 = FronkDB::singleton(); $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 */ protected function submitScanAction() { $postData = json_decode(file_get_contents('php://input'), true) ?? []; $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 = FronkDB::singleton(); // 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; $isOverwrite = true; // Log the overwrite WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [ 'articleId' => $articleId, 'articleNumber' => $article->articleNumber, 'articleTitle' => $article->title, 'quantity' => $quantity, 'overwrittenItemId' => $overwriteItemId, ]); // Update stocktake progress (don't increase count since we're replacing) $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 */ protected function getMyScansAction() { $stocktakeId = intval($this->request->stocktakeId); if (!$stocktakeId) { self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); return; } $db = FronkDB::singleton(); $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 */ protected 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 = FronkDB::singleton(); // 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, ] ]); } }