Files
thetool/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php
2026-01-17 12:48:08 +00:00

762 lines
26 KiB
PHP

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