Xinon mobile/improve
This commit is contained in:
@@ -1,473 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||
|
||||
/**
|
||||
* Warehouse Stocktake Handler
|
||||
*
|
||||
* Handles all endpoints for the Warehouse Stocktake PWA.
|
||||
* Migrated from WarehouseStocktakePWAController with new structure.
|
||||
*/
|
||||
class WarehouseStocktakeHandler extends MobileAppBaseHandler {
|
||||
|
||||
protected $requiredPermission = 'WarehouseUser';
|
||||
protected $appName = 'WarehouseStocktake';
|
||||
protected $viewTemplate = 'MobileApp/WarehouseStocktake';
|
||||
|
||||
/**
|
||||
* Get active stocktakes that user can participate in
|
||||
* GET /MobileApp/WarehouseStocktake/getActiveStocktakes
|
||||
*/
|
||||
public function getActiveStocktakesAction() {
|
||||
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
|
||||
|
||||
$result = [];
|
||||
foreach ($stocktakes as $stocktake) {
|
||||
$location = $stocktake->getLocation();
|
||||
$result[] = [
|
||||
'id' => $stocktake->id,
|
||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
||||
'title' => $stocktake->title,
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'stocktakes' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stocktake details
|
||||
* GET /MobileApp/WarehouseStocktake/getStocktake?id=X
|
||||
*/
|
||||
public function getStocktakeAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$location = $stocktake->getLocation();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'stocktake' => [
|
||||
'id' => $stocktake->id,
|
||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
||||
'title' => $stocktake->title,
|
||||
'status' => $stocktake->status,
|
||||
'locationId' => $stocktake->warehouseLocationId,
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article by QR code or article number
|
||||
* GET /MobileApp/WarehouseStocktake/getArticle?code=X
|
||||
*/
|
||||
public function getArticleAction() {
|
||||
$code = $this->request->code;
|
||||
|
||||
if (!$code) {
|
||||
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = null;
|
||||
|
||||
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
|
||||
// Also accept WH: for backwards compatibility
|
||||
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
||||
$articleId = intval($matches[1]);
|
||||
} else {
|
||||
// Try to find by article number
|
||||
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
|
||||
if ($article) {
|
||||
$articleId = $article->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get category name
|
||||
$category = WarehouseCategory::get($article->category_id);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'title' => $article->title,
|
||||
'description' => $article->description ?? '',
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'categoryName' => $category ? $category->name : '',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search articles by text with optional category filter
|
||||
* GET /MobileApp/WarehouseStocktake/searchArticles?query=X&categoryId=Y
|
||||
*/
|
||||
public function searchArticlesAction() {
|
||||
$query = $this->request->query ?? '';
|
||||
$categoryId = intval($this->request->categoryId ?? 0);
|
||||
|
||||
$db = $this->db();
|
||||
$conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
|
||||
|
||||
if ($query && strlen($query) >= 2) {
|
||||
$escapedQuery = $db->escape($query);
|
||||
$conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
|
||||
}
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$conditions[] = "category_id = {$categoryId}";
|
||||
}
|
||||
|
||||
if (count($conditions) === 1 && !$categoryId) {
|
||||
self::returnJson(['success' => true, 'articles' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $conditions);
|
||||
$result = $db->query("SELECT id, articleNumber, title, unit, category_id
|
||||
FROM WarehouseArticle
|
||||
WHERE {$whereClause}
|
||||
ORDER BY title ASC
|
||||
LIMIT 50");
|
||||
|
||||
$articles = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$articles[] = [
|
||||
'id' => intval($row['id']),
|
||||
'articleNumber' => $row['articleNumber'],
|
||||
'title' => $row['title'],
|
||||
'unit' => $row['unit'] ?? 'Stk.',
|
||||
'categoryId' => intval($row['category_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'articles' => $articles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories for browsing
|
||||
* GET /MobileApp/WarehouseStocktake/getCategories
|
||||
*/
|
||||
public function getCategoriesAction() {
|
||||
$db = $this->db();
|
||||
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
|
||||
|
||||
$categories = [];
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$categories[] = [
|
||||
'id' => intval($row['id']),
|
||||
'name' => $row['name'],
|
||||
];
|
||||
}
|
||||
self::returnJson(['success' => true, 'categories' => $categories]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if article is already scanned in stocktake
|
||||
* GET /MobileApp/WarehouseStocktake/checkAlreadyScanned?stocktakeId=X&articleId=Y
|
||||
*/
|
||||
public function checkAlreadyScannedAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
$articleId = intval($this->request->articleId);
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$db = $this->db();
|
||||
$scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
|
||||
$scannedByRow = $scannedByResult->fetch_assoc();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'alreadyScanned' => true,
|
||||
'existingItem' => [
|
||||
'id' => $existing->id,
|
||||
'countedQuantity' => $existing->countedQuantity,
|
||||
'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
|
||||
'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
self::returnJson(['success' => true, 'alreadyScanned' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a scanned item
|
||||
* POST /MobileApp/WarehouseStocktake/submitScan
|
||||
*/
|
||||
public function submitScanAction() {
|
||||
$postData = $this->getPostData();
|
||||
|
||||
$stocktakeId = intval($postData['stocktakeId'] ?? 0);
|
||||
$articleId = intval($postData['articleId'] ?? 0);
|
||||
$quantity = floatval($postData['quantity'] ?? 0);
|
||||
$rack = $postData['rack'] ?? null;
|
||||
$shelf = $postData['shelf'] ?? null;
|
||||
$note = $postData['note'] ?? null;
|
||||
$overwrite = boolval($postData['overwrite'] ?? false);
|
||||
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($quantity <= 0) {
|
||||
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify stocktake exists and is in progress
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'in_progress') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify article exists
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->db();
|
||||
|
||||
// If overwrite mode is enabled, mark existing item as overwritten
|
||||
if ($overwrite && $overwriteItemId) {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
|
||||
// Mark old item as overwritten by new item
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
|
||||
|
||||
$finalQuantity = $quantity;
|
||||
|
||||
// Log the overwrite
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'overwrittenItemId' => $overwriteItemId,
|
||||
]);
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isOverwrite' => true,
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this article was already scanned in this stocktake (non-overwritten)
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
// Update existing entry - add to quantity
|
||||
$newQuantity = $existing->countedQuantity + $quantity;
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET
|
||||
countedQuantity = {$newQuantity},
|
||||
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
|
||||
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
|
||||
scannedAt = " . time() . ",
|
||||
scannedBy = {$this->user->id}
|
||||
WHERE id = {$existing->id}");
|
||||
|
||||
$itemId = $existing->id;
|
||||
$finalQuantity = $newQuantity;
|
||||
$isUpdate = true;
|
||||
} else {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
$finalQuantity = $quantity;
|
||||
$isUpdate = false;
|
||||
}
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
// Log the scan
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'totalQuantity' => $finalQuantity,
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isUpdate' => $isUpdate,
|
||||
]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => $isUpdate
|
||||
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
|
||||
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isUpdate' => $isUpdate,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent scans for current user in a stocktake
|
||||
* GET /MobileApp/WarehouseStocktake/getMyScans?stocktakeId=X
|
||||
*/
|
||||
public function getMyScansAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
|
||||
if (!$stocktakeId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->db();
|
||||
$result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
|
||||
FROM WarehouseStocktakeItem si
|
||||
JOIN WarehouseArticle wa ON wa.id = si.articleId
|
||||
WHERE si.stocktakeId = {$stocktakeId}
|
||||
AND si.scannedBy = {$this->user->id}
|
||||
ORDER BY si.scannedAt DESC
|
||||
LIMIT 50");
|
||||
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = [
|
||||
'id' => intval($row['id']),
|
||||
'articleId' => intval($row['articleId']),
|
||||
'articleNumber' => $row['articleNumber'],
|
||||
'articleTitle' => $row['articleTitle'],
|
||||
'countedQuantity' => floatval($row['countedQuantity']),
|
||||
'unit' => $row['unit'] ?? 'Stk.',
|
||||
'rack' => $row['rack'],
|
||||
'shelf' => $row['shelf'],
|
||||
'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'items' => $items]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress stats
|
||||
* GET /MobileApp/WarehouseStocktake/getProgress?stocktakeId=X
|
||||
*/
|
||||
public function getProgressAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
|
||||
if (!$stocktakeId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->db();
|
||||
|
||||
// Total scanned items
|
||||
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
|
||||
$totalRow = $totalResult->fetch_assoc();
|
||||
$totalScanned = intval($totalRow['count']);
|
||||
|
||||
// My scanned items
|
||||
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
|
||||
$myRow = $myResult->fetch_assoc();
|
||||
$myScanned = intval($myRow['count']);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'progress' => [
|
||||
'totalScanned' => $totalScanned,
|
||||
'myScanned' => $myScanned,
|
||||
'status' => $stocktake->status,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
<?php
|
||||
|
||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||
|
||||
/**
|
||||
* ShippingNote (Lieferschein) Handler
|
||||
*
|
||||
* Handles all endpoints for the Lager > 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
<?php
|
||||
|
||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||
|
||||
/**
|
||||
* Workorder (Aufträge) Handler
|
||||
*
|
||||
* Handles all endpoints for the Workorder module in MobileApp.
|
||||
* API Base: /MobileApp/Workorder/Workorder/{action}
|
||||
*
|
||||
* Ports functionality from WorkorderCompanyController and WorkorderBaseController.
|
||||
*/
|
||||
class WorkorderHandler extends MobileAppBaseHandler {
|
||||
|
||||
protected $requiredPermission = 'RMLCompany';
|
||||
|
||||
/** @var array Status definitions for workorders */
|
||||
protected $statusOptions = [
|
||||
'new' => ['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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user