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