Xinon mobile/improve

This commit is contained in:
Luca Haid
2026-01-17 12:48:08 +00:00
parent 51c9c5ae7e
commit 1426d769d2
37 changed files with 7502 additions and 1042 deletions

View File

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

View File

@@ -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()) {

View File

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

View File

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

View File

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

View File

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