diff --git a/.gitignore b/.gitignore index 81ebe75ee..d44056d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ composer.lock .project .settings .idea -.claude/ nbproject config/config.php scripts/addressdb/import @@ -52,3 +51,5 @@ Thumbs.db /Layout/default/DeviceDetail/ /Layout/default/DeviceDetail/ +mobile-presentation/ +nul diff --git a/Layout/default/MobileApp/App.php b/Layout/default/MobileApp/App.php index b60ddf599..a7f33fb95 100644 --- a/Layout/default/MobileApp/App.php +++ b/Layout/default/MobileApp/App.php @@ -1,77 +1,9 @@ - - - - - - Xinon Mobile - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - -
Lädt...
-
-
-
- - - - - - - - +$appConfig = [ + 'title' => 'Xinon Mobile', + 'appName' => 'Xinon', + 'manifestPath' => '/mobile/manifest.json', + 'appJsPath' => '/mobile/app.js', + 'swPath' => '/mobile/sw.js', +]; +require __DIR__ . '/Base.php'; diff --git a/Layout/default/MobileApp/Base.php b/Layout/default/MobileApp/Base.php new file mode 100644 index 000000000..91f8d1c7f --- /dev/null +++ b/Layout/default/MobileApp/Base.php @@ -0,0 +1,67 @@ + 'Xinon Mobile', + 'appName' => 'Xinon', + 'manifestPath' => '/mobile/manifest.json', + 'appJsPath' => '/mobile/app.js', + 'swPath' => '/mobile/sw.js', + 'additionalStylesheets' => [], +], $appConfig ?? []); +?> + + + + + + <?= htmlspecialchars($config['title']) ?> + + + + + + + + + + + + + + + + + +
+
+
+ + +
Lädt...
+
+
+
+ + + + diff --git a/Layout/default/MobileApp/WarehouseStocktake.php b/Layout/default/MobileApp/WarehouseStocktake.php index 6ba94de1a..738bab8ed 100644 --- a/Layout/default/MobileApp/WarehouseStocktake.php +++ b/Layout/default/MobileApp/WarehouseStocktake.php @@ -1,78 +1,10 @@ - - - - - - Lager Inventur - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - -
Lädt...
-
-
-
- - - - - - - - +$appConfig = [ + 'title' => 'Lager Inventur', + 'appName' => 'Inventur', + 'manifestPath' => '/mobile/warehouse-stocktake/manifest.json', + 'appJsPath' => '/mobile/warehouse-stocktake/app.js', + 'swPath' => '/mobile/warehouse-stocktake/sw.js', + 'additionalStylesheets' => ['/mobile/warehouse-stocktake/app.css'], +]; +require __DIR__ . '/Base.php'; diff --git a/Layout/default/WarehouseShippingNote/PDF_HEADER.html b/Layout/default/WarehouseShippingNote/PDF_HEADER.html index 6c6bd5781..40c2a07ff 100644 --- a/Layout/default/WarehouseShippingNote/PDF_HEADER.html +++ b/Layout/default/WarehouseShippingNote/PDF_HEADER.html @@ -30,6 +30,7 @@ .invoice-details td { text-align: left; + white-space: nowrap; } .invoice-details td:first-child { diff --git a/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php b/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php deleted file mode 100644 index a9773471c..000000000 --- a/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php +++ /dev/null @@ -1,473 +0,0 @@ - '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, - ] - ]); - } -} diff --git a/application/MobileApp/MobileAppController.php b/application/MobileApp/MobileAppController.php index 96f62b246..294972802 100644 --- a/application/MobileApp/MobileAppController.php +++ b/application/MobileApp/MobileAppController.php @@ -21,10 +21,7 @@ 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()) { diff --git a/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php index 75c1b36f3..ddd02ca85 100644 --- a/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php +++ b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php @@ -2,20 +2,10 @@ 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']); @@ -35,9 +25,6 @@ class InventurHandler extends MobileAppBaseHandler { self::returnJson(['success' => true, 'stocktakes' => $result]); } - /** - * Get stocktake details - */ public function getStocktakeAction() { $id = intval($this->request->id); if (!$id) { @@ -68,9 +55,6 @@ class InventurHandler extends MobileAppBaseHandler { ]); } - /** - * Get article by QR code or article number - */ public function getArticleAction() { $code = $this->request->code; @@ -116,9 +100,6 @@ class InventurHandler extends MobileAppBaseHandler { ]); } - /** - * Search articles - */ public function searchArticlesAction() { $query = $this->request->query ?? ''; $categoryId = intval($this->request->categoryId ?? 0); @@ -161,9 +142,6 @@ class InventurHandler extends MobileAppBaseHandler { 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"); @@ -178,9 +156,6 @@ class InventurHandler extends MobileAppBaseHandler { self::returnJson(['success' => true, 'categories' => $categories]); } - /** - * Check if already scanned - */ public function checkAlreadyScannedAction() { $stocktakeId = intval($this->request->stocktakeId); $articleId = intval($this->request->articleId); @@ -216,9 +191,6 @@ class InventurHandler extends MobileAppBaseHandler { } } - /** - * Submit scan - */ public function submitScanAction() { $postData = $this->getPostData(); @@ -366,9 +338,6 @@ class InventurHandler extends MobileAppBaseHandler { ]); } - /** - * Get my scans - */ public function getMyScansAction() { $stocktakeId = intval($this->request->stocktakeId); @@ -404,9 +373,6 @@ class InventurHandler extends MobileAppBaseHandler { self::returnJson(['success' => true, 'items' => $items]); } - /** - * Get progress - */ public function getProgressAction() { $stocktakeId = intval($this->request->stocktakeId); diff --git a/application/MobileApp/Modules/Lager/Movement/MovementHandler.php b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php index 19451ad01..d3e499019 100644 --- a/application/MobileApp/Modules/Lager/Movement/MovementHandler.php +++ b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php @@ -2,20 +2,10 @@ 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 = []; @@ -33,10 +23,6 @@ class MovementHandler extends MobileAppBaseHandler { 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; @@ -84,10 +70,6 @@ class MovementHandler extends MobileAppBaseHandler { ]); } - /** - * Search articles - * GET /MobileApp/Lager/Movement/searchArticles?query=X - */ public function searchArticlesAction() { $query = $this->request->query ?? ''; @@ -122,10 +104,6 @@ class MovementHandler extends MobileAppBaseHandler { 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; @@ -142,10 +120,6 @@ class MovementHandler extends MobileAppBaseHandler { } } - /** - * 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); @@ -165,10 +139,6 @@ class MovementHandler extends MobileAppBaseHandler { self::returnJson(['success' => true, 'currentStock' => $currentStock]); } - /** - * Submit a stock movement - * POST /MobileApp/Lager/Movement/submitMovement - */ public function submitMovementAction() { $postData = $this->getPostData(); @@ -284,10 +254,6 @@ class MovementHandler extends MobileAppBaseHandler { ]); } - /** - * 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); @@ -330,10 +296,6 @@ class MovementHandler extends MobileAppBaseHandler { 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'], @@ -343,4 +305,277 @@ class MovementHandler extends MobileAppBaseHandler { self::returnJson(['success' => true, 'types' => $types]); } + + public function getPendingOrdersAction() { + $db = $this->db(); + + $result = $db->query("SELECT wo.*, wd.name as distributorName + FROM WarehouseOrder wo + LEFT JOIN WarehouseDistributor wd ON wd.id = wo.distributorId + WHERE wo.status IN ('sent', 'partiallyDelivered') + ORDER BY wo.`create` DESC"); + + $orders = []; + while ($row = $result->fetch_assoc()) { + $positions = json_decode($row['positions'], true) ?: []; + $totalItems = array_sum(array_column($positions, 'amount')); + + // Calculate days since sent + $daysSinceSent = 0; + if (!empty($row['create'])) { + $daysSinceSent = floor((time() - intval($row['create'])) / 86400); + } + + $orders[] = [ + 'id' => intval($row['id']), + 'orderNumber' => $row['orderNumber'], + 'distributorName' => $row['distributorName'] ?? 'Unbekannt', + 'status' => $row['status'], + 'statusLabel' => $row['status'] === 'sent' ? 'Versendet' : 'Teilweise geliefert', + 'totalItems' => $totalItems, + 'positionCount' => count($positions), + 'daysSinceSent' => $daysSinceSent, + 'create' => date('d.m.Y', $row['create']), + ]; + } + + self::returnJson(['success' => true, 'orders' => $orders]); + } + + public function getOrderForReceivingAction() { + $orderId = intval($this->request->orderId ?? 0); + + if ($orderId <= 0) { + self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']); + return; + } + + $order = WarehouseOrderModel::get($orderId); + if (!$order) { + self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']); + return; + } + + if (!in_array($order->status, ['sent', 'partiallyDelivered'])) { + self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']); + return; + } + + $distributor = WarehouseDistributorModel::get($order->distributorId); + $positions = json_decode($order->positions, true) ?: []; + + // Get already delivered quantities from linked movements + $linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : []; + $deliveredByArticle = []; + + foreach ($linkedMovementIds as $movementId) { + $movement = WarehouseMovementModel::get($movementId); + if ($movement && $movement->movementType === 'IN') { + if (!isset($deliveredByArticle[$movement->articleId])) { + $deliveredByArticle[$movement->articleId] = 0; + } + $deliveredByArticle[$movement->articleId] += $movement->quantity; + } + } + + // Enrich positions with article details and delivered quantities + $enrichedPositions = []; + foreach ($positions as $index => $pos) { + $articleId = intval($pos['article']); + $article = WarehouseArticleModel::get($articleId); + + $orderedQty = floatval($pos['amount']); + $deliveredQty = $deliveredByArticle[$articleId] ?? 0; + $remainingQty = max(0, $orderedQty - $deliveredQty); + + $enrichedPositions[] = [ + 'index' => $index, + 'articleId' => $articleId, + 'articleNumber' => $article ? $article->articleNumber : '', + 'articleTitle' => $article ? $article->title : ($pos['article_text'] ?? 'Unbekannt'), + 'unit' => $article ? ($article->unit ?? 'Stk.') : 'Stk.', + 'orderedQty' => $orderedQty, + 'deliveredQty' => $deliveredQty, + 'remainingQty' => $remainingQty, + 'receivingQty' => $remainingQty, // Default to remaining + ]; + } + + self::returnJson([ + 'success' => true, + 'order' => [ + 'id' => $order->id, + 'orderNumber' => $order->orderNumber, + 'distributorName' => $distributor ? $distributor->name : 'Unbekannt', + 'status' => $order->status, + 'note' => $order->note, + 'create' => date('d.m.Y H:i', $order->create), + ], + 'positions' => $enrichedPositions + ]); + } + + public function submitOrderReceivingAction() { + $postData = $this->getPostData(); + + $orderId = intval($postData['orderId'] ?? 0); + $locationId = intval($postData['locationId'] ?? 0); + $positions = $postData['positions'] ?? []; + $deliveryNoteFileId = $postData['deliveryNoteFileId'] ?? null; + $note = $postData['note'] ?? null; + + // Validation + if ($orderId <= 0) { + self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']); + return; + } + + if ($locationId <= 0) { + self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']); + return; + } + + if (empty($positions)) { + self::returnJson(['success' => false, 'message' => 'Keine Positionen angegeben']); + return; + } + + $order = WarehouseOrderModel::get($orderId); + if (!$order) { + self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']); + return; + } + + if (!in_array($order->status, ['sent', 'partiallyDelivered'])) { + self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']); + return; + } + + $db = $this->db(); + $createdMovementIds = []; + $totalReceived = 0; + + // Create movements for each position with quantity > 0 + foreach ($positions as $pos) { + $articleId = intval($pos['articleId'] ?? 0); + $quantity = floatval($pos['quantity'] ?? 0); + + if ($articleId <= 0 || $quantity <= 0) { + continue; + } + + // Find or create WarehouseItem + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; + $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0; + $newQty = $currentQty + $quantity; + + // Update or create WarehouseItem + 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 movement record + $movementNote = "Lagereingang aus Bestellung {$order->orderNumber}"; + if ($note) { + $movementNote .= " - " . $note; + } + $noteEscaped = "'" . $db->escape($movementNote) . "'"; + + $db->query("INSERT INTO WarehouseMovement + (movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, linkedOrderId, note, userId, createBy, `create`) + VALUES ('IN', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, 'Warenlieferung', {$orderId}, {$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}"); + + $createdMovementIds[] = $movementId; + $totalReceived += $quantity; + } + + if (empty($createdMovementIds)) { + self::returnJson(['success' => false, 'message' => 'Keine Mengen eingegeben']); + return; + } + + // Update order with linked movement IDs + $existingMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : []; + $allMovementIds = array_merge($existingMovementIds, $createdMovementIds); + + // Update delivery note file IDs if provided + $existingFileIds = $order->deliveryNoteFileIds ? json_decode($order->deliveryNoteFileIds, true) : []; + if ($deliveryNoteFileId) { + $existingFileIds[] = $deliveryNoteFileId; + } + + // Determine new status - check if all items are now fully delivered + $orderPositions = json_decode($order->positions, true) ?: []; + $allFullyDelivered = true; + + // Get all delivered quantities including new ones + $deliveredByArticle = []; + foreach ($allMovementIds as $movementId) { + $movement = WarehouseMovementModel::get($movementId); + if ($movement && $movement->movementType === 'IN') { + if (!isset($deliveredByArticle[$movement->articleId])) { + $deliveredByArticle[$movement->articleId] = 0; + } + $deliveredByArticle[$movement->articleId] += $movement->quantity; + } + } + + foreach ($orderPositions as $pos) { + $articleId = intval($pos['article']); + $orderedQty = floatval($pos['amount']); + $deliveredQty = $deliveredByArticle[$articleId] ?? 0; + + if ($deliveredQty < $orderedQty) { + $allFullyDelivered = false; + break; + } + } + + $newStatus = $allFullyDelivered ? 'fullyDelivered' : 'partiallyDelivered'; + + // Update order + $orderAsArray = (array)$order; + $orderAsArray['linkedMovementIds'] = json_encode($allMovementIds); + $orderAsArray['deliveryNoteFileIds'] = json_encode($existingFileIds); + $orderAsArray['status'] = $newStatus; + WarehouseOrderModel::update($orderAsArray); + + // Create log entry + $logMessage = count($createdMovementIds) . " Lagerbewegung(en) erstellt via Mobile App."; + if ($note) { + $logMessage .= "\n" . $note; + } + + WarehouseLogModel::create([ + 'table' => 'WarehouseOrder', + 'rowId' => $orderId, + 'type' => 'statusChange', + 'message' => "Status geändert auf " . ($newStatus === 'fullyDelivered' ? 'Geliefert' : 'Teilweise geliefert') . ".\n" . $logMessage, + 'createBy' => $this->user->id, + 'create' => time() + ]); + + self::returnJson([ + 'success' => true, + 'message' => "{$totalReceived} Artikel empfangen. " . count($createdMovementIds) . " Lagerbewegung(en) erstellt.", + 'newStatus' => $newStatus, + 'createdMovementIds' => $createdMovementIds + ]); + } } diff --git a/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php b/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php new file mode 100644 index 000000000..2e7d004e6 --- /dev/null +++ b/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php @@ -0,0 +1,761 @@ + ShippingNote module. + * API Base: /MobileApp/Lager/ShippingNote/{action} + */ +class ShippingNoteHandler extends MobileAppBaseHandler { + + protected $requiredPermission = 'WarehouseUser'; + + // Office coordinates for distance calculation + const OFFICE_LAT = 46.99552810791587; + const OFFICE_LNG = 15.7751923956463; + + /** + * Get customer by GPS location (nearest within radius) + * GET /MobileApp/Lager/ShippingNote/getCustomerByLocation?lat=X&lng=Y + */ + public function getCustomerByLocationAction() { + $lat = floatval($this->request->lat ?? 0); + $lng = floatval($this->request->lng ?? 0); + $radius = intval($this->request->radius ?? 200); // default 200 meters + + if (!$lat || !$lng) { + self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']); + return; + } + + $db = $this->db(); + + // Haversine formula for distance in meters + $sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long, + (6371000 * acos( + cos(radians({$lat})) * cos(radians(gps_lat)) * + cos(radians(gps_long) - radians({$lng})) + + sin(radians({$lat})) * sin(radians(gps_lat)) + )) AS distance + FROM Address + WHERE gps_lat IS NOT NULL + AND gps_long IS NOT NULL + AND customer_number > 0 + HAVING distance < {$radius} + ORDER BY distance ASC + LIMIT 1"; + + $result = $db->query($sql); + + if ($result && $row = $result->fetch_assoc()) { + // Build display name + $displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']); + + self::returnJson([ + 'success' => true, + 'found' => true, + 'customer' => [ + 'id' => intval($row['id']), + 'customerNumber' => $row['customer_number'], + 'displayName' => $displayName, + 'company' => $row['company'], + 'firstname' => $row['firstname'], + 'lastname' => $row['lastname'], + 'street' => $row['street'], + 'zip' => $row['zip'], + 'city' => $row['city'], + 'email' => $row['email'], + 'phone' => $row['phone'], + 'distance' => round(floatval($row['distance'])), + ] + ]); + } else { + self::returnJson([ + 'success' => true, + 'found' => false, + 'message' => 'Kein Kunde in der Nähe gefunden' + ]); + } + } + + /** + * Reverse geocode coordinates to address + * GET /MobileApp/Lager/ShippingNote/reverseGeocode?lat=X&lng=Y + */ + public function reverseGeocodeAction() { + $lat = floatval($this->request->lat ?? 0); + $lng = floatval($this->request->lng ?? 0); + + if (!$lat || !$lng) { + self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']); + return; + } + + // Use Google Maps Geocoding API + $apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : ''; + if (!$apiKey) { + self::returnJson(['success' => false, 'message' => 'Google Maps API nicht konfiguriert']); + return; + } + + $url = "https://maps.googleapis.com/maps/api/geocode/json?latlng={$lat},{$lng}&key={$apiKey}&language=de"; + + $response = @file_get_contents($url); + if (!$response) { + self::returnJson(['success' => false, 'message' => 'Geocoding fehlgeschlagen']); + return; + } + + $data = json_decode($response, true); + if ($data['status'] !== 'OK' || empty($data['results'])) { + self::returnJson(['success' => false, 'message' => 'Keine Adresse gefunden']); + return; + } + + // Parse address components + $result = $data['results'][0]; + $components = $result['address_components']; + + $street = ''; + $streetNumber = ''; + $zip = ''; + $city = ''; + + foreach ($components as $comp) { + if (in_array('route', $comp['types'])) { + $street = $comp['long_name']; + } + if (in_array('street_number', $comp['types'])) { + $streetNumber = $comp['long_name']; + } + if (in_array('postal_code', $comp['types'])) { + $zip = $comp['long_name']; + } + if (in_array('locality', $comp['types'])) { + $city = $comp['long_name']; + } + } + + $fullStreet = trim($street . ' ' . $streetNumber); + + self::returnJson([ + 'success' => true, + 'address' => [ + 'street' => $fullStreet, + 'zip' => $zip, + 'city' => $city, + 'formatted' => $result['formatted_address'] ?? '', + ] + ]); + } + + /** + * Search customers by name/company + * GET /MobileApp/Lager/ShippingNote/searchCustomers?query=X + */ + public function searchCustomersAction() { + $query = trim($this->request->query ?? ''); + + if (strlen($query) < 1) { + self::returnJson(['success' => true, 'customers' => []]); + return; + } + + $db = $this->db(); + + // Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst") + $words = preg_split('/\s+/', trim($query)); + $wordConditions = []; + + foreach ($words as $word) { + if (strlen($word) < 1) continue; + $escapedWord = $db->escape($word); + $wordConditions[] = "(company LIKE '%{$escapedWord}%' + OR firstname LIKE '%{$escapedWord}%' + OR lastname LIKE '%{$escapedWord}%' + OR customer_number LIKE '%{$escapedWord}%')"; + } + + if (empty($wordConditions)) { + self::returnJson(['success' => true, 'customers' => []]); + return; + } + + $whereClause = implode(' AND ', $wordConditions); + + $sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long + FROM Address + WHERE customer_number > 0 + AND ({$whereClause}) + ORDER BY company, lastname, firstname + LIMIT 20"; + + $result = $db->query($sql); + $customers = []; + + while ($row = $result->fetch_assoc()) { + $displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']); + $customers[] = [ + 'id' => intval($row['id']), + 'customerNumber' => $row['customer_number'], + 'displayName' => $displayName, + 'company' => $row['company'], + 'firstname' => $row['firstname'], + 'lastname' => $row['lastname'], + 'street' => $row['street'], + 'zip' => $row['zip'], + 'city' => $row['city'], + 'email' => $row['email'], + 'phone' => $row['phone'], + 'gpsLat' => $row['gps_lat'] ? floatval($row['gps_lat']) : null, + 'gpsLong' => $row['gps_long'] ? floatval($row['gps_long']) : null, + ]; + } + + self::returnJson(['success' => true, 'customers' => $customers]); + } + + /** + * Search articles + * GET /MobileApp/Lager/ShippingNote/searchArticles?query=X + */ + public function searchArticlesAction() { + $query = trim($this->request->query ?? ''); + + if (strlen($query) < 1) { + self::returnJson(['success' => true, 'articles' => []]); + return; + } + + $db = $this->db(); + $escapedQuery = $db->escape($query); + + $sql = "SELECT id, articleNumber, title, unit + FROM WarehouseArticle + WHERE (isEndOfLife IS NULL OR isEndOfLife = 0) + AND (articleNumber LIKE '%{$escapedQuery}%' + OR title LIKE '%{$escapedQuery}%') + ORDER BY title ASC + LIMIT 30"; + + $result = $db->query($sql); + $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 article by QR code or article number + * GET /MobileApp/Lager/ShippingNote/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; + } + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'unit' => $article->unit ?? 'Stk.', + ] + ]); + } + + /** + * Get user's assigned car + * GET /MobileApp/Lager/ShippingNote/getUserCar?userId=X + */ + public function getUserCarAction() { + $userId = intval($this->request->userId ?? $this->user->id); + + $db = $this->db(); + + // Get user's assigned car from TimerecordingCar (user_id is on TimerecordingCar) + $sql = "SELECT id, number_plate, brand, model + FROM TimerecordingCar + WHERE user_id = {$userId} + AND (retired IS NULL OR retired = 0) + LIMIT 1"; + + $result = $db->query($sql); + + if ($result && $row = $result->fetch_assoc()) { + $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')); + if (!$carName) $carName = $row['number_plate']; + + self::returnJson([ + 'success' => true, + 'car' => [ + 'id' => intval($row['id']), + 'name' => $carName, + 'plate' => $row['number_plate'], + ] + ]); + } else { + self::returnJson([ + 'success' => true, + 'car' => null + ]); + } + } + + /** + * Get all available cars for selection + * GET /MobileApp/Lager/ShippingNote/getAllCars + */ + public function getAllCarsAction() { + $db = $this->db(); + + $sql = "SELECT id, number_plate, brand, model + FROM TimerecordingCar + WHERE (retired IS NULL OR retired = 0) + ORDER BY brand, model ASC"; + + $result = $db->query($sql); + $cars = []; + + while ($row = $result->fetch_assoc()) { + $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')); + if (!$carName) $carName = $row['number_plate']; + + $cars[] = [ + 'id' => intval($row['id']), + 'name' => $carName, + 'plate' => $row['number_plate'], + ]; + } + + self::returnJson(['success' => true, 'cars' => $cars]); + } + + /** + * Get hour types for selection + * GET /MobileApp/Lager/ShippingNote/getHourTypes + */ + public function getHourTypesAction() { + // Hour types matching desktop modal + $hourTypes = [ + ['id' => '', 'name' => 'Normal'], + ['id' => '50', 'name' => '+50%'], + ['id' => '100', 'name' => '+100%'], + ['id' => 'regie', 'name' => 'Regie'], + ]; + + self::returnJson(['success' => true, 'hourTypes' => $hourTypes]); + } + + /** + * Calculate round-trip distance from office to coordinates + * GET /MobileApp/Lager/ShippingNote/calculateDistance?lat=X&lng=Y + */ + public function calculateDistanceAction() { + $lat = floatval($this->request->lat ?? 0); + $lng = floatval($this->request->lng ?? 0); + + if (!$lat || !$lng) { + self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']); + return; + } + + // Use estimation on localhost for development + $isLocalhost = in_array($_SERVER['HTTP_HOST'] ?? '', ['localhost', '127.0.0.1']); + + // Use Google Distance Matrix API for accurate driving distance + $apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : ''; + if (!$apiKey || $isLocalhost) { + // Fallback to straight-line distance * 1.3 (rough road factor) + $distance = $this->haversineDistance(self::OFFICE_LAT, self::OFFICE_LNG, $lat, $lng); + $kmOneWay = round($distance / 1000 * 1.3, 1); + $kmRoundTrip = $kmOneWay * 2; + + self::returnJson([ + 'success' => true, + 'distanceOneWay' => $kmOneWay, + 'distanceRoundTrip' => $kmRoundTrip, + 'estimated' => true + ]); + return; + } + + $origin = self::OFFICE_LAT . ',' . self::OFFICE_LNG; + $destination = "{$lat},{$lng}"; + $url = "https://maps.googleapis.com/maps/api/distancematrix/json?origins={$origin}&destinations={$destination}&mode=driving&key={$apiKey}"; + + $response = @file_get_contents($url); + if (!$response) { + self::returnJson(['success' => false, 'message' => 'Distanzberechnung fehlgeschlagen']); + return; + } + + $data = json_decode($response, true); + if ($data['status'] !== 'OK' || empty($data['rows'][0]['elements'][0]['distance'])) { + self::returnJson(['success' => false, 'message' => 'Keine Route gefunden']); + return; + } + + $distanceMeters = $data['rows'][0]['elements'][0]['distance']['value']; + $kmOneWay = round($distanceMeters / 1000, 1); + $kmRoundTrip = $kmOneWay * 2; + + self::returnJson([ + 'success' => true, + 'distanceOneWay' => $kmOneWay, + 'distanceRoundTrip' => $kmRoundTrip, + 'estimated' => false + ]); + } + + /** + * Create new shipping note + * POST /MobileApp/Lager/ShippingNote/create + */ + public function createAction() { + $postData = $this->getPostData(); + + // Validate required fields + $requiredFields = ['deliveryAddressName', 'deliveryAddressLine', 'deliveryAddressPLZ', 'deliveryAddressCity', 'note']; + foreach ($requiredFields as $field) { + if (empty($postData[$field])) { + self::returnJson(['success' => false, 'message' => "Feld '{$field}' ist erforderlich"]); + return; + } + } + + // Must have at least positions OR hoursEntries + $positions = $postData['positions'] ?? []; + $hoursEntries = $postData['hoursEntries'] ?? []; + + if (empty($positions) && empty($hoursEntries)) { + self::returnJson(['success' => false, 'message' => 'Mindestens eine Position oder Stundenbuchung erforderlich']); + return; + } + + $db = $this->db(); + + // Prepare data + $data = [ + 'status' => 'new', + 'type' => null, + 'billingAddressId' => null, + 'deliveryAddressName' => $db->escape($postData['deliveryAddressName']), + 'deliveryAddressLine' => $db->escape($postData['deliveryAddressLine']), + 'deliveryAddressPLZ' => $db->escape($postData['deliveryAddressPLZ']), + 'deliveryAddressCity' => $db->escape($postData['deliveryAddressCity']), + 'deliveryAddressEMail' => $db->escape($postData['deliveryAddressEMail'] ?? ''), + 'note' => $db->escape($postData['note']), + 'positions' => json_encode($positions), + 'hoursEntries' => json_encode($hoursEntries), + 'textElements' => json_encode($postData['textElements'] ?? []), + 'metadata' => json_encode($postData['metadata'] ?? []), + 'create' => time(), + 'createBy' => $this->user->id, + ]; + + // Generate shipping note number + $shippingNoteNumber = WarehouseShippingNoteModel::generateShippingNoteNumber(); + $data['shippingNoteNumber'] = $shippingNoteNumber; + + // Build INSERT query + $columns = implode(', ', array_map(function($k) { return "`{$k}`"; }, array_keys($data))); + $values = implode(', ', array_map(function($v) { + return $v === null ? 'NULL' : "'{$v}'"; + }, array_values($data))); + + $db->query("INSERT INTO WarehouseShippingNote ({$columns}) VALUES ({$values})"); + $id = $db->insert_id; + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Fehler beim Speichern']); + return; + } + + self::returnJson([ + 'success' => true, + 'message' => 'Lieferschein erstellt', + 'shippingNote' => [ + 'id' => $id, + 'shippingNoteNumber' => $shippingNoteNumber, + ] + ]); + } + + /** + * Sign a shipping note + * POST /MobileApp/Lager/ShippingNote/sign?id=X + */ + public function signAction() { + $id = intval($this->request->id ?? 0); + $postData = $this->getPostData(); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Lieferschein-ID fehlt']); + return; + } + + $shippingNote = WarehouseShippingNoteModel::get($id); + if (!$shippingNote) { + self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']); + return; + } + + // Check if already signed + if (!empty($shippingNote->signature) || !empty($shippingNote->signatureName)) { + self::returnJson(['success' => false, 'message' => 'Bereits unterschrieben']); + return; + } + + // Validate signature data + $signature = $postData['signature'] ?? ''; + $signatureName = $postData['signatureName'] ?? ''; + + if (empty($signature) || empty($signatureName)) { + self::returnJson(['success' => false, 'message' => 'Unterschrift und Name erforderlich']); + return; + } + + $db = $this->db(); + $signatureEscaped = $db->escape($signature); + $signatureNameEscaped = $db->escape($signatureName); + $signatureDate = date('Y-m-d'); + + $db->query("UPDATE WarehouseShippingNote + SET signature = '{$signatureEscaped}', + signatureName = '{$signatureNameEscaped}', + signatureDate = '{$signatureDate}' + WHERE id = {$id}"); + + self::returnJson([ + 'success' => true, + 'message' => 'Unterschrift gespeichert' + ]); + } + + /** + * Get my unsigned shipping notes + * GET /MobileApp/Lager/ShippingNote/getMyShippingNotes + */ + public function getMyShippingNotesAction() { + $onlyUnsigned = ($this->request->unsigned ?? '1') === '1'; + $limit = intval($this->request->limit ?? 20); + + $db = $this->db(); + + $whereClause = "createBy = {$this->user->id}"; + if ($onlyUnsigned) { + $whereClause .= " AND (signature IS NULL OR signature = '')"; + } + + $sql = "SELECT id, shippingNoteNumber, status, type, deliveryAddressName, deliveryAddressLine, + deliveryAddressPLZ, deliveryAddressCity, note, + signature, signatureName, signatureDate, `create` + FROM WarehouseShippingNote + WHERE {$whereClause} + ORDER BY `create` DESC + LIMIT {$limit}"; + + $result = $db->query($sql); + $shippingNotes = []; + + while ($row = $result->fetch_assoc()) { + $shippingNotes[] = [ + 'id' => intval($row['id']), + 'shippingNoteNumber' => $row['shippingNoteNumber'], + 'status' => $row['status'], + 'type' => $row['type'], + 'deliveryAddressName' => $row['deliveryAddressName'], + 'deliveryAddressLine' => $row['deliveryAddressLine'], + 'deliveryAddressPLZ' => $row['deliveryAddressPLZ'], + 'deliveryAddressCity' => $row['deliveryAddressCity'], + 'note' => $row['note'], + 'isSigned' => !empty($row['signature']), + 'signatureName' => $row['signatureName'], + 'signatureDate' => $row['signatureDate'], + 'create' => date('d.m.Y H:i', $row['create']), + ]; + } + + self::returnJson(['success' => true, 'shippingNotes' => $shippingNotes]); + } + + /** + * Get a single shipping note by ID + * GET /MobileApp/Lager/ShippingNote/getShippingNote?id=X + */ + public function getShippingNoteAction() { + $id = intval($this->request->id ?? 0); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'ID fehlt']); + return; + } + + $shippingNote = WarehouseShippingNoteModel::get($id); + if (!$shippingNote) { + self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'shippingNote' => [ + 'id' => intval($shippingNote->id), + 'shippingNoteNumber' => $shippingNote->shippingNoteNumber, + 'status' => $shippingNote->status, + 'type' => $shippingNote->type, + 'billingAddressId' => $shippingNote->billingAddressId, + 'deliveryAddressName' => $shippingNote->deliveryAddressName, + 'deliveryAddressLine' => $shippingNote->deliveryAddressLine, + 'deliveryAddressPLZ' => $shippingNote->deliveryAddressPLZ, + 'deliveryAddressCity' => $shippingNote->deliveryAddressCity, + 'deliveryAddressEMail' => $shippingNote->deliveryAddressEMail, + 'note' => $shippingNote->note, + 'positions' => json_decode($shippingNote->positions, true) ?? [], + 'hoursEntries' => json_decode($shippingNote->hoursEntries, true) ?? [], + 'isSigned' => !empty($shippingNote->signature), + 'signatureName' => $shippingNote->signatureName, + 'signatureDate' => $shippingNote->signatureDate, + 'create' => date('d.m.Y H:i', $shippingNote->create), + ] + ]); + } + + /** + * Get shipping note types + * GET /MobileApp/Lager/ShippingNote/getTypes + */ + public function getTypesAction() { + $types = [ + ['value' => 'V', 'text' => 'Verrechnen'], + ['value' => 'XI', 'text' => 'Xinon Intern'], + ['value' => 'XH', 'text' => 'Xinon Hersteller'], + ['value' => 'SNOPP', 'text' => 'SNOPP'], + ['value' => 'ESTMK', 'text' => 'Energie Steiermark'], + ['value' => 'SBIDI', 'text' => 'SBIDI'], + ]; + + self::returnJson(['success' => true, 'types' => $types]); + } + + /** + * Get current user info (for pre-filling forms) + * GET /MobileApp/Lager/ShippingNote/getCurrentUser + */ + public function getCurrentUserAction() { + self::returnJson([ + 'success' => true, + 'user' => [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'firstname' => $this->user->firstname ?? '', + 'lastname' => $this->user->lastname ?? '', + ] + ]); + } + + /** + * Search employees for multi-employee selection + * GET /MobileApp/Lager/ShippingNote/searchEmployees?query=X + */ + public function searchEmployeesAction() { + $query = trim($this->request->query ?? ''); + + $db = $this->db(); + + // Base query: active workers who have TimerecordingEmployee entry (= employees) + $sql = "SELECT w.id, w.name, w.email + FROM Worker w + INNER JOIN TimerecordingEmployee te ON te.user_id = w.id + WHERE w.active = 1"; + + // Add search filter if query provided + if (strlen($query) >= 1) { + // Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst") + $words = preg_split('/\s+/', trim($query)); + $wordConditions = []; + + foreach ($words as $word) { + if (strlen($word) < 1) continue; + $escapedWord = $db->escape($word); + $wordConditions[] = "(w.name LIKE '%{$escapedWord}%' OR w.email LIKE '%{$escapedWord}%')"; + } + + if (!empty($wordConditions)) { + $sql .= " AND " . implode(' AND ', $wordConditions); + } + } + + $sql .= " ORDER BY w.name ASC LIMIT 20"; + + $result = $db->query($sql); + $employees = []; + + while ($row = $result->fetch_assoc()) { + $employees[] = [ + 'id' => intval($row['id']), + 'name' => $row['name'], + 'email' => $row['email'], + ]; + } + + self::returnJson(['success' => true, 'employees' => $employees]); + } + + /** + * Helper: Calculate Haversine distance in meters + */ + private function haversineDistance($lat1, $lng1, $lat2, $lng2) { + $earthRadius = 6371000; // meters + + $dLat = deg2rad($lat2 - $lat1); + $dLng = deg2rad($lng2 - $lng1); + + $a = sin($dLat / 2) * sin($dLat / 2) + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * + sin($dLng / 2) * sin($dLng / 2); + + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + + return $earthRadius * $c; + } +} diff --git a/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php b/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php new file mode 100644 index 000000000..06826b287 --- /dev/null +++ b/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php @@ -0,0 +1,929 @@ + ['text' => 'Neu', 'color' => 'primary'], + 'assigned' => ['text' => 'Zugewiesen', 'color' => 'info'], + 'scheduled' => ['text' => 'Geplant', 'color' => 'warning'], + 'in_progress' => ['text' => 'In Bearbeitung', 'color' => 'warning'], + 'correction_requested' => ['text' => 'Korrektur angefordert', 'color' => 'danger'], + 'intervention_required' => ['text' => 'Eingriff erforderlich', 'color' => 'danger'], + 'civil_engineering_required' => ['text' => 'Tiefbau benötigt', 'color' => 'orange'], + 'civil_engineering_completed' => ['text' => 'Tiefbau abgeschlossen', 'color' => 'success'], + 'problem_solved' => ['text' => 'Problem gelöst', 'color' => 'success'], + 'documented' => ['text' => 'Dokumentiert', 'color' => 'success'], + 'completed' => ['text' => 'Abgeschlossen', 'color' => 'secondary'], + 'charged' => ['text' => 'Verrechnet', 'color' => 'purple'], + 'cancelled' => ['text' => 'Abgebrochen', 'color' => 'danger'], + 'archived' => ['text' => 'Archiviert', 'color' => 'muted'], + ]; + + /** + * Get workorders list for the company + * POST /MobileApp/Workorder/Workorder/get + */ + public function getAction() { + $postData = $this->getPostData(); + + $pagination = $postData['pagination'] ?? ['page' => 1, 'per_page' => 20]; + $filters = $postData['filters'] ?? []; + $order = $postData['order'] ?? []; + $search = trim($postData['search'] ?? ''); + + // Get company for current user + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson([ + 'success' => true, + 'workorders' => [], + 'pagination' => ['page' => 1, 'per_page' => $pagination['per_page'], 'total' => 0] + ]); + return; + } + + // Build workorders query + $workorders = WorkorderModel::getCompanyWorkorders( + $filters, + $pagination['per_page'], + ($pagination['page'] - 1) * $pagination['per_page'], + $order, + $company->id, + $search + ); + + $totalCount = WorkorderModel::countCompanyWorkorders($filters, $company->id, $search); + + // Transform for mobile app + $result = []; + foreach ($workorders as $wo) { + $result[] = $this->transformWorkorder($wo); + } + + self::returnJson([ + 'success' => true, + 'workorders' => $result, + 'pagination' => [ + 'page' => intval($pagination['page']), + 'per_page' => intval($pagination['per_page']), + 'total' => $totalCount, + 'totalPages' => ceil($totalCount / $pagination['per_page']) + ] + ]); + } + + /** + * Get single workorder details + * GET /MobileApp/Workorder/Workorder/getWorkorder?id=X + */ + public function getWorkorderAction() { + $id = intval($this->request->id ?? 0); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'ID fehlt']); + return; + } + + // Verify user has access + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson(['success' => false, 'message' => 'Keine Berechtigung']); + return; + } + + // Get workorder with full joined data + $workorder = $this->getWorkorderWithDetails($id, $company->id); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'workorder' => $this->transformWorkorder($workorder, true) + ]); + } + + /** + * Get complete workorder detail (combined endpoint) + * Returns workorder, documentation, tenant config, and checklist in one request + * GET /MobileApp/Workorder/Workorder/getWorkorderDetail?id=X + */ + public function getWorkorderDetailAction() { + $id = intval($this->request->id ?? 0); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'ID fehlt']); + return; + } + + // Verify user has access + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson(['success' => false, 'message' => 'Keine Berechtigung']); + return; + } + + // Get workorder with full joined data + $workorderData = $this->getWorkorderWithDetails($id, $company->id); + if (!$workorderData) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + $workorder = $this->transformWorkorder($workorderData, true); + + // Get tenant config + $tenantConfig = $this->getTenantConfigFromWorkorder($id); + $tenantConfigData = null; + $translationMap = []; + + if ($tenantConfig) { + $customTypes = json_decode($tenantConfig->documentationTypes, true) ?? []; + $translationMap = array_merge( + ['civil_engineering_photo' => 'Tiefbau_Foto'], + array_column($customTypes, 'text', 'value') + ); + + $tenantConfigData = [ + 'documentationTypes' => $customTypes, + 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired, + 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [], + 'requireCableLength' => (bool)$tenantConfig->requireCableLength, + 'requireCableType' => (bool)$tenantConfig->requireCableType, + ]; + } + + // Get documentation + $docs = WorkorderDocumentationModel::getAll( + ['workorderId' => $id], + null, 0, + ['key' => 'create', 'order' => 'ASC'] + ); + + $typeCounts = []; + $responseDocs = []; + + foreach ($docs as $doc) { + $file = new File($doc->fileId); + $documentTypeKey = $doc->documentType; + $typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1; + $originalFilename = $file->orig_filename ?? $file->filename; + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey; + $newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension); + + $responseDocs[] = [ + 'id' => $doc->id, + 'fileId' => $doc->fileId, + 'fileName' => $newFilename, + 'description' => $doc->description, + 'documentType' => $documentTypeKey, + 'documentTypeText' => $translationMap[$documentTypeKey] ?? $documentTypeKey, + 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt', + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc->create, + 'createFormatted' => date('d.m.Y H:i', $doc->create), + 'previewUrl' => "/File/Download/{$doc->fileId}", + ]; + } + + // Get journals + $journals = WorkorderJournalModel::getAll( + ['workorderId' => $id], + null, 0, + ['key' => 'create', 'order' => 'DESC'] + ); + + $responseJournals = []; + foreach ($journals as $journal) { + $responseJournals[] = [ + 'id' => $journal->id, + 'text' => $journal->text, + 'statusChange' => $journal->statusChange ?? null, + 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt', + 'create' => $journal->create, + 'createFormatted' => date('d.m.Y H:i', $journal->create), + ]; + } + + // Build checklist + $docTypes = $tenantConfigData['documentationTypes'] ?? []; + $uploadedTypes = array_column((array)$docs, 'documentType'); + $uploadedTypeCounts = array_count_values($uploadedTypes); + + $checklist = []; + $completedCount = 0; + + foreach ($docTypes as $type) { + $isCompleted = isset($uploadedTypeCounts[$type['value']]) && $uploadedTypeCounts[$type['value']] > 0; + if ($isCompleted) $completedCount++; + + $checklist[] = [ + 'type' => $type['value'], + 'text' => $type['text'], + 'required' => $type['required'] ?? false, + 'completed' => $isCompleted, + 'count' => $uploadedTypeCounts[$type['value']] ?? 0, + ]; + } + + self::returnJson([ + 'success' => true, + 'workorder' => $workorder, + 'tenantConfig' => $tenantConfigData, + 'docs' => $responseDocs, + 'journals' => $responseJournals, + 'checklist' => $checklist, + 'checklistProgress' => [ + 'completed' => $completedCount, + 'total' => count($docTypes), + 'allRequired' => $this->allRequiredCompleted($checklist) + ] + ]); + } + + /** + * Get documentation and journals for a workorder + * GET /MobileApp/Workorder/Workorder/getDocumentation?workorderId=X + */ + public function getDocumentationAction() { + $workorderId = intval($this->request->workorderId ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + // Get documentation + $docs = WorkorderDocumentationModel::getAll( + ['workorderId' => $workorderId], + null, 0, + ['key' => 'create', 'order' => 'ASC'] + ); + + // Get journals + $journals = WorkorderJournalModel::getAll( + ['workorderId' => $workorderId], + null, 0, + ['key' => 'create', 'order' => 'DESC'] + ); + + // Get tenant config for type translations + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + $translationMap = []; + if ($tenantConfig && !empty($tenantConfig->documentationTypes)) { + $customTypes = json_decode($tenantConfig->documentationTypes, true); + $customMap = array_column($customTypes, 'text', 'value'); + $translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap); + } + + // Transform docs + $responseDocs = []; + $typeCounts = []; + + foreach ($docs as $doc) { + $file = new File($doc->fileId); + $documentTypeKey = $doc->documentType; + $typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1; + $originalFilename = $file->orig_filename ?? $file->filename; + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey; + $newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension); + + $responseDocs[] = [ + 'id' => $doc->id, + 'fileId' => $doc->fileId, + 'fileName' => $newFilename, + 'description' => $doc->description, + 'documentType' => $documentTypeKey, + 'documentTypeText' => $translationMap[$documentTypeKey] ?? $documentTypeKey, + 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt', + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc->create, + 'createFormatted' => date('d.m.Y H:i', $doc->create), + 'previewUrl' => "/File/Download/{$doc->fileId}", + ]; + } + + // Transform journals + $responseJournals = []; + foreach ($journals as $journal) { + $responseJournals[] = [ + 'id' => $journal->id, + 'text' => $journal->text, + 'statusChange' => $journal->statusChange ?? null, + 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt', + 'create' => $journal->create, + 'createFormatted' => date('d.m.Y H:i', $journal->create), + ]; + } + + self::returnJson([ + 'success' => true, + 'docs' => $responseDocs, + 'journals' => $responseJournals, + 'docCount' => count($responseDocs), + 'journalCount' => count($responseJournals) + ]); + } + + /** + * Get tenant configuration + * GET /MobileApp/Workorder/Workorder/getTenantConfig?workorderId=X + */ + public function getTenantConfigAction() { + $workorderId = intval($this->request->workorderId ?? 0); + + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + + if (!$tenantConfig) { + self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true) ?? [], + 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired, + 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [], + 'requireCableLength' => (bool)$tenantConfig->requireCableLength, + 'requireCableType' => (bool)$tenantConfig->requireCableType, + ]); + } + + /** + * Upload documentation files + * POST /MobileApp/Workorder/Workorder/uploadDocumentation + */ + public function uploadDocumentationAction() { + if (empty($_FILES['files']) && empty($_FILES['file'])) { + self::returnJson(['success' => false, 'message' => 'Keine Datei hochgeladen']); + return; + } + + $workorderId = intval($_POST['workorderId'] ?? 0); + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + $documentType = $_POST['documentType'] ?? 'general'; + $description = $_POST['description'] ?? ''; + + // Handle both single file and multiple files + if (!empty($_FILES['files'])) { + foreach ($_FILES['files']['name'] as $index => $name) { + if ($_FILES['files']['error'][$index] === UPLOAD_ERR_OK) { + $_FILES['file'] = [ + 'name' => $name, + 'type' => $_FILES['files']['type'][$index], + 'tmp_name' => $_FILES['files']['tmp_name'][$index], + 'error' => $_FILES['files']['error'][$index], + 'size' => $_FILES['files']['size'][$index] + ]; + $this->saveDocumentation($workorderId, $documentType, $description); + } + } + } else if (!empty($_FILES['file'])) { + $this->saveDocumentation($workorderId, $documentType, $description); + } + + // Update workorder status if needed + $workorder = WorkorderModel::get($workorderId); + $oldStatus = $workorder->status; + $newStatus = null; + + if (in_array($oldStatus, ['assigned', 'scheduled'])) { + $newStatus = 'in_progress'; + } else if (in_array($oldStatus, ['correction_requested', 'problem_solved', 'civil_engineering_completed'])) { + $newStatus = 'assigned'; + } + + if ($newStatus) { + $workorder->status = $newStatus; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Status wurde nach Dokumenten-Upload automatisch geändert.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText($newStatus), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + } + + self::returnJson(['success' => true, 'message' => 'Datei(en) erfolgreich hochgeladen']); + } + + /** + * Delete documentation + * POST /MobileApp/Workorder/Workorder/deleteDocumentation + */ + public function deleteDocumentationAction() { + $postData = $this->getPostData(); + $id = intval($postData['id'] ?? 0); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Dokumenten-ID fehlt']); + return; + } + + WorkorderDocumentationModel::delete($id); + self::returnJson(['success' => true, 'message' => 'Dokument gelöscht']); + } + + /** + * Add journal entry + * POST /MobileApp/Workorder/Workorder/addJournal + */ + public function addJournalAction() { + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $text = trim($postData['text'] ?? ''); + + if (!$workorderId || !$text) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID und Text sind erforderlich']); + return; + } + + WorkorderJournalModel::create([ + 'workorderId' => $workorderId, + 'text' => $text, + 'createBy' => $this->user->id, + 'create' => time() + ]); + + // Return updated journals + $journals = WorkorderJournalModel::getAll( + ['workorderId' => $workorderId], + null, 0, + ['key' => 'create', 'order' => 'DESC'] + ); + + $responseJournals = []; + foreach ($journals as $journal) { + $responseJournals[] = [ + 'id' => $journal->id, + 'text' => $journal->text, + 'statusChange' => $journal->statusChange ?? null, + 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt', + 'create' => $journal->create, + 'createFormatted' => date('d.m.Y H:i', $journal->create), + ]; + } + + self::returnJson([ + 'success' => true, + 'message' => 'Journaleintrag hinzugefügt', + 'journals' => $responseJournals + ]); + } + + /** + * Update additional info (notes) + * POST /MobileApp/Workorder/Workorder/updateAdditionalInfo + */ + public function updateAdditionalInfoAction() { + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + $oldInfo = $workorder->additionalInfo; + $newInfo = $postData['additionalInfo'] ?? null; + $workorder->additionalInfo = $newInfo; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'", + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson([ + 'success' => true, + 'message' => 'Zusatzinfo aktualisiert', + 'newInfo' => $newInfo + ]); + } + + /** + * Schedule appointment + * POST /MobileApp/Workorder/Workorder/scheduleAppointment + */ + public function scheduleAppointmentAction() { + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $appointmentDate = intval($postData['appointmentDate'] ?? 0); + + if (!$workorderId || !$appointmentDate) { + self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + // Validate time is set + $hour = (int)date('H', $appointmentDate); + if ($hour >= 23 || $hour < 1) { + self::returnJson(['success' => false, 'message' => 'Bitte geben Sie eine Uhrzeit an']); + return; + } + + $workorder->appointmentDate = $appointmentDate; + $workorder->status = 'scheduled'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $appointmentDate), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert']); + } + + /** + * Request intervention (report problem) + * POST /MobileApp/Workorder/Workorder/requestIntervention + */ + public function requestInterventionAction() { + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $journalText = trim($postData['journalText'] ?? ''); + $interventionType = $postData['interventionType'] ?? ''; + + if (!$workorderId || !$journalText) { + self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + $oldStatus = $workorder->status; + $workorder->status = 'intervention_required'; + WorkorderModel::update((array)$workorder); + + $fullText = $interventionType ? "{$interventionType}: {$journalText}" : "Eingriff erforderlich: {$journalText}"; + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $fullText, + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Eingriff wurde angefordert']); + } + + /** + * Complete workorder + * POST /MobileApp/Workorder/Workorder/completeWorkorder + */ + public function completeWorkorderAction() { + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + // Validate cable data if required + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + if ($tenantConfig) { + if ($tenantConfig->requireCableLength && empty(trim($workorder->cableLength))) { + self::returnJson(['success' => false, 'message' => 'Bitte geben Sie die Kabellänge an']); + return; + } + if ($tenantConfig->requireCableType && empty(trim($workorder->cableType))) { + self::returnJson(['success' => false, 'message' => 'Bitte geben Sie den Kabeltyp an']); + return; + } + } + + $oldStatus = $workorder->status; + $workorder->status = 'documented'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Arbeitsauftrag zur Prüfung eingereicht.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag zur Prüfung eingereicht']); + } + + /** + * Update workorder data (cable info) + * POST /MobileApp/Workorder/Workorder/updateWorkorderData + */ + public function updateWorkorderDataAction() { + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + $journalText = "Zusatzdaten aktualisiert:\n"; + $changed = false; + + if (isset($postData['cableLength'])) { + if ($workorder->cableLength != $postData['cableLength']) { + $journalText .= "Kabellänge: '{$workorder->cableLength}' -> '{$postData['cableLength']}'\n"; + $workorder->cableLength = $postData['cableLength']; + $changed = true; + } + } + + if (isset($postData['cableType'])) { + if ($workorder->cableType != $postData['cableType']) { + $journalText .= "Kabeltyp: '{$workorder->cableType}' -> '{$postData['cableType']}'\n"; + $workorder->cableType = $postData['cableType']; + $changed = true; + } + } + + if (!$changed) { + self::returnJson(['success' => true, 'message' => 'Keine Änderungen vorgenommen']); + return; + } + + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $journalText, + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Daten gespeichert']); + } + + /** + * Get checklist status for a workorder + * GET /MobileApp/Workorder/Workorder/getChecklist?workorderId=X + */ + public function getChecklistAction() { + $workorderId = intval($this->request->workorderId ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + // Get tenant config for required doc types + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : []; + + // Get existing documentation + $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]); + $uploadedTypes = array_column((array)$docs, 'documentType'); + $uploadedTypeCounts = array_count_values($uploadedTypes); + + // Build checklist + $checklist = []; + $completedCount = 0; + + foreach ($docTypes as $type) { + $isCompleted = isset($uploadedTypeCounts[$type['value']]) && $uploadedTypeCounts[$type['value']] > 0; + if ($isCompleted) $completedCount++; + + $checklist[] = [ + 'type' => $type['value'], + 'text' => $type['text'], + 'required' => $type['required'] ?? false, + 'completed' => $isCompleted, + 'count' => $uploadedTypeCounts[$type['value']] ?? 0, + ]; + } + + self::returnJson([ + 'success' => true, + 'checklist' => $checklist, + 'completed' => $completedCount, + 'total' => count($docTypes), + 'allRequired' => $this->allRequiredCompleted($checklist) + ]); + } + + // ===================== + // HELPER METHODS + // ===================== + + /** + * Transform workorder for API response + * @param array|object $wo Workorder data (can be array or object from getCompanyWorkorders) + * @param bool $detailed Include full customer details + */ + private function transformWorkorder($wo, $detailed = false) { + // Handle both array and object formats + $isArray = is_array($wo); + $get = function($key, $default = null) use ($wo, $isArray) { + if ($isArray) { + return $wo[$key] ?? $default; + } + return $wo->$key ?? $default; + }; + + // Customer name: use company if available, else customerName (firstname lastname) + $customerCompany = $get('customerCompany', ''); + $customerName = $customerCompany ?: $get('customerName', ''); + + // Build address from the joined data + $street = $get('street', ''); + $hausnummer = $get('hausnummer', ''); + $plz = $get('plz', ''); + $city = $get('city', ''); + $customerAddress = trim("{$street} {$hausnummer}, {$plz} {$city}", ', '); + + $status = $get('status'); + $appointmentDate = $get('appointmentDate'); + $deadlineDate = $get('deadlineDate'); + $cableType = $get('cableType', ''); + $cableLength = $get('cableLength', ''); + + $result = [ + 'id' => intval($get('id', 0)), + 'fcpName' => $get('rimo_fcp_name', ''), + 'oaid' => $get('oaid', ''), + 'status' => $status, + 'statusText' => $this->statusOptions[$status]['text'] ?? $status, + 'statusColor' => $this->statusOptions[$status]['color'] ?? 'secondary', + 'customerName' => $customerName, + 'customerAddress' => $customerAddress, + 'additionalInfo' => $get('additionalInfo', ''), + 'appointmentDate' => $appointmentDate ? intval($appointmentDate) : null, + 'appointmentFormatted' => $appointmentDate ? date('d.m.Y H:i', $appointmentDate) : null, + 'deadlineDate' => $deadlineDate ? intval($deadlineDate) : null, + 'deadlineFormatted' => $deadlineDate ? date('d.m.Y', $deadlineDate) : null, + 'cableType' => $cableType, + 'cableLength' => $cableLength, + 'hasCableFlag' => !empty($cableType) || !empty($cableLength), + ]; + + // For detailed view (single workorder), include customer contact info + if ($detailed) { + $result['customer'] = [ + 'id' => intval($get('id', 0)), + 'name' => $customerName, + 'street' => trim("{$street} {$hausnummer}"), + 'zip' => $plz, + 'city' => $city, + 'phone' => $get('phone', ''), + 'email' => $get('email', ''), + 'gpsLat' => null, // Not available in this query + 'gpsLng' => null, + ]; + + $result['campaign'] = $get('networkOwnerName', ''); + } + + return $result; + } + + /** + * Get status display text + */ + private function getStatusText($statusKey) { + return $this->statusOptions[$statusKey]['text'] ?? ucfirst(str_replace('_', ' ', $statusKey)); + } + + /** + * Get tenant config from workorder + */ + private function getTenantConfigFromWorkorder($workorderId) { + if (!$workorderId) return null; + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) return null; + + $preorder = new Preorder($workorder->preorderId); + if (!$preorder->id) return null; + + $campaign = new Preordercampaign($preorder->preordercampaign_id); + if (!$campaign->id) return null; + + $network = NetworkModel::getOne($campaign->network_id); + if (!$network) return null; + + return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]); + } + + /** + * Save documentation file + */ + private function saveDocumentation($workorderId, $documentType, $description) { + try { + $uploaded = mfUpload::handleFormUpload("file", false, "/Workorder"); + WorkorderDocumentationModel::create([ + 'workorderId' => $workorderId, + 'fileId' => $uploaded->id, + 'description' => $description, + 'documentType' => $documentType, + 'create' => time(), + 'createBy' => $this->user->id + ]); + } catch (Exception $e) { + // Log error if necessary + } + } + + /** + * Check if all required checklist items are completed + */ + private function allRequiredCompleted($checklist) { + foreach ($checklist as $item) { + if ($item['required'] && !$item['completed']) { + return false; + } + } + return true; + } + + /** + * Get single workorder with full joined data (same structure as getCompanyWorkorders) + */ + private function getWorkorderWithDetails($workorderId, $companyId) { + $db = $this->db(); + $fronkDbName = FRONKDB_DBNAME; + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + + $sql = " + SELECT w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo, + w.cableType, w.cableLength, hn.rimo_fcp_name, + owner_addr.company as networkOwnerName, p.preordercampaign_id, + CONCAT_WS(' ', p.firstname, p.lastname) as customerName, p.company as customerCompany, p.oaid, + p.phone, p.email, str.name as street, hn.hausnummer, hn.stiege, we.bezeichner as apartment, + plz.plz, ort.name as city + FROM `{$fronkDbName}`.`Workorder` w + JOIN `{$fronkDbName}`.`Preorder` p ON w.preorderId = p.id + LEFT JOIN `{$fronkDbName}`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id + LEFT JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id + LEFT JOIN `{$fronkDbName}`.`Address` owner_addr ON n.owner_id = owner_addr.id + LEFT JOIN `{$addressDbName}`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id + LEFT JOIN `{$addressDbName}`.`Strasse` str ON hn.strasse_id = str.id + LEFT JOIN `{$addressDbName}`.`Plz` plz ON hn.plz_id = plz.id + LEFT JOIN `{$addressDbName}`.`Ortschaft` ort ON hn.ortschaft_id = ort.id + LEFT JOIN `{$addressDbName}`.`Wohneinheit` we ON p.adb_wohneinheit_id = we.id + WHERE w.id = " . intval($workorderId) . " + AND w.companyId = " . intval($companyId) . " + LIMIT 1 + "; + + $result = $db->query($sql); + return $result ? $result->fetch_assoc() : null; + } +} diff --git a/application/WarehouseMovement/WarehouseMovementController.php b/application/WarehouseMovement/WarehouseMovementController.php index df04de7a9..f8e6711f2 100644 --- a/application/WarehouseMovement/WarehouseMovementController.php +++ b/application/WarehouseMovement/WarehouseMovementController.php @@ -184,34 +184,21 @@ class WarehouseMovementController extends TTCrud { } protected function formatRow($row) { - // Format movement type with badge - $typeLabels = [ - 'IN' => 'Einbuchung', - 'OUT' => 'Ausbuchung', - 'ADJUSTMENT' => 'Korrektur', - ]; - $row['movementType'] = $typeLabels[$row['movementType']] ?? $row['movementType']; + $rawType = $row['movementType']; - // Format article if (!empty($row['articleId'])) { - $article = ArticleModel::get($row['articleId']); + $article = WarehouseArticleModel::get($row['articleId']); if ($article) { $row['articleId'] = "{$article->articleNumber}
{$article->title}"; } } - // 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']); - } + $allCategories = WarehouseMovementModel::getReasonCategories(); + $row['reasonCategory'] = $allCategories[$rawType][$row['reasonCategory']] ?? $row['reasonCategory']; return $row; } diff --git a/application/WarehouseMovement/WarehouseMovementModel.php b/application/WarehouseMovement/WarehouseMovementModel.php index 23cfd4407..fa069a20e 100644 --- a/application/WarehouseMovement/WarehouseMovementModel.php +++ b/application/WarehouseMovement/WarehouseMovementModel.php @@ -12,16 +12,14 @@ class WarehouseMovementModel extends TTCrudBaseModel { public ?float $quantityAfter = null; public string $reasonCategory; public ?string $note = null; + public ?int $linkedOrderId = 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"; + $prefix = "LB{$year}-X"; $db = FronkDB::singleton(); $result = $db->query("SELECT movementNumber FROM WarehouseMovement @@ -85,11 +83,8 @@ class WarehouseMovementModel extends TTCrudBaseModel { ]; } - /** - * Get article object - */ - public function getArticle(): ?ArticleModel { - return ArticleModel::get($this->articleId); + public function getArticle(): ?WarehouseArticleModel { + return WarehouseArticleModel::get($this->articleId); } /** @@ -114,6 +109,14 @@ class WarehouseMovementModel extends TTCrudBaseModel { return WarehouseItemModel::get($this->warehouseItemId); } + /** + * Get linked order if this movement was created from an order delivery + */ + public function getLinkedOrder(): ?WarehouseOrderModel { + if (!$this->linkedOrderId) return null; + return WarehouseOrderModel::get($this->linkedOrderId); + } + /** * Get formatted movement type label */ diff --git a/application/WarehouseOrder/WarehouseOrderController.php b/application/WarehouseOrder/WarehouseOrderController.php index 0e4d9d939..dc486fcfe 100644 --- a/application/WarehouseOrder/WarehouseOrderController.php +++ b/application/WarehouseOrder/WarehouseOrderController.php @@ -406,9 +406,72 @@ $appendToBody ]; try { + $createdMovementIds = []; + + // Create warehouse movements for delivery statuses + if (in_array($postData['status'], ['partiallyDelivered', 'fullyDelivered']) + && isset($postData['deliveryData']) && is_array($postData['deliveryData'])) { + + // Get location ID from request or use default (K1 Fladnitz 150) + $locationId = intval($postData['locationId'] ?? 0); + if ($locationId <= 0) { + // Default to K1 Fladnitz 150 + $allLocations = WarehouseLocationModel::getAll(); + $defaultLocation = null; + foreach ($allLocations as $loc) { + if ($loc->title === 'K1 Fladnitz 150') { + $defaultLocation = $loc; + break; + } + } + $locationId = $defaultLocation ? $defaultLocation->id : 1; + } + + // Prepare delivery data with articleId from order positions + $positions = json_decode($order->positions, true) ?: []; + $deliveryDataWithArticleIds = []; + + foreach ($postData['deliveryData'] as $index => $delivery) { + if (isset($positions[$index])) { + $delivery['articleId'] = $positions[$index]['article']; + } + $deliveryDataWithArticleIds[] = $delivery; + } + + $createdMovementIds = $this->createMovementsForDelivery( + intval($postData['orderId']), + $deliveryDataWithArticleIds, + $locationId + ); + + if (!empty($createdMovementIds)) { + // Update order with linked movement IDs + $existingMovementIds = $order->linkedMovementIds + ? json_decode($order->linkedMovementIds, true) : []; + $allMovementIds = array_merge($existingMovementIds, $createdMovementIds); + $orderAsArray['linkedMovementIds'] = json_encode($allMovementIds); + + // Add movement info to log message + $fullLogMessage .= ($fullLogMessage ? "\n\n" : "") . + count($createdMovementIds) . " Lagerbewegung(en) erstellt."; + $log['message'] = trim($fullLogMessage); + } + } + + // Store delivery note file IDs + if (!empty($postData['deliveryNoteFileIds'])) { + $existingFileIds = $order->deliveryNoteFileIds + ? json_decode($order->deliveryNoteFileIds, true) : []; + $allFileIds = array_merge($existingFileIds, $postData['deliveryNoteFileIds']); + $orderAsArray['deliveryNoteFileIds'] = json_encode($allFileIds); + } + if ($postData['status'] !== 'noChanges') { $orderAsArray['status'] = $postData['status']; WarehouseOrderModel::update($orderAsArray); + } elseif (!empty($orderAsArray['linkedMovementIds']) || !empty($orderAsArray['deliveryNoteFileIds'])) { + // Update even if status didn't change but we added linked data + WarehouseOrderModel::update($orderAsArray); } // Only create a log entry if there's actually something to log @@ -416,7 +479,11 @@ $appendToBody WarehouseLogModel::create($log); } - self::returnJson(['success' => true, 'message' => 'Log entry created']); + self::returnJson([ + 'success' => true, + 'message' => 'Log entry created', + 'createdMovementIds' => $createdMovementIds + ]); } catch (Exception $e) { self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]); } @@ -485,6 +552,107 @@ $appendToBody } } + protected function createMovementsForDelivery(int $orderId, array $deliveryData, int $locationId): array { + $order = WarehouseOrderModel::get($orderId); + $createdMovementIds = []; + foreach ($deliveryData as $delivery) { + $deliveredAmount = floatval($delivery['amount']); + $articleId = intval($delivery['articleId']); + + // Only create movements for items actually delivered + if ($deliveredAmount <= 0 || $articleId <= 0) { + continue; + } + + // Find or create WarehouseItem for article + location + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; + + if (!$warehouseItem) { + // Create new warehouse item with zero quantity + $warehouseItemId = WarehouseItemModel::create([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId, + 'quantity' => 0 + ]); + $warehouseItem = WarehouseItemModel::get($warehouseItemId); + } + + $quantityBefore = $warehouseItem->quantity; + $quantityAfter = $quantityBefore + $deliveredAmount; + + // Create warehouse movement + $movementData = [ + 'movementNumber' => WarehouseMovementModel::generateMovementNumber(), + 'movementType' => 'IN', + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId, + 'warehouseItemId' => $warehouseItem->id, + 'quantity' => $deliveredAmount, + 'quantityBefore' => $quantityBefore, + 'quantityAfter' => $quantityAfter, + 'reasonCategory' => 'Warenlieferung', + 'linkedOrderId' => $orderId, + 'note' => "Lagereingang aus Bestellung {$order->orderNumber}", + 'userId' => $this->user->id, + 'createBy' => $this->user->id, + 'create' => time() + ]; + + $movementId = WarehouseMovementModel::create($movementData); + $createdMovementIds[] = $movementId; + + // Update warehouse item quantity + $warehouseItem->quantity = $quantityAfter; + WarehouseItemModel::update((array)$warehouseItem); + } + + return $createdMovementIds; + } + + protected function getLinkedMovementsAction() { + $orderId = $this->request->orderId; + if (empty($orderId)) { + self::returnJson(['error' => 'Order ID is required']); + return; + } + + $order = WarehouseOrderModel::get($orderId); + $linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : []; + + $movements = []; + foreach ($linkedMovementIds as $movementId) { + $movement = WarehouseMovementModel::get($movementId); + if ($movement) { + $article = $movement->getArticle(); + $location = $movement->getLocation(); + $movements[] = [ + 'id' => $movement->id, + 'movementNumber' => $movement->movementNumber, + 'quantity' => $movement->quantity, + 'articleName' => $article ? $article->title : 'Unbekannt', + 'locationName' => $location ? $location->title : 'Unbekannt', + 'create' => $movement->create + ]; + } + } + + self::returnJson($movements); + } + + protected function getLocationsAction() { + $locations = WarehouseLocationModel::getAll(); + $result = array_map(function($loc) { + return [ + 'value' => $loc->id, + 'text' => $loc->title + ]; + }, $locations); + self::returnJson($result); + } } \ No newline at end of file diff --git a/application/WarehouseOrder/WarehouseOrderModel.php b/application/WarehouseOrder/WarehouseOrderModel.php index 4e14082c7..237f55ca2 100644 --- a/application/WarehouseOrder/WarehouseOrderModel.php +++ b/application/WarehouseOrder/WarehouseOrderModel.php @@ -32,6 +32,8 @@ class WarehouseOrderModel extends TTCrudBaseModel { public string $delAddrPLZ; public int $editor; public ?string $note; + public ?string $linkedMovementIds = null; + public ?string $deliveryNoteFileIds = null; public string $positions; public ?int $sendShippingNote; public int $create; diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteController.php b/application/WarehouseShippingNote/WarehouseShippingNoteController.php index 02b07ee85..448e6ab12 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteController.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteController.php @@ -6,7 +6,7 @@ class WarehouseShippingNoteController extends TTCrud { //@formatter:off protected array $columns = [ - ['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']], + ['key' => 'shippingNoteNumber', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap', 'filter' => 'search']], ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true], ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], ['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [ @@ -56,6 +56,7 @@ class WarehouseShippingNoteController extends TTCrud { ]); $this->postData['positions'] = json_encode($this->postData['positions']); if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']); + $this->postData['shippingNoteNumber'] = WarehouseShippingNoteModel::generateShippingNoteNumber(); return true; } @@ -486,10 +487,6 @@ class WarehouseShippingNoteController extends TTCrud { "bank_bank" => TT_INVOICE_BANK_BANK, "bank_owner" => TT_INVOICE_BANK_OWNER]; - // Replace placeholders in header - // create shipping note in this format LS2024-X0001 - // pad number on the left side with zeros - $shippingNoteNumber = "LS" . date("Y", $shippingNote->create) . "-" . str_pad($shippingNote->id, 4, "0", STR_PAD_LEFT); $headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html"); $headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml); $headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $headerHtml); @@ -504,7 +501,7 @@ class WarehouseShippingNoteController extends TTCrud { $headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml); $headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml); - $headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNoteNumber, $headerHtml); + $headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNote->shippingNoteNumber, $headerHtml); $headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml); $headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html"; diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php index 8cc4e5721..5354951b0 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php @@ -2,6 +2,7 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel { public int $id; + public ?string $shippingNoteNumber = null; public ?int $billingAddressId; public ?string $type; public ?string $metadata; @@ -21,4 +22,23 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel { public ?int $eShopOrderId; public ?int $create; public ?int $createBy; + + public static function generateShippingNoteNumber(): string { + $year = date('Y'); + $prefix = "LS{$year}-X"; + + $db = FronkDB::singleton(); + $result = $db->query("SELECT shippingNoteNumber FROM WarehouseShippingNote + WHERE shippingNoteNumber LIKE '{$prefix}%' + ORDER BY shippingNoteNumber DESC LIMIT 1"); + + if ($row = $result->fetch_assoc()) { + $lastNumber = intval(substr($row['shippingNoteNumber'], -4)); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad((string)$nextNumber, 4, '0', STR_PAD_LEFT); + } } \ No newline at end of file diff --git a/db/migrations/20260117120000_add_shipping_note_number.php b/db/migrations/20260117120000_add_shipping_note_number.php new file mode 100644 index 000000000..ba76c9224 --- /dev/null +++ b/db/migrations/20260117120000_add_shipping_note_number.php @@ -0,0 +1,50 @@ +getEnvironment() == "thetool") { + $table = $this->table('WarehouseShippingNote'); + $table + ->addColumn('shippingNoteNumber', 'string', ['limit' => 20, 'null' => true, 'after' => 'id']) + ->addIndex(['shippingNoteNumber'], ['unique' => true]) + ->update(); + + // Get all shipping notes ordered by create timestamp to assign numbers in chronological order + $rows = $this->fetchAll( + "SELECT id, YEAR(FROM_UNIXTIME(`create`)) as year + FROM WarehouseShippingNote + ORDER BY `create` ASC, id ASC" + ); + + // Group by year and assign sequential numbers + $yearCounters = []; + foreach ($rows as $row) { + $year = $row['year']; + if (!isset($yearCounters[$year])) { + $yearCounters[$year] = 0; + } + $yearCounters[$year]++; + + $shippingNoteNumber = 'LS' . $year . '-X' . str_pad((string)$yearCounters[$year], 4, '0', STR_PAD_LEFT); + + $this->execute( + "UPDATE WarehouseShippingNote SET shippingNoteNumber = '{$shippingNoteNumber}' WHERE id = {$row['id']}" + ); + } + + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WarehouseShippingNote'); + $table->removeColumn('shippingNoteNumber')->update(); + } + } +} diff --git a/db/migrations/20260117150000_add_order_movement_linking.php b/db/migrations/20260117150000_add_order_movement_linking.php new file mode 100644 index 000000000..6a6d9a31b --- /dev/null +++ b/db/migrations/20260117150000_add_order_movement_linking.php @@ -0,0 +1,43 @@ +getEnvironment() == "thetool") { + // Add columns to WarehouseOrder for linking to movements and delivery note files + $orderTable = $this->table('WarehouseOrder'); + $orderTable + ->addColumn('linkedMovementIds', 'text', ['null' => true, 'after' => 'note']) + ->addColumn('deliveryNoteFileIds', 'text', ['null' => true, 'after' => 'linkedMovementIds']) + ->update(); + + // Add column to WarehouseMovement for linking back to orders + $movementTable = $this->table('WarehouseMovement'); + $movementTable + ->addColumn('linkedOrderId', 'integer', ['null' => true, 'signed' => false, 'after' => 'note']) + ->addIndex(['linkedOrderId'], ['name' => 'idx_linkedOrderId']) + ->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $orderTable = $this->table('WarehouseOrder'); + $orderTable + ->removeColumn('linkedMovementIds') + ->removeColumn('deliveryNoteFileIds') + ->update(); + + $movementTable = $this->table('WarehouseMovement'); + $movementTable + ->removeIndex(['linkedOrderId']) + ->removeColumn('linkedOrderId') + ->update(); + } + } +} diff --git a/public/js/pages/WarehouseOrder/WarehouseOrder.css b/public/js/pages/WarehouseOrder/WarehouseOrder.css index b58b3475d..0f0211ca6 100644 --- a/public/js/pages/WarehouseOrder/WarehouseOrder.css +++ b/public/js/pages/WarehouseOrder/WarehouseOrder.css @@ -180,3 +180,411 @@ input:checked + .ios-switch-slider:before { input:disabled + .ios-switch-slider { cursor: not-allowed; } + +/* ===== ORDER DETAIL REDESIGN ===== */ + +.order-detail-container { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.order-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-radius: 10px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.order-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.order-number { + font-size: 1.25rem; + font-weight: 700; + color: #1a1a2e; +} + +.order-meta { + font-size: 0.875rem; + color: #6c757d; +} + +.order-meta span { + margin-right: 12px; +} + +.order-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.status-badge { + padding: 6px 14px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-new { background: #e3f2fd; color: #1565c0; } +.status-accepted { background: #e8f5e9; color: #2e7d32; } +.status-ordered { background: #fff3e0; color: #ef6c00; } +.status-sent { background: #fce4ec; color: #c2185b; } +.status-partiallyDelivered { background: #fff8e1; color: #f9a825; } +.status-fullyDelivered { background: #e8f5e9; color: #2e7d32; } +.status-cancelled { background: #ffebee; color: #c62828; } + +/* Status Form */ +.status-form-container { + background: #fff; + border: 2px solid #e3f2fd; + border-radius: 10px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 4px 12px rgba(0,0,0,0.08); +} + +.status-form-header { + display: flex; + gap: 16px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.status-form-header > div { + flex: 1; + min-width: 200px; +} + +.delivery-table { + width: 100%; + border-collapse: collapse; + margin: 16px 0; +} + +.delivery-table th { + text-align: left; + padding: 10px 12px; + background: #f8f9fa; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + color: #6c757d; + border-bottom: 2px solid #dee2e6; +} + +.delivery-table td { + padding: 12px; + border-bottom: 1px solid #e9ecef; +} + +.delivery-table input[type="number"] { + width: 80px; + padding: 6px 10px; + border: 1px solid #ced4da; + border-radius: 6px; + text-align: center; +} + +.delivery-table input[type="text"] { + width: 100%; + padding: 6px 10px; + border: 1px solid #ced4da; + border-radius: 6px; +} + +.status-form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e9ecef; +} + +.file-upload-row { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 16px; +} + +.file-upload-item { + flex: 1; + min-width: 200px; +} + +/* Section Titles */ +.section-title { + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6c757d; + margin: 24px 0 12px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.section-title i { + color: #1976d2; +} + +/* Positions Table */ +.positions-container { + border-radius: 10px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + border: 1px solid #e0e0e0; + margin-bottom: 24px; + background: #fff; +} + +.positions-table { + display: table !important; + width: 100%; + border-collapse: collapse; + background: #fff; +} + +.positions-table thead { + display: table-header-group !important; +} + +.positions-table tbody { + display: table-row-group !important; +} + +.positions-table tfoot { + display: table-footer-group !important; +} + +.positions-table tr { + display: table-row !important; +} + +.positions-table th, +.positions-table td { + display: table-cell !important; + vertical-align: middle; + position: static !important; +} + +.positions-table th { + text-align: left; + padding: 14px 20px; + background: #f8f9fa; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.3px; + color: #495057; + border-bottom: 2px solid #dee2e6; + position: static !important; +} + +/* Right-align numeric columns (Menge, Einzelpreis, Summe) */ +.positions-table th:nth-child(2), +.positions-table th:nth-child(3), +.positions-table th:nth-child(4), +.positions-table td:nth-child(2), +.positions-table td:nth-child(3), +.positions-table td:nth-child(4) { + text-align: right; +} + +.positions-table td { + padding: 12px 20px; + border-bottom: 1px solid #e9ecef; + font-size: 0.95rem; + color: #333; +} + +.positions-table tbody td:first-child { + font-weight: 500; + color: #1a1a2e; +} + +.positions-table tbody tr:nth-child(even) { + background: #fafbfc; +} + +.positions-table tbody tr:hover { + background: #f0f4f8; +} + +/* Total row in tfoot */ +.positions-table tfoot .total-row { + background: #f0f0f0; +} + +.positions-table tfoot .total-row td { + color: #333; + font-weight: 600; + font-size: 1rem; + padding: 14px 20px; + border-bottom: none; + border-top: 2px solid #dee2e6; +} + +.positions-table tfoot .total-row td:first-child { + font-weight: 700; +} + +/* Movements Table */ +.movements-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.movements-table th { + text-align: left; + padding: 8px 12px; + background: #f8f9fa; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + color: #6c757d; +} + +.movements-table td { + padding: 10px 12px; + border-bottom: 1px solid #e9ecef; +} + +.movements-table a { + color: #1976d2; + text-decoration: none; + font-weight: 500; +} + +.movements-table a:hover { + text-decoration: underline; +} + +.movement-qty { + color: #2e7d32; + font-weight: 600; +} + +/* Timeline */ +.timeline-container { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 10px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0,0,0,0.05); +} + +.timeline { + position: relative; + padding-left: 24px; + margin-left: 8px; +} + +.timeline::before { + content: ''; + position: absolute; + left: 3px; + top: 6px; + bottom: 6px; + width: 2px; + background: #dee2e6; + border-radius: 1px; +} + +.timeline-item { + position: relative; + padding-bottom: 20px; +} + +.timeline-item:last-child { + padding-bottom: 0; +} + +.timeline-marker { + position: absolute; + left: -21px; + top: 4px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #fff; + border: 2px solid #1976d2; + z-index: 1; +} + +.timeline-item.is-first .timeline-marker { + background: #1976d2; + width: 12px; + height: 12px; + left: -22px; + top: 3px; +} + +.timeline-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.timeline-date { + font-size: 0.8rem; + color: #6c757d; + font-weight: 500; +} + +.timeline-author { + font-size: 0.8rem; + color: #1976d2; + font-weight: 600; +} + +.timeline-body { + font-size: 0.9rem; + color: #333; + line-height: 1.5; + white-space: pre-line; +} + +.timeline-files { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.timeline-files a { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: #e3f2fd; + color: #1565c0; + border-radius: 4px; + font-size: 0.8rem; + text-decoration: none; +} + +.timeline-files a:hover { + background: #bbdefb; +} + +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; +} diff --git a/public/js/pages/WarehouseOrder/WarehouseOrder.js b/public/js/pages/WarehouseOrder/WarehouseOrder.js index 501439c38..8c1e73ae8 100644 --- a/public/js/pages/WarehouseOrder/WarehouseOrder.js +++ b/public/js/pages/WarehouseOrder/WarehouseOrder.js @@ -11,20 +11,32 @@ Vue.component('change-status-modal', { note: '', file: null, uploadedFiles: [], + deliveryNoteFiles: [], sendEmail: false, sendEmailViewedPDF: false, sendEmailMail: '', submitLoading: false, - deliveredPositions: {} // To track delivery details for each position + deliveredPositions: {}, // To track delivery details for each position + warehouseLocations: [], + selectedLocationId: null }; }, async mounted() { - const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getById`, {params: {id: this.orderId}}); - if (response.data.status === 'cancelled') { + const [orderResponse, locationsResponse] = await Promise.all([ + axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getById`, {params: {id: this.orderId}}), + axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getLocations`) + ]); + + if (orderResponse.data.status === 'cancelled') { this.$emit('close'); window.notify('error', 'Bestellung wurde storniert'); } - this.order = response.data; + this.order = orderResponse.data; + this.warehouseLocations = locationsResponse.data; + + // Set default location to "K1 Fladnitz 150" if available + const defaultLocation = this.warehouseLocations.find(loc => loc.text === 'K1 Fladnitz 150'); + this.selectedLocationId = defaultLocation ? defaultLocation.value : (this.warehouseLocations[0]?.value || null); // Initialize deliveredPositions after fetching the order if (this.order && this.order.positions) { @@ -40,6 +52,10 @@ Vue.component('change-status-modal', { } }, computed: { + movementPreviewCount() { + if (!this.deliveredPositions) return 0; + return Object.values(this.deliveredPositions).filter(p => p.amount > 0).length; + }, availableStatuses() { // This computed property remains unchanged switch (this.order.status) { @@ -86,8 +102,7 @@ Vue.component('change-status-modal', { } }, methods: { - async handleFileUpload(event) { - // This method remains unchanged + async handleFileUpload(event, isDeliveryNote = false) { const files = event.target.files; if (!files.length) return; @@ -104,16 +119,21 @@ Vue.component('change-status-modal', { }); if (response.data.success) { - this.uploadedFiles.push({ + const fileEntry = { id: response.data.fileId, name: file.name - }); - window.notify('success', `File "${file.name}" uploaded successfully`); + }; + if (isDeliveryNote) { + this.deliveryNoteFiles.push(fileEntry); + } else { + this.uploadedFiles.push(fileEntry); + } + window.notify('success', `Datei "${file.name}" erfolgreich hochgeladen`); } else { - window.notify('error', `File "${file.name}" upload failed: ${response.data.error || 'Unknown error'}`); + window.notify('error', `Datei "${file.name}" Upload fehlgeschlagen: ${response.data.error || 'Unbekannter Fehler'}`); } } catch (error) { - window.notify('error', `Error uploading file "${file.name}"`); + window.notify('error', `Fehler beim Hochladen von "${file.name}"`); } } event.target.value = ''; @@ -121,6 +141,9 @@ Vue.component('change-status-modal', { removeFile(index) { this.uploadedFiles.splice(index, 1) }, + removeDeliveryNoteFile(index) { + this.deliveryNoteFiles.splice(index, 1) + }, async submit() { this.submitLoading = true; if (this.newStatus === 'accepted' && this.sendEmail && !this.sendEmailMail) { @@ -139,6 +162,7 @@ Vue.component('change-status-modal', { } const fileIds = this.uploadedFiles.map(file => file.id); + const deliveryNoteFileIds = this.deliveryNoteFiles.map(file => file.id); // Prepare delivery data if the status is related to delivery let deliveryData = null; @@ -151,7 +175,9 @@ Vue.component('change-status-modal', { status: this.newStatus, note: this.note, fileIds: JSON.stringify(fileIds), - deliveryData: deliveryData // Send the new delivery data to the backend + deliveryData: deliveryData, // Send the new delivery data to the backend + locationId: this.selectedLocationId, + deliveryNoteFileIds: deliveryNoteFileIds }); if (response.data.success) { @@ -230,6 +256,12 @@ Vue.component('change-status-modal', {
+

Lagerstandort

+ +

Positionen Lieferung erfassen

Artikel
@@ -270,6 +302,32 @@ Vue.component('change-status-modal', {
+ +
+ + Es werden {{ movementPreviewCount }} Lagerbewegung(en) erstellt. +
+ +

Lieferschein Foto

+
+ + +
+
+
+ + Lieferschein hochgeladen +
+ +
@@ -581,60 +639,399 @@ Vue.component('tt-file', { Vue.component('warehouse-order-detail', { template: ` - - +
-