495 lines
18 KiB
PHP
495 lines
18 KiB
PHP
<?php
|
|
|
|
class WarehouseStocktakePWAController extends mfBaseController {
|
|
|
|
protected $user;
|
|
|
|
protected function init() {
|
|
$this->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,
|
|
]
|
|
]);
|
|
}
|
|
}
|