'shippingNoteNumber', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap', 'filter' => 'search']], ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true], ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], ['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [ ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'], ['value' => 'accepted', 'text' => 'Akzeptiert', 'icon' => 'fas fa-check text-success'], ['value' => 'invoiced', 'text' => 'In Rechnung gestellt', 'icon' => 'fas fa-file-invoice-dollar text-info'], ['value' => 'cancelled', 'text' => 'Storniert', 'icon' => 'fas fa-ban text-danger'], ['value' => 'on_hold', 'text' => 'In Wartestellung', 'icon' => 'fas fa-pause text-warning'], ]]], ['key' => 'type', 'text' => 'Typ', 'required' => false], ['key' => 'deliveryAddressName', 'text' => 'L.-Adr. Name', 'required' => true], ['key' => 'deliveryAddressLine', 'text' => 'L.-Adr.', 'required' => true], ['key' => 'deliveryAddressPLZ', 'text' => 'L.-Adr. PLZ', 'required' => true], ['key' => 'deliveryAddressEMail', 'text' => 'L.-Adr. EMail', 'required' => false, 'table' => false], ['key' => 'positions', 'text' => 'Positionen', 'required' => true, 'table' => false, 'modal' => false], ['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => ['visible' => false], 'table' => ['filter' => 'date']], ['key' => 'createBy', 'text' => 'Erstellt von', 'required' => false, 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'select'], 'modal' => ['items' => [], 'type' => 'select',]], ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],]; protected array $defaultOrder = ['key' => 'create', 'order' => 'DESC']; protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_MENU' => false]; protected array $infoMessages = ['create' => 'Lieferschein wurde erstellt.', 'update' => 'Lieferschein wurde aktualisiert', 'delete' => 'Lieferschein wurde gelöscht', 'noChanges' => 'Keine Änderungen vorgenommen']; protected array $permissionCheck = ['WarehouseUser']; protected array $additionalActions = [ [ 'key' => 'createManualInvoice', 'title' => 'Rechnung erstellen', 'class' => 'fas fa-file-invoice text-primary', 'condition' => ['status' => 'accepted'] ] ]; //@formatter:on protected function prepareCrudConfig() { if (!$this->user->can('WarehouseAdmin')) $this->additionalJSVariables['WAREHOUSE_ADMIN'] = false; if (isset($_SESSION[MFAPPNAME . '_warehouse_login_override']) && is_numeric($_SESSION[MFAPPNAME . '_warehouse_login_override'])) $this->additionalJSVariables['HIDE_MENU'] = true; } protected function beforeCreate($postData): bool { $this->validate($postData, [ fn($p) => $p['status'] === 'new' ?: 'Status muss "Neu" sein', fn($p) => $this->validateHours($p['hoursEntries']) ]); $this->postData['positions'] = json_encode($this->postData['positions']); if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']); $this->postData['shippingNoteNumber'] = WarehouseShippingNoteModel::generateShippingNoteNumber(); return true; } protected function beforeUpdate($postData): bool { if (!$this->user->can('WarehouseAdmin')) { $this->validate($postData, [ fn($p) => !in_array(WarehouseShippingNoteModel::get($p['id'])->status, ['accepted', 'invoiced']) ?: 'Änderungen nicht mehr möglich', fn($p) => $this->validateHours($p['hoursEntries']) ]); } $this->postData['positions'] = json_encode($this->postData['positions']); if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']); (new WarehouseHistoryController)->create($postData, $this->mod); return true; } private function validateHours($entries): bool { foreach ($entries as $e) { if (!preg_match('/^[\d,.]*$/', $e['hourCount'])) { $this->sendError('Stundenanzahl darf nur Zahlen, Komma oder Punkt enthalten'); } } return true; } private function validate($data, array $rules): void { foreach ($rules as $rule) { if (($result = $rule($data)) !== true) { $this->sendError(is_string($result) ? $result : 'Validation failed'); } } } protected function customAutoCompleteBillingAddressId($id) { $address = new Address($id); if (!$address->id) return null; return [ 'id' => $address->id, 'title' => sprintf( "%s (%s %s, %s)%s", str_replace(["'", "\n", "\r"], ["\\'", ' ', ' '], $address->getCompanyOrName()), $address->zip, $address->city, $address->street, $address->customer_number ? " [{$address->customer_number}]" : '' ) ]; } protected function getHistoryAction() { self::returnJson(array_values( array_filter( (new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns), fn($e) => $e['key'] !== 'positions' ) )); } protected function getShippingNoteForInvoiceAction() { $id = $this->request->id; // Get shipping note $shippingNote = WarehouseShippingNoteModel::get($id); if (!$shippingNote) { self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']); return; } // Get billing address info $billingAddress = null; if ($shippingNote->billingAddressId) { $billingAddress = new Address($shippingNote->billingAddressId); if (!$billingAddress->id) { $billingAddress = null; } } // Determine price type ONCE (not in loop for performance) $priceType = 'Verkauf'; if ($shippingNote->billingAddressId) { $addressPriceType = AddressPriceTypeModel::getFirst(['address_id' => $shippingNote->billingAddressId]); if ($addressPriceType) { $warehousePriceType = WarehouseArticlePriceTypeModel::get($addressPriceType->priceType_id); if ($warehousePriceType) { $priceType = $warehousePriceType->title; } } } // Decode and enrich positions $positions = json_decode($shippingNote->positions, true); if (!is_array($positions)) { $positions = []; } $enrichedPositions = []; foreach ($positions as $position) { if (isset($position['article'])) { // Fetch article details $article = WarehouseArticleModel::get($position['article']); if (!$article) continue; // Get price for determined price type $prices = json_decode($article->cheapestSellPrice, true) ?: []; $price = 0; foreach ($prices as $p) { if ($p['title'] === $priceType) { $price = $p['price']; break; } } $enrichedPositions[] = [ 'type' => 'article', 'articleId' => $article->id, 'product_name' => $article->articleNumber . " | " . $article->title, 'product_info' => $article->description, 'amount' => $position['amount'], 'unit' => $article->unit, 'price' => $price, 'discount' => 0, 'vatrate' => 20 ]; } elseif (isset($position['articlePacket'])) { // Handle article packets $packet = WarehouseArticlePacketModel::get($position['articlePacket']); if (!$packet) continue; $enrichedPositions[] = [ 'type' => 'packet', 'packetId' => $packet->id, 'product_name' => $packet->title, 'product_info' => $packet->description ?? '', 'amount' => $position['amount'], 'unit' => 'Pau.', 'price' => 0, 'discount' => 0, 'vatrate' => 20 ]; } elseif (isset($position['articleText'])) { // Handle custom text entries $enrichedPositions[] = [ 'type' => 'text', 'product_name' => $position['articleText'], 'product_info' => '', 'amount' => $position['amount'] ?? 1, 'unit' => 'Stk.', 'price' => 0, 'discount' => 0, 'vatrate' => 20 ]; } } // Add hours entries as positions $hoursEntries = json_decode($shippingNote->hoursEntries, true); if (!is_array($hoursEntries)) { $hoursEntries = []; } foreach ($hoursEntries as $hoursEntry) { if (empty($hoursEntry['hourCount']) || floatval(str_replace(",", ".", $hoursEntry['hourCount'])) <= 0) { continue; } $userName = 'Unbekannt'; if (!empty($hoursEntry['userId']) && is_numeric($hoursEntry['userId'])) { try { $user = UserModel::getOne($hoursEntry['userId']); $userName = $user ? $user->name : 'Unbekannt'; } catch (Exception $e) { $userName = 'Unbekannt'; } } elseif (!empty($hoursEntry['userId_text'])) { $userName = $hoursEntry['userId_text']; } $enrichedPositions[] = [ 'type' => 'hours', 'product_name' => 'Arbeitsstunden - ' . $userName, 'product_info' => 'Datum: ' . (isset($hoursEntry['date']) ? date('d.m.Y', strtotime($hoursEntry['date'])) : ''), 'amount' => str_replace(",", ".", $hoursEntry['hourCount']), 'unit' => 'h', 'price' => 60, 'discount' => 0, 'vatrate' => 20 ]; } self::returnJson([ 'success' => true, 'data' => [ 'shippingNoteId' => $shippingNote->id, 'billingAddress' => $billingAddress ? [ 'id' => $billingAddress->id, 'customer_number' => $billingAddress->customer_number, 'company' => $billingAddress->company, 'firstname' => $billingAddress->firstname, 'lastname' => $billingAddress->lastname, 'street' => $billingAddress->street, 'zip' => $billingAddress->zip, 'city' => $billingAddress->city, 'email' => $billingAddress->email, 'uid' => $billingAddress->uid, 'fibu_account_number' => $billingAddress->fibu_account_number, 'billing_type' => $billingAddress->billing_type, 'billing_delivery' => $billingAddress->billing_delivery, 'bank_account_bank' => $billingAddress->bank_account_bank, 'bank_account_owner' => $billingAddress->bank_account_owner, 'bank_account_iban' => $billingAddress->bank_account_iban, 'bank_account_bic' => $billingAddress->bank_account_bic, 'sepa_date' => $billingAddress->sepa_date, 'fibu_payment_due' => $billingAddress->fibu_payment_due, 'fibu_payment_skonto' => $billingAddress->fibu_payment_skonto, 'fibu_payment_skonto_rate' => $billingAddress->fibu_payment_skonto_rate ] : null, 'deliveryAddress' => [ 'name' => $shippingNote->deliveryAddressName, 'line' => $shippingNote->deliveryAddressLine, 'plz' => $shippingNote->deliveryAddressPLZ, 'city' => $shippingNote->deliveryAddressCity, 'email' => $shippingNote->deliveryAddressEMail ], 'note' => $shippingNote->note, 'positions' => $enrichedPositions ] ]); } protected function getArticleAddressPriceAction() { empty($this->request->articleId) && $this->sendError('Keine Artikel ID gefunden'); empty($this->request->addressId) && $this->sendError('Keine Adress ID gefunden'); // TODO: we use verkauf as default now, later we will need to use from address $priceTypes = WarehouseArticlePriceTypeModel::getAll(['title' => 'Verkauf']); if (empty($priceTypes)) $this->sendError('Keine Preiskategorie gefunden'); $priceType = $priceTypes[0]->title; $article = WarehouseArticleModel::get($this->request->articleId); $prices = json_decode($article->cheapestSellPrice, true); foreach ($prices as $price) if ($price['title'] === $priceType) self::returnJson(['success' => true, 'price' => $price['price']]); $this->sendError('Kein Preis gefunden'); } protected function signAction() { $id = $this->request->id; if (empty($id) || !$shippingNote = (array) WarehouseShippingNoteModel::get($id)) $this->sendError('Lieferschein nicht gefunden'); if ($shippingNote["signature"] || $shippingNote["signatureName"]) $this->sendError('Bereits unterschrieben'); $post = json_decode(file_get_contents('php://input'), true); if (empty($post['signature']) || empty($post['signatureName'])) { $this->sendError('Unterschrift/Name fehlt'); } try { WarehouseShippingNoteModel::update(array_merge($shippingNote, ['signature' => $post['signature'], 'signatureName' => $post['signatureName'], 'signatureDate' => date('Y-m-d')])); self::returnJson(['success' => true, 'message' => 'Unterschrift gespeichert']); } catch (Exception $e) { $this->sendError('Speichern fehlgeschlagen'); } } private function sendSuccess(string $message) { self::returnJson(['success' => true, 'message' => $message]); } public function createPDFAction($returnFilename = false, $idOverride = null) { $id = $idOverride ?? $this->request->id; if (strlen($id) < 1) { http_response_code(500); self::returnJson(['success' => false, 'message' => 'Lieferschein wurde nicht gefunden']); } $shippingNote = WarehouseShippingNoteModel::get($id); if (!empty($shippingNote->billingAddressId)) $address = AddressModel::getOne($shippingNote->billingAddressId); $positions = []; // loop through all positions and add articleTitle and articleDescription to each position entry foreach (json_decode($shippingNote->positions, true) as $position) { if (isset($position['article'])) { $article = WarehouseArticleModel::get($position['article']); if (isset($article->articleNumber)) { $position['articleTitle'] = $article->articleNumber . " | " . $article->title; } else { $position['articleTitle'] = $article->title; } if (isset($position['isEnergieMaterial']) && $position['isEnergieMaterial'] == 1) { $position['articleTitle'] .= " (beigestelltes Material)"; } $position['articleDescription'] = $article->description === $article->title ? "" : $article->description; $position['articleUnit'] = $article->unit; $positions[] = $position; } elseif (isset($position['articlePacket'])) { $articlePacket = WarehouseArticlePacketModel::get($position['articlePacket']); $position['articleTitle'] = $articlePacket->title; $position['articleDescription'] = $articlePacket->description === $articlePacket->title ? "" : $articlePacket->description; $position['articleUnit'] = 'Stk.'; $positions[] = $position; } elseif (isset($position['articleText'])) { $position['articleTitle'] = $position['articleText']; if (isset($position['isEnergieMaterial']) && $position['isEnergieMaterial'] == 1) $position['articleTitle'] .= " (beigestelltes Material)"; $position['articleDescription'] = ""; $position['articleUnit'] = 'Stk.'; $positions[] = $position; } elseif (isset($position['article_text'])) { $position['articleTitle'] = $position['article_text']; if (isset($position['isEnergieMaterial']) && $position['isEnergieMaterial'] == 1) $position['articleTitle'] .= " (beigestelltes Material)"; $position['articleDescription'] = ""; $position['articleUnit'] = 'Stk.'; $positions[] = $position; } } // json decode hoursEntries and add to positions $hoursEntries = json_decode($shippingNote->hoursEntries, true); foreach ($hoursEntries as $hoursEntry) { // die(json_encode($hoursEntry)); $articleTitle = "Arbeitsstunden"; if (isset($hoursEntry['priceType']) && $hoursEntry['priceType'] == 50) { $articleTitle = "Arbeitsstunden (50% Zuschlag)"; } elseif (isset($hoursEntry['priceType']) && $hoursEntry['priceType'] == 100) { $articleTitle = "Arbeitsstunden (100% Zuschlag)"; } elseif (isset($hoursEntry['hourType']) && $hoursEntry['hourType'] == '50') { $articleTitle = "Arbeitsstunden (+50% Zuschlag)"; } elseif (isset($hoursEntry['hourType']) && $hoursEntry['hourType'] == '100') { $articleTitle = "Arbeitsstunden (+100% Zuschlag)"; } elseif (isset($hoursEntry['hourType']) && $hoursEntry['hourType'] == 'regie') { $articleTitle = "Arbeitsstunden (Regie)"; } if (floatval(str_replace(",", ".", $hoursEntry['hourCount'])) > 0) { $userText = ''; if (!empty($hoursEntry['userId'])) { $user = UserModel::getOne($hoursEntry['userId']); $userText = $user->name; } else { $userText = $hoursEntry['userId_text']; } $articleDescription = "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Mitarbeiter: " . $userText; if (isset($hoursEntry['comment'])) { $articleDescription .= " | Zusatz: " . $hoursEntry['comment']; } $positions[] = [ 'articleTitle' => $articleTitle, 'articleDescription' => $articleDescription, 'articleUnit' => 'Std.', 'amount' => $hoursEntry['hourCount'], ]; } if (!empty($hoursEntry['externalCar']) && !empty($hoursEntry['kilometerCount']) && $hoursEntry['kilometerCount'] > 0 && $hoursEntry['externalCar'] == 1) { $positions[] = [ 'articleTitle' => "Fahrkostenpauschale (hin und retour)", 'articleDescription' => "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Externes Fahrzeug", 'articleUnit' => 'km', 'amount' => $hoursEntry['kilometerCount'], 'price' => 2 * $hoursEntry['kilometerCount'] ?? 0, ]; } else if (!empty($hoursEntry['carId']) && $hoursEntry['kilometerCount'] > 0) { $hoursEntry['carId'] = intval($hoursEntry['carId']); $positions[] = [ 'articleTitle' => "Fahrkostenpauschale (hin und retour)", 'articleDescription' => "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Fahrzeug: " . TimerecordingCarModel::getOne($hoursEntry['carId'])->number_plate, 'articleUnit' => 'km', 'amount' => $hoursEntry['kilometerCount'], 'price' => 2 * $hoursEntry['kilometerCount'] ?? 0, ]; } else if (!empty($hoursEntry['carId_text']) && $hoursEntry['kilometerCount'] > 0) { $positions[] = [ 'articleTitle' => "Fahrkostenpauschale (hin und retour)", 'articleDescription' => "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Fahrzeug: " . $hoursEntry['carId_text'], 'articleUnit' => 'km', 'amount' => $hoursEntry['kilometerCount'], 'price' => 2 * $hoursEntry['kilometerCount'] ?? 0, ]; } } usort($positions, function ($a, $b) { $aHasMitarbeiter = str_contains($a['articleDescription'], 'Mitarbeiter'); $bHasMitarbeiter = str_contains($b['articleDescription'], 'Mitarbeiter'); $aHasFahrzeug = str_contains($a['articleDescription'], 'Fahrzeug'); $bHasFahrzeug = str_contains($b['articleDescription'], 'Fahrzeug'); if ($aHasMitarbeiter && !$bHasMitarbeiter) return -1; if (!$aHasMitarbeiter && $bHasMitarbeiter) return 1; if ($aHasFahrzeug && !$bHasFahrzeug) return -1; if (!$aHasFahrzeug && $bHasFahrzeug) return 1; return 0; }); $textElements = []; // parse shippingNote.textElements ({"1":true,"2":true}) to array, fetch each text element and put content into array $shippingNoteTextElements = json_decode($shippingNote->textElements, true); foreach ($shippingNoteTextElements as $key => $value) { if ($value) { $textElement = WarehouseShippingNoteTextElementModel::get($key); $textElements[] = $textElement->content; } } if (empty($textElements)) { $textElements = null; } $pdf_vars = ["shippingNote" => $shippingNote, "positions" => $positions, "textElements" => $textElements, "showPrices" => isset($_GET['price']) && $_GET['price'] == "true", "bank_iban" => TT_INVOICE_BANK_IBAN, "bank_bic" => TT_INVOICE_BANK_BIC, "bank_bank" => TT_INVOICE_BANK_BANK, "bank_owner" => TT_INVOICE_BANK_OWNER]; $headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html"); $headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml); $headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $headerHtml); $headerHtml = str_replace("{{ addressLine_2 }}", $shippingNote->deliveryAddressLine, $headerHtml); $headerHtml = str_replace("{{ addressLine_3 }}", $shippingNote->deliveryAddressPLZ . " " . $shippingNote->deliveryAddressCity, $headerHtml); $headerHtml = str_replace("{{ addressLine_4 }}", "", $headerHtml); $headerHtml = str_replace("{{ addressLine_5 }}", "", $headerHtml); $headerHtml = str_replace("{{ billingAddressHeader }}", !isset($address) ? '' : "Rechnungsadresse", $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_1 }}", !isset($address) ? '' : $address->getCompanyOrName(), $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_2 }}", !isset($address) ? '' : $address->street, $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_3 }}", !isset($address) ? '' : $address->zip . " " . $address->city, $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml); $headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml); $headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNote->shippingNoteNumber, $headerHtml); $headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml); $headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html"; file_put_contents($headerFile, $headerHtml); // Replace placeholders in header $footerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_FOOTER.html"); $footerHtml = str_replace("{{ bank_iban }}", TT_INVOICE_BANK_IBAN_FORMATTED, $footerHtml); $footerHtml = str_replace("{{ bank_bic }}", TT_INVOICE_BANK_BIC, $footerHtml); $footerHtml = str_replace("{{ bank_bank }}", TT_INVOICE_BANK_BANK, $footerHtml); $footerHtml = str_replace("{{ bank_owner }}", TT_INVOICE_BANK_OWNER, $footerHtml); $footerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html"; file_put_contents($footerFile, $footerHtml); $pdf = new PdfForm("WarehouseShippingNote/PDF_MAIN", $pdf_vars); $wkhtmltopdfArgs = "--header-html $headerFile --footer-html $footerFile"; $filename = $pdf->render($wkhtmltopdfArgs); if ($returnFilename === true) return $filename; // return the pdf and die so the client sees the pdf not the filename header('Content-Type: application/pdf'); header('Content-Disposition: inline; filename="' . $filename . '"'); readfile($filename); die(); } // TODO: either move this to UserController or make it better protected function userAutoCompleteAction() { $users = array_map(function ($user) { return ['value' => $user->id, 'text' => $user->name]; }, UserModel::search(['employee' => true])); $out = null; $searchedID = $this->request->searchedID; if (strlen($searchedID) > 0) { // find user with value searchedID $out = array_filter($users, function ($user) use ($searchedID) { return $user['value'] == $searchedID; }); } else { $out = array_filter($users, function ($user) { ; return strpos(strtolower($user['text']), strtolower($this->request->q)) !== false; }); $out = array_slice($out, 0, 10); } self::returnJson(array_values($out)); } protected function changeStatusAction() { $json = json_decode(file_get_contents('php://input'), true); if (empty($json['id'])) self::sendError('Lieferschein wurde nicht gefunden'); $shippingNote = (array) WarehouseShippingNoteModel::get($json['id']); if ($shippingNote['status'] === 'invoiced') self::sendError('Status kann nicht geändert werden'); $shippingNote['status'] = $json['status']; WarehouseShippingNoteModel::update($shippingNote); $statusNiceText = [ 'new' => 'Neu', 'in_progress' => 'In Bearbeitung', 'accepted' => 'Akzeptiert', 'invoiced' => 'In Rechnung gestellt', 'cancelled' => 'Storniert', 'on_hold' => 'In Wartestellung', ]; self::returnJson(['success' => true, 'message' => 'Status wurde auf ' . $statusNiceText[$json['status']] . ' geändert']); } //TODO: either move this to TimerecordingCarController or make it better protected function timerecordingCarAutoCompleteAction() { $timerecordingCars = array_map(function ($timerecordingCar) { return ['value' => $timerecordingCar->id, 'text' => $timerecordingCar->number_plate . " " . $timerecordingCar->brand . " " . $timerecordingCar->model]; }, TimerecordingCarModel::getAll()); $out = null; $searchedID = $this->request->searchedID; if (strlen($searchedID) > 0) { // find user with value searchedID $out = array_filter($timerecordingCars, function ($timerecordingCar) use ($searchedID) { return $timerecordingCar['value'] == $searchedID; }); } else { $out = array_filter($timerecordingCars, function ($timerecordingCar) { return strpos(strtolower($timerecordingCar['text']), strtolower($this->request->q)) !== false; }); $out = array_slice($out, 0, 10); } self::returnJson(array_values($out)); } protected function timerecordingCarForUserAction() { $timerecordingCars = TimerecordingCarModel::getAll(); foreach ($timerecordingCars as $timerecordingCar) { if ($timerecordingCar->user_id == $this->request->userId) { header('Content-Type: application/json'); die(json_encode(['success' => true, 'id' => $timerecordingCar->id])); } } die(json_encode(['success' => true, 'status' => 'USER_NO_CAR'])); } /** * Helper function to parse Google Geocoding API response into the format expected by the frontend. * @param array $components The address_components array from Google's response. * @return array The formatted address array. */ private function formatGoogleAddress(array $components): array { $address = [ 'house_number' => null, 'road' => null, 'postcode' => null, 'city' => null, 'town' => null, 'village' => null, 'residential' => null, 'hamlet' => null, ]; foreach ($components as $component) { $types = $component['types']; if (in_array('street_number', $types)) { $address['house_number'] = $component['long_name']; } if (in_array('route', $types)) { $address['road'] = $component['long_name']; } if (in_array('postal_code', $types)) { $address['postcode'] = $component['long_name']; } if (in_array('locality', $types)) { $address['city'] = $component['long_name']; $address['town'] = $component['long_name']; // Town is often the same as city } // A village in Google can be sublocality or sublocality_level_1 if (in_array('sublocality', $types) || in_array('sublocality_level_1', $types)) { $address['village'] = $component['long_name']; } if (in_array('neighborhood', $types)) { $address['residential'] = $component['long_name']; if (!$address['village']) { // Fallback for village if not already set $address['village'] = $component['long_name']; } } } // If city is null, try to find it in administrative areas if (!$address['city']) { foreach ($components as $component) { if (in_array('administrative_area_level_3', $component['types'])) { $address['city'] = $component['long_name']; $address['town'] = $component['long_name']; break; } } } return $address; } protected function geoAutocompleteAction() { $search = urlencode($this->request->q); $apiKey = TT_GEOCODING_API_SECRET; // Bias search results for Austria (AT) in German (de) $url = TT_GEOCODING_API_URL . "?address=$search&key=$apiKey®ion=at&language=de&components=country:AT"; $response = @file_get_contents($url); if ($response === false) { self::returnJson([]); return; } $data = json_decode($response, true); $out = []; if ($data['status'] === 'OK') { foreach ($data['results'] as $entry) { $lat = $entry['geometry']['location']['lat']; $lng = $entry['geometry']['location']['lng']; $text = $entry['formatted_address']; $hasHouseNumber = false; foreach ($entry['address_components'] as $component) { if (in_array('street_number', $component['types'])) { $hasHouseNumber = true; break; } } $value = $lat . "," . $lng . (!$hasHouseNumber ? ", area" : ""); // Deduplication logic from original code $isDuplicate = false; foreach ($out as $existing) { if ($existing['text'] === $text) { $isDuplicate = true; break; } } if (!$isDuplicate) { $out[] = ['value' => $value, 'text' => $text]; } } } self::returnJson($out); } protected function geoReverseAction() { $lat = $this->request->lat; $lon = $this->request->lon; $apiKey = TT_GEOCODING_API_SECRET; $url = TT_GEOCODING_API_URL . "?latlng=$lat,$lon&key=$apiKey&language=de"; $response = @file_get_contents($url); if ($response === false) { self::returnJson(['address' => [], 'error' => 'REQUEST_FAILED', 'error_message' => 'Could not connect to Google Maps API.']); return; } $data = json_decode($response, true); if ($data['status'] === 'OK' && !empty($data['results'])) { $addressComponents = $data['results'][0]['address_components']; $formattedAddress = $this->formatGoogleAddress($addressComponents); self::returnJson(['address' => $formattedAddress]); } else { self::returnJson(['address' => [], 'error' => $data['status'], 'error_message' => $data['error_message'] ?? 'Reverse geocoding failed.']); } } //TODO: export this to an api class for openstreetmap protected function getDistanceAction() { $this->request->to = str_replace("Gebiet ", "", $this->request->to); $filename = TEMP_DIR . "/OpenStreetMap/" . urlencode($this->request->from) . "-" . urlencode($this->request->to) . ".json"; if (file_exists($filename)) { $data = file_get_contents($filename); self::returnJson(json_decode($data, true)); } $from = $this->request->from; $to = $this->request->to; $from = urlencode($from); $to = urlencode($to); function geocode($address) { if ($address === 'Xinon GmbH') { return [['lat' => 46.99555015, 'lon' => 15.77507876755547]]; } $address = urlencode($address); $apiKey = TT_GEOCODING_API_SECRET; // Bias search results for Austria $url = TT_GEOCODING_API_URL . "?address=$address&key=$apiKey®ion=at&components=country:AT"; $response = @file_get_contents($url); if ($response === false) { die(json_encode(['success' => false, 'message' => 'Error while geocoding: API connection failed.'])); } $data = json_decode($response, true); if ($data['status'] === 'OK' && !empty($data['results'])) { $location = $data['results'][0]['geometry']['location']; // The OSRM routing engine expects 'lon', so we map Google's 'lng' to 'lon'. return [['lat' => $location['lat'], 'lon' => $location['lng']]]; } else { die(json_encode(['success' => false, 'message' => 'Error while geocoding: ' . ($data['error_message'] ?? $data['status'])])); } } function route($from, $to) { // The geocode() function (which you've already updated to use Google) // will provide the necessary coordinates. It will `die()` on error, so we can // assume we have valid data if the script continues. $fromData = geocode($from); $toData = geocode($to); $fromLat = $fromData[0]['lat']; $fromLon = $fromData[0]['lon']; $toLat = $toData[0]['lat']; $toLon = $toData[0]['lon']; $apiKey = TT_GEOCODING_API_SECRET; $url = "https://maps.googleapis.com/maps/api/directions/json" . "?origin={$fromLat},{$fromLon}" . "&destination={$toLat},{$toLon}" . "&key={$apiKey}" . "&mode=driving" . "®ion=at"; // Bias results for Austria $response = @file_get_contents($url); if ($response === false) { die(json_encode(['success' => false, 'message' => 'Could not connect to Google Directions API.'])); } $data = json_decode($response, true); // Check for a successful response and if a route was found if ($data['status'] === 'OK' && !empty($data['routes'])) { // The distance is provided in meters in the first "leg" of the first "route" $distance = $data['routes'][0]['legs'][0]['distance']['value']; // Return the round-trip distance return $distance * 2; } else { // Handle cases where no route is found or another API error occurred $errorMessage = $data['error_message'] ?? 'No route could be found between the locations.'; die(json_encode(['success' => false, 'message' => "Routing Error: " . $errorMessage])); } } $distance = route($from, $to); $roundedDistanceKm = round($distance / 1000, 0); if (!file_exists(dirname($filename))) { mkdir(dirname($filename), 0777, true); } file_put_contents($filename, json_encode(['success' => true, 'distance' => $roundedDistanceKm])); self::returnJson(['success' => true, 'distance' => $roundedDistanceKm]); } protected function uploadFileAction() { $file = $_FILES['file'] ?? null; if (!$file || $file['error'] !== UPLOAD_ERR_OK) { self::returnJson(['error' => 'File upload failed']); exit; } try { $uploaded = mfUpload::handleFormUpload("file", false, "/WarehouseShippingNote"); self::returnJson(['success' => true, 'fileId' => $uploaded->id]); } catch (Exception $e) { self::returnJson(['error' => 'Upload error: ' . $e->getMessage()]); } } protected function getRecentCalendarEventsAction() { $worker = UserModel::getOne($this->user->id); if (!$worker || !empty($worker->name)) { die($worker->name); self::returnJson([]); return; } if (strpos($_SERVER['REMOTE_ADDR'], '172.18') === 0) { self::returnJson([]); return; } $calendars = CalendarModel::getAll(); // loop through all calendars and find the one with user_id as the worker's id $calendarId = null; foreach ($calendars as $calendar) { if ($calendar->user_id == $worker->id) { $calendarId = $calendar->go_calendar_id; break; } } $eventsJson = CalendarModel::getCalendarEvents($this->user, 0, 0, true); if (isset($_GET['die_calendar'])) { die($eventsJson); } $allEvents = json_decode($eventsJson, true)['data'] ?? []; if (!is_array($allEvents)) { self::returnJson([]); return; } $endDate = new DateTime(); $startDate = (new DateTime())->modify('-3 days'); $startTimestamp = $startDate->setTime(0, 0, 0)->getTimestamp(); $endTimestamp = $endDate->setTime(23, 59, 59)->getTimestamp(); $filteredEvents = array_filter($allEvents, function ($event) use ($startTimestamp, $endTimestamp, $calendarId) { if (!isset($event['cstart']) && !isset($event['category']) || (intval($event['calendar_id']['calendar_id']) != $calendarId)) { return false; } $eventStartTimestamp = strtotime($event['cstart']['cstart'] ?? $event['cstart']); if (empty($event['location']['location']) || empty($event['category']['category'])) { return false; } if (date('H', $eventStartTimestamp) < 5 || date('H', $eventStartTimestamp) > 21) { return false; } return $eventStartTimestamp >= $startTimestamp && $eventStartTimestamp <= $endTimestamp; }); usort($filteredEvents, function ($a, $b) { $timeA = strtotime($a['cstart']['cstart'] ?? $a['cstart']); $timeB = strtotime($b['cstart']['cstart'] ?? $b['cstart']); return $timeB <=> $timeA; }); $limitedEvents = array_slice($filteredEvents, 0, 20); $finalResponse = array_map(function ($event) { $eventStart = $event['cstart']['cstart'] ?? $event['cstart']; return [ 'date' => date('Y-m-d H:i:s', strtotime($eventStart)), 'location' => $event['location']['location'] ?? '', 'category' => $event['category']['category'] ?? '', 'ccategory' => $event['ccategory']['ccategory'] ?? '', 'event_type' => $event['event_type']['event_type'] ?? '', ]; }, $limitedEvents); self::returnJson($finalResponse); } protected function warehouseLogoutAction() { if (isset($_SESSION[MFAPPNAME . '_warehouse_login_override'])) { unset($_SESSION[MFAPPNAME . '_warehouse_login_override']); self::returnJson(['success' => true, 'message' => 'Logout erfolgreich']); } else { self::returnJson(['success' => false, 'message' => 'Kein aktiver Login gefunden']); } } protected function createNewLogAction() { $postData = json_decode(file_get_contents('php://input'), true); if (empty($postData['shippingNoteId'])) { self::returnJson(['success' => false, 'message' => 'ShippingNoteId fehlt']); return; } $log = [ "table" => "WarehouseShippingNote", "rowId" => intval($postData['shippingNoteId']), "type" => 'noChanges', "fileIds" => $postData['fileIds'] ?? null, "message" => $postData['note'] ?? null, "createBy" => intval($this->user->id), "create" => time() ]; WarehouseLogModel::create($log); self::returnJson(['success' => true, 'message' => 'Logeintrag erfolgreich erstellt']); } protected function getLogAction() { $shippingNoteId = $this->request->shippingNoteId; if (empty($shippingNoteId)) { self::returnJson(['success' => false, 'message' => 'ShippingNoteId fehlt']); return; } $logs = WarehouseLogModel::getAll(['table' => 'WarehouseShippingNote','rowId' => $shippingNoteId], null, 0, ['order' => 'DESC', 'key' => 'create']); self::returnJson($logs); } protected function swAction() { // This script's only job is to unregister the service worker. $javascript = " self.addEventListener('install', event => { // Take control immediately so we can proceed to the 'activate' step. self.skipWaiting(); console.log('Deregistering PWA Service Worker: Installing...'); }); self.addEventListener('activate', event => { console.log('Deregistering PWA Service Worker: Activating...'); // The main command to unregister the service worker. self.registration.unregister() .then(() => { // After unregistering, get a list of all clients (open tabs/windows). return self.clients.matchAll(); }) .then(clients => { // Reload all clients to ensure they are no longer controlled by the SW. clients.forEach(client => client.navigate(client.url)); console.log('Service worker has been successfully deregistered.'); }); });"; header("Content-Type: application/javascript"); header("Service-Worker-Allowed: /"); // Ensure the browser never caches this deregistering script. header("Cache-Control: no-cache, no-store, must-revalidate"); header("Pragma: no-cache"); header("Expires: 0"); echo $javascript; exit; } }