diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..64b936302 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(docker-compose up:*)", + "Bash(python:*)", + "Bash(cat:*)", + "Bash(find:*)", + "Bash(docker-compose exec:*)", + "mcp__sequentialthinking__sequentialthinking" + ] + } +} diff --git a/Layout/default/Cpeprovisioning/Index.php b/Layout/default/Cpeprovisioning/Index.php index 398cdb01a..9e1209544 100644 --- a/Layout/default/Cpeprovisioning/Index.php +++ b/Layout/default/Cpeprovisioning/Index.php @@ -318,6 +318,9 @@ $pagination_entity_name = "Zu provisionierende CPEs"; + diff --git a/Layout/default/Device/Detail.php b/Layout/default/Device/Detail.php index 622a52b66..c4d393830 100644 --- a/Layout/default/Device/Detail.php +++ b/Layout/default/Device/Detail.php @@ -408,7 +408,7 @@ foreach ($devicesall as $deviceall) { success == "true" && $devicesconfig->data > 0) { + if ($devicesconfig->success == "true" && $devicesconfig->data) { ?>
@@ -825,7 +825,7 @@ foreach ($devicesall as $deviceall) { if (data.success == false) { $('#olt-body').text('Keine OLT/ONT Daten verfügbar'); } else { - $('#service-ports-h4').show(); + $('#service-ports-h4').show(); $('#olt-body').append(`
diff --git a/Layout/default/MobileApp/App.php b/Layout/default/MobileApp/App.php new file mode 100644 index 000000000..b60ddf599 --- /dev/null +++ b/Layout/default/MobileApp/App.php @@ -0,0 +1,77 @@ + + + + + + + Xinon Mobile + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
Lädt...
+
+
+
+ + + + + + + + diff --git a/Layout/default/MobileApp/WarehouseStocktake.php b/Layout/default/MobileApp/WarehouseStocktake.php new file mode 100644 index 000000000..6ba94de1a --- /dev/null +++ b/Layout/default/MobileApp/WarehouseStocktake.php @@ -0,0 +1,78 @@ + + + + + + + Lager Inventur + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
Lädt...
+
+
+
+ + + + + + + + diff --git a/Layout/default/Pop/Detail.php b/Layout/default/Pop/Detail.php index 7e498970e..aa661967a 100644 --- a/Layout/default/Pop/Detail.php +++ b/Layout/default/Pop/Detail.php @@ -722,12 +722,12 @@ if (!empty(trim($pops->vlan_ipv6))) $('[data-toggle="popover"]').popover(); }); - + - - - + + + diff --git a/Layout/default/Preorder/Index.php b/Layout/default/Preorder/Index.php index ce132a676..8e3e833d4 100644 --- a/Layout/default/Preorder/Index.php +++ b/Layout/default/Preorder/Index.php @@ -888,7 +888,7 @@ $pagination_entity_name = "Vorbestellungen"; Filter-Vorlagen
OLT
+ + + + +
+ + + +
+
+
+ + diff --git a/Layout/default/WarehouseArticle/LABEL_BULK.php b/Layout/default/WarehouseArticle/LABEL_BULK.php new file mode 100644 index 000000000..b6fa823c7 --- /dev/null +++ b/Layout/default/WarehouseArticle/LABEL_BULK.php @@ -0,0 +1,54 @@ + QRCode::OUTPUT_IMAGE_PNG, + 'scale' => 10, + 'quietzoneSize' => 1, +]); +$qrcode = new QRCode($options); +?> + + + + + + +id . ":" . $article->articleNumber; + $qrCodeBase64 = $qrcode->render($qrData); +?> +
+ + + + + +
+ + + +
articleNumber); ?>
+
title); ?>
+
+
+ + + diff --git a/Layout/default/WarehouseOffer/PDF_MAIN.php b/Layout/default/WarehouseOffer/PDF_MAIN.php index 88888077d..37bde4d2f 100644 --- a/Layout/default/WarehouseOffer/PDF_MAIN.php +++ b/Layout/default/WarehouseOffer/PDF_MAIN.php @@ -61,8 +61,10 @@ if ($includeTax) { } $formattedOfferDate = date("d.m.Y", $offerDate); -$validityDays = isset($validity) ? (int)$validity : 14; -$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $offerDate)); +$validityDays = isset($validity) ? (int)$validity : 31; +// Use versionDate (when this version was created) for validity calculation, fallback to offerDate +$validityBaseDate = isset($versionDate) ? $versionDate : $offerDate; +$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $validityBaseDate)); ?> diff --git a/Layout/default/menu.php b/Layout/default/menu.php index a3dfc4035..82ce59296 100644 --- a/Layout/default/menu.php +++ b/Layout/default/menu.php @@ -184,6 +184,8 @@ can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Bestellwünsche
  • can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Lieferscheine
  • can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Projekte
  • + can("WarehouseAdmin")): ?>
  • "> Inventur
  • + can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
  • "> Lagerbewegung
  • can("WarehouseAdmin")): ?>
  • "> Administration
  • diff --git a/application/ADBNetzgebiet/ADBNetzgebietController.php b/application/ADBNetzgebiet/ADBNetzgebietController.php index c4304dcd3..414546f4a 100644 --- a/application/ADBNetzgebiet/ADBNetzgebietController.php +++ b/application/ADBNetzgebiet/ADBNetzgebietController.php @@ -24,6 +24,9 @@ class ADBNetzgebietController extends mfBaseController { "GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"), "SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"), "HISTORY_URL" => $this::getUrl("ADBNetzgebiet/getHistory"), + "START_RIMO_IMPORT_URL" => $this::getUrl("ADBNetzgebiet/startRimoImport"), + "GET_RIMO_IMPORT_STATUS_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportStatus"), + "GET_RIMO_IMPORT_LOG_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportLog"), "NETWORK_URL" => $this::getUrl("Network/Index"), "NETWORK_CREATE_URL" => $this::getUrl("Network/add"), "CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"), @@ -130,6 +133,193 @@ class ADBNetzgebietController extends mfBaseController { self::returnJson(['success' => true, 'data' => $history]); } + protected function startRimoImportAction(): void { + $id = $_GET['id'] ?? null; + if (empty($id)) { + self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]); + return; + } + + $netzgebiet = ADBNetzgebiet::get($id); + if (!$netzgebiet || !$netzgebiet->id) { + self::returnJson(['success' => false, 'message' => "Netzgebiet not found."]); + return; + } + + if (strpos($netzgebiet->source, 'rimo-') !== 0) { + self::returnJson(['success' => false, 'message' => "This action is only for RIMO-source Netzgebiete."]); + return; + } + + if (empty($netzgebiet->source_id)) { + self::returnJson(['success' => false, 'message' => "Netzgebiet has no Source ID."]); + return; + } + + $safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id); + $importTempDir = TEMP_DIR . "/ADBNetzgebietRimoImport/"; + $logDir = $importTempDir . $safeSourceId; + $logFile = $logDir . "/import.log"; + $lockFile = $logDir . "/import.lock"; + + if (is_dir($importTempDir)) { + foreach (glob($importTempDir . "*") as $dir) { + if (is_dir($dir) && (time() - filemtime($dir)) > 86400) { + // simple cleanup + if (file_exists($dir . "/import.log")) @unlink($dir . "/import.log"); + if (file_exists($dir . "/import.lock")) @unlink($dir . "/import.lock"); + @rmdir($dir); + } + } + } + + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + if (file_exists($lockFile)) { + if ((time() - filemtime($lockFile)) > 3600) { // stale lock for 1h + @unlink($lockFile); + } else { + self::returnJson(['success' => false, 'message' => "Import is already running.", 'status' => 'running']); + return; + } + } + + if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) { + $remaining = 900 - (time() - filemtime($logFile)); + self::returnJson(['success' => false, 'message' => "Please wait before starting another import.", 'status' => 'cooldown', 'remaining' => $remaining]); + return; + } + + touch($lockFile); + + $projectRoot = dirname(dirname(__DIR__)); + $scriptRelativePath = 'scripts/adb-rimo-import/rimo-import.php'; + $scriptFullPath = $projectRoot . '/' . $scriptRelativePath; + + if (!file_exists($scriptFullPath)) { + self::returnJson(['success' => false, 'message' => "Import script not found."]); + return; + } + + $php_executable = "php"; + $command = "$php_executable $scriptRelativePath " . escapeshellarg($netzgebiet->source_id); + + $bgCommand = 'cd ' . escapeshellarg($projectRoot) . ' && ' . $command . ' > ' . escapeshellarg($logFile) . ' 2>&1 & echo $!'; + $pid = shell_exec($bgCommand); + + if(empty($pid) || !is_numeric(trim($pid))) { + self::returnJson(['success' => false, 'message' => "Failed to start background process."]); + return; + } + + file_put_contents($lockFile, trim($pid)); + + self::returnJson(['success' => true, 'message' => 'RIMO import started.']); + } + + protected function getRimoImportStatusAction(): void { + $ids = $this->postData['ids'] ?? []; + if (empty($ids)) { + self::returnJson(['success' => true, 'data' => []]); + return; + } + + $statuses = []; + foreach ($ids as $id) { + $netzgebiet = ADBNetzgebiet::get($id); + if (!$netzgebiet || !$netzgebiet->id || strpos($netzgebiet->source, 'rimo-') !== 0 || empty($netzgebiet->source_id)) { + $statuses[$id] = ['status' => 'not_applicable']; + continue; + } + + $safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id); + $logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId; + $logFile = $logDir . "/import.log"; + $lockFile = $logDir . "/import.lock"; + + if (file_exists($lockFile)) { + $pid = trim(file_get_contents($lockFile)); + // Check if process is still running. posix_getpgid returns false if process does not exist. + if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) { + $statuses[$id] = ['status' => 'running']; + } else { + // Stale lock file, process is gone. + @unlink($lockFile); + // Check for cooldown based on log file from the finished process + if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) { + $statuses[$id] = [ + 'status' => 'cooldown', + 'remaining' => 900 - (time() - filemtime($logFile)) + ]; + } else { + $statuses[$id] = ['status' => 'idle']; + } + } + } elseif (file_exists($logFile) && (time() - filemtime($logFile)) < 900) { + $statuses[$id] = [ + 'status' => 'cooldown', + 'remaining' => 900 - (time() - filemtime($logFile)) + ]; + } else { + $statuses[$id] = ['status' => 'idle']; + } + } + self::returnJson(['success' => true, 'data' => $statuses]); + } + + protected function getRimoImportLogAction(): void { + $id = $_GET['id'] ?? null; + if (empty($id)) { + self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]); + return; + } + + $netzgebiet = ADBNetzgebiet::get($id); + if (!$netzgebiet || !$netzgebiet->id || empty($netzgebiet->source_id)) { + self::returnJson(['success' => false, 'message' => "Netzgebiet not found or not applicable."]); + return; + } + + $safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id); + $logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId; + $logFile = $logDir . "/import.log"; + $lockFile = $logDir . "/import.lock"; + + $logContent = ""; + if (file_exists($logFile)) { + $logContent = file_get_contents($logFile); + } + + $status = 'idle'; + if (file_exists($lockFile)) { + $pid = trim(file_get_contents($lockFile)); + if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) { + $status = 'running'; + } else { + @unlink($lockFile); // Stale lock, process is gone + } + } + + if ($status !== 'running') { + if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) { + $status = 'cooldown'; + } else { + $status = file_exists($logFile) ? 'finished' : 'idle'; + } + } + + self::returnJson([ + 'success' => true, + 'data' => [ + 'log' => $logContent, + 'status' => $status, + 'timestamp' => file_exists($logFile) ? filemtime($logFile) : null + ] + ]); + } + // TODO: Implement RIMO API check protected function checkRimoSourceIdAction(): void { self::returnJson(['success' => false, 'message' => "RIMO API check not available."]); diff --git a/application/Address/AddressController.php b/application/Address/AddressController.php index 655c708e1..d514a785d 100644 --- a/application/Address/AddressController.php +++ b/application/Address/AddressController.php @@ -726,16 +726,24 @@ class AddressController extends mfBaseController { } $xinon_project = new XinonProject(); - $tickets = $xinon_project->searchSupportTickets('', 0, ['pageSize' => 100, - 'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [$address->customer_number]]]])]); + $filterParams = ['pageSize' => 100, + 'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [(string)$address->customer_number]]]])]; + + $tickets = $xinon_project->searchSupportTickets('', 0, $filterParams) ?? []; $shippingNotes = array_map(function ($shippingNote) { $shippingNote->createByName = (new User($shippingNote->createBy))->getAbbrName(); return $shippingNote; }, WarehouseShippingNoteModel::getAll(['billingAddressId' => $address->id])); - Helper::renderVue($this,"AddressTickets", - "Tickets und Lieferscheine von Kunden: " . $address->getCompanyOrName() . '(' . $address->customer_number . ')', ["TICKETS" => $tickets,"SHIPPING_NOTES" => $shippingNotes,"ADDRESS" => $address]); + $customerName = str_replace(["\r", "\n"], ' ', $address->getCompanyOrName()); + Helper::renderVue($this,"AddressTickets", "Tickets und Lieferscheine", [ + "TICKETS" => $tickets, + "SHIPPING_NOTES" => $shippingNotes, + "CUSTOMER_NAME" => $customerName, + "CUSTOMER_NUMBER" => $address->customer_number, + "HIDE_PAGE_TITLE" => true + ]); } protected function sendServicePinAction() { diff --git a/application/AssetManagement/AssetManagementController.php b/application/AssetManagement/AssetManagementController.php index 9db10b068..af710d615 100644 --- a/application/AssetManagement/AssetManagementController.php +++ b/application/AssetManagement/AssetManagementController.php @@ -7,7 +7,7 @@ class AssetManagementController extends TTCrud // Simplified columns for better layout, details are in the 'assetDetails' slot protected array $columns = [ - ['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search']], + ['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]], ['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]], ['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']], ['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']], @@ -42,7 +42,12 @@ class AssetManagementController extends TTCrud $json = json_decode(file_get_contents('php://input'), true); $pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10]; $filters = $json['filters'] ?? []; - $order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC']; + $order = $json['order'] ?? ['key' => 'name', 'order' => 'ASC']; + + // Map virtual column 'assetDetails' to actual 'name' column for sorting + if (isset($order['key']) && $order['key'] === 'assetDetails') { + $order['key'] = 'name'; + } // Fetch paginated assets $assets = AssetManagementModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order); diff --git a/application/Calendar/CalendarModel.php b/application/Calendar/CalendarModel.php index d3fe6f931..d74abce7f 100644 --- a/application/Calendar/CalendarModel.php +++ b/application/Calendar/CalendarModel.php @@ -265,9 +265,10 @@ class CalendarModel continue; } if ($data['all_day_event'] == 1) { - if (in_array("Feiertag", $categories)) { + if (is_array($categories) && in_array("Feiertag", $categories)) { continue; } + $starttime = date("Y-m-d", $data['start_time']); $endtime = date("Y-m-d", $data['end_time']); } else { diff --git a/application/Cpeprovisioning/CpeprovisioningController.php b/application/Cpeprovisioning/CpeprovisioningController.php index f66ed1e8d..a55928198 100644 --- a/application/Cpeprovisioning/CpeprovisioningController.php +++ b/application/Cpeprovisioning/CpeprovisioningController.php @@ -549,23 +549,23 @@ class CpeprovisioningController extends mfBaseController { "ORDER_URL" => $this->getUrl("Order"), "NETWORKS" => NetworkModel::getAll(), "ROUTER_OPTIONS" => [ + ['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'], + ['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'], + ['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'], + ['value' => 'FritzBox 6670 Cable', 'text' => 'FritzBox 6670 Cable (Inet, Phone, IPTV)'], // General Options ['value' => 'eigener Router', 'text' => 'Eigener Router'], ['value' => 'anderes CPE', 'text' => 'Anderes CPE'], - // PPPoE/DHCP Routers - ['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'], - ['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'], - ['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'], - ['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'], - ['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'], - ['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'], - ['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'], // Static Routers ['value' => 'Mikrotik HAP AC', 'text' => 'Mikrotik HAP AC (Inet, IPTV)'], ['value' => 'Mikrotik HEX S', 'text' => 'Mikrotik HEX S (Inet, IPTV)'], ['value' => 'Mikrotik RB3011', 'text' => 'Mikrotik RB3011 (Inet, IPTV)'], - // CMTS Routers + // Legacy ['value' => 'FritzBox 6490 Cable', 'text' => 'FritzBox 6490 Cable (Inet, Phone, IPTV)'], + ['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'], + ['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'], + ['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'], + ['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'], ], "ROUTER_SHIPPING_DATA" => [ "TP-Link Archer C80" => ["weight" => 1, "length" => 35, "width" => 24, "height" => 8], diff --git a/application/ManualInvoice/ManualInvoiceController.php b/application/ManualInvoice/ManualInvoiceController.php index caa91d659..f2225af5e 100644 --- a/application/ManualInvoice/ManualInvoiceController.php +++ b/application/ManualInvoice/ManualInvoiceController.php @@ -208,8 +208,6 @@ class ManualInvoiceController extends TTCrud $post = json_decode(file_get_contents('php://input'), true); $id = $post['id'] ?? null; $recipientEmail = $post['email'] ?? null; - $subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH'; - $bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team'; if (!$id || !$recipientEmail) { self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']); @@ -222,6 +220,19 @@ class ManualInvoiceController extends TTCrud return; } + // Format invoice date for display + $invoiceDateFormatted = date('d.m.Y', $invoice->invoice_date); + + // Set default subject and body with invoice number and date + $defaultSubject = "Ihre Rechnung {$invoice->invoice_number} vom {$invoiceDateFormatted}"; + $defaultBody = "Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung Nr. {$invoice->invoice_number} vom {$invoiceDateFormatted}.\n\nMit freundlichen Grüßen\nIhr XINON Team"; + + $subject = $post['subject'] ?? $defaultSubject; + $bodyText = $post['body'] ?? $defaultBody; + + // Convert literal \n strings to actual newlines (in case frontend sends escaped strings) + $bodyText = str_replace('\n', "\n", $bodyText); + // Generate PDF $pdf_filename = $this->createPDFAction(true); if (!$pdf_filename || !file_exists($pdf_filename)) { @@ -232,19 +243,33 @@ class ManualInvoiceController extends TTCrud $pdfContent = file_get_contents($pdf_filename); // --- HTML Email Generation --- - $logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png'; $logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png'; - $logoToolExists = file_exists($logoToolPath); $logoXinonExists = file_exists($logoXinonPath); - // Construct HTML Body - $html = 'Rechnung'; - $html .= '
    '; + // Construct HTML Body with Outlook compatibility + $html = ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= 'Rechnung'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; - // Logos - $html .= '
    '; - if ($logoToolExists) $html .= 'The Tool'; - if ($logoXinonExists) $html .= 'Xinon'; + // Outlook-safe container table + $html .= ''; + $html .= '
    '; + + // Logo with Outlook-safe sizing + $html .= '
    '; + if ($logoXinonExists) { + $html .= ''; + $html .= 'XINON GmbH'; + $html .= ''; + } $html .= '
    '; $html .= '

    ' . htmlspecialchars($subject) . '

    '; @@ -254,7 +279,9 @@ class ManualInvoiceController extends TTCrud $html .= '
    '; $html .= 'XINON GmbH | www.xinon.at'; - $html .= '
    '; + $html .= '
    '; + $html .= ''; + $html .= ''; $mail = new PHPMailer(true); try { @@ -269,12 +296,11 @@ class ManualInvoiceController extends TTCrud $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = 587; - // Logos - if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); + // Logo embedding if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); $mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice'); - $mail->setFrom('thetool@xinon.at', 'XINON TheTool'); + $mail->setFrom('thetool@xinon.at', 'XINON GmbH - Rechnungswesen'); $customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname); $mail->addAddress($recipientEmail, $customerName); @@ -283,7 +309,10 @@ class ManualInvoiceController extends TTCrud $mail->Body = $html; $mail->AltBody = strip_tags($bodyText); - $mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf'); + // Attachment filename: YYYY-MM-DD_InvoiceNumber_Rechnung.pdf + $invoiceDateFile = date('Y-m-d', $invoice->invoice_date); + $attachmentFilename = "{$invoiceDateFile}_{$invoice->invoice_number}_Rechnung.pdf"; + $mail->addStringAttachment($pdfContent, $attachmentFilename, 'base64', 'application/pdf'); $mail->send(); @@ -349,20 +378,21 @@ class ManualInvoiceController extends TTCrud $data['invoice_date'] = strtotime($data['invoice_date']); } - $data = array_merge([ - 'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(), - 'invoice_date' => $data['invoice_date'] ?? time(), - 'status' => 'erstellt', - 'fibu_payment_skonto' => 0, - 'fibu_payment_skonto_rate' => 0, - 'gesamtrabatt' => 0, - 'total' => 0, - 'total_gross' => 0, - 'create_by' => $me->id, - 'edit_by' => $me->id, - 'create' => time(), - 'edit' => time() - ], $data); + // Always generate invoice number (override any null from frontend) + $data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber(); + $data['invoice_date'] = $data['invoice_date'] ?? time(); + $data['status'] = 'erstellt'; + $data['fibu_payment_skonto'] = $data['fibu_payment_skonto'] ?? 0; + $data['fibu_payment_skonto_rate'] = $data['fibu_payment_skonto_rate'] ?? 0; + $data['gesamtrabatt'] = $data['gesamtrabatt'] ?? 0; + $data['total'] = $data['total'] ?? 0; + $data['total_gross'] = $data['total_gross'] ?? 0; + $data['lock'] = 0; + $data['exported'] = 0; + $data['create_by'] = $me->id; + $data['edit_by'] = $me->id; + $data['create'] = time(); + $data['edit'] = time(); return true; } @@ -389,9 +419,15 @@ class ManualInvoiceController extends TTCrud unset($data['positions']); } - if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') { - $this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden'; - return false; + if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id']))) { + if ($invoice->lock == 1) { + $this->infoMessages['update'] = 'Rechnung ist gesperrt und kann nicht bearbeitet werden'; + return false; + } + if ($invoice->status === 'exportiert') { + $this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden'; + return false; + } } // Convert invoice_date from string to timestamp if needed @@ -626,6 +662,12 @@ class ManualInvoiceController extends TTCrud if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) { self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']); + return; + } + + if ($originalInvoice->lock == 1) { + self::returnJson(['success' => false, 'message' => 'Originalrechnung ist gesperrt und kann nicht gutgeschrieben werden']); + return; } $me = new User(); @@ -673,6 +715,8 @@ class ManualInvoiceController extends TTCrud 'vatgroup_id' => $originalInvoice->vatgroup_id, 'credit_for_invoice_id' => $originalInvoiceId, 'status' => 'erstellt', + 'lock' => 0, + 'exported' => 0, 'create' => time(), 'edit' => time(), 'create_by' => $me->id, @@ -681,6 +725,7 @@ class ManualInvoiceController extends TTCrud if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) { self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']); + return; } foreach ($positions as $pos) { @@ -718,7 +763,11 @@ class ManualInvoiceController extends TTCrud protected function beforeDelete(): bool { if ($id = $this->request->id) { $invoice = ManualInvoiceModel::get($id); - if ($invoice && $invoice->status === 'exported') { + if ($invoice && $invoice->lock == 1) { + $this->infoMessages['delete'] = 'Rechnung ist gesperrt und kann nicht gelöscht werden'; + return false; + } + if ($invoice && ($invoice->status === 'exported' || $invoice->status === 'exportiert')) { $this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden'; return false; } @@ -732,4 +781,49 @@ class ManualInvoiceController extends TTCrud } return true; } + + protected function getArticleVatInfoAction() { + $articleId = $_GET['article_id'] ?? null; + $vatarea = $_GET['vatarea'] ?? 'domestic'; + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Article ID required']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Article not found']); + return; + } + + // Map revenueAccount to vatgroup_id + // revenueAccount 0 = Dienstleistungen = vatgroup_id 2 + // revenueAccount 1 = Handelswaren = vatgroup_id 3 + $vatgroupId = $article->revenueAccount == 0 ? 2 : 3; + + // Get vatrate for this vatgroup and area + $vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]); + + if (!$vatrate) { + self::returnJson(['success' => false, 'message' => 'Vatrate not found for vatgroup ' . $vatgroupId . ' and area ' . $vatarea]); + return; + } + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'title' => $article->title, + 'articleNumber' => $article->articleNumber, + 'description' => $article->description, + 'revenueAccount' => $article->revenueAccount + ], + 'vatgroup_id' => $vatgroupId, + 'fibu_cost_account' => $vatrate->account, + 'fibu_cost_account_legacy' => $vatrate->legacy_account, + 'fibu_taxcode' => $vatrate->taxcode, + 'vatrate' => $vatrate->rate + ]); + } } \ No newline at end of file diff --git a/application/ManualInvoice/ManualInvoiceModel.php b/application/ManualInvoice/ManualInvoiceModel.php index 77408527e..7c6d82560 100644 --- a/application/ManualInvoice/ManualInvoiceModel.php +++ b/application/ManualInvoice/ManualInvoiceModel.php @@ -44,6 +44,8 @@ class ManualInvoiceModel extends TTCrudBaseModel { public ?int $bmd_export_date; public ?int $date_delivered; public string $status; + public int $lock = 0; + public int $exported = 0; public ?int $credit_for_invoice_id; public int $create_by; public int $edit_by; diff --git a/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php b/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php new file mode 100644 index 000000000..a9773471c --- /dev/null +++ b/application/MobileApp/Apps/WarehouseStocktake/WarehouseStocktakeHandler.php @@ -0,0 +1,473 @@ + '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, + ] + ]); + } +} diff --git a/application/MobileApp/MobileAppController.php b/application/MobileApp/MobileAppController.php new file mode 100644 index 000000000..96f62b246 --- /dev/null +++ b/application/MobileApp/MobileAppController.php @@ -0,0 +1,451 @@ +needlogin = false; + + // Try to load user if session exists + $me = mfValuecache::singleton()->get("me"); + if (!$me) { + if (mfLoginController::isLoggedIn()) { + $me = new User(); + $me->loadMe(); + mfValuecache::singleton()->set("me", $me); + } + } + $this->user = $me; + } + + /** + * Main dispatcher + */ + public function indexAction() { + $module = $this->request->module ?? null; + $submodule = $this->request->submodule ?? null; + $endpoint = $this->request->endpoint ?? null; + + // Auth endpoints: /MobileApp/auth/{action} + if (strtolower($module) === 'auth') { + return $this->handleAuth($submodule ?? 'check'); + } + + // API call: /MobileApp/{module}/{submodule}/{endpoint} + if ($module && $submodule && $endpoint) { + return $this->handleApiCall($module, $submodule, $endpoint); + } + + // Everything else: render the main Vue SPA + // The Vue app handles internal routing for /MobileApp, /MobileApp/Lager, /MobileApp/Lager/Inventur, etc. + return $this->renderApp(); + } + + /** + * Render the main Vue SPA + */ + protected function renderApp() { + $this->layout()->setTemplate("MobileApp/App"); + $this->layout()->set("JSGlobals", [ + 'BASE_PATH' => '/MobileApp', + 'USER' => $this->user ? [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'username' => $this->user->username, + ] : null, + 'INITIAL_PATH' => $_SERVER['REQUEST_URI'] ?? '/MobileApp', + ]); + } + + /** + * Handle authentication endpoints + */ + protected function handleAuth($action) { + switch (strtolower($action)) { + case 'login': + return $this->authLogin(); + case 'verify2fa': + return $this->authVerify2FA(); + case 'resend2fa': + return $this->authResend2FA(); + case 'logout': + return $this->authLogout(); + case 'check': + return $this->authCheck(); + default: + self::returnJson(['success' => false, 'error' => 'Unknown auth endpoint'], 404); + } + } + + /** + * POST /MobileApp/auth/login + * + * Step 1 of authentication. If 2FA is required, returns requires2FA: true + * and the frontend should proceed to verify2fa endpoint. + */ + protected function authLogin() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405); + return; + } + + $postData = json_decode(file_get_contents('php://input'), true) ?? []; + $username = $postData['username'] ?? ''; + $password = $postData['password'] ?? ''; + $rememberMe = $postData['rememberMe'] ?? false; + + if (!$username || !$password) { + self::returnJson(['success' => false, 'message' => 'Benutzername und Passwort erforderlich']); + return; + } + + $db = FronkDB::singleton(); + $escapedUsername = $db->escape($username); + + $res = $db->select(MFUSERTABLE, "*", "username='$escapedUsername'"); + if (!$db->num_rows($res)) { + sleep(1); + self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']); + return; + } + + $userRow = $db->fetch_object($res); + + if ($userRow->active == 0) { + self::returnJson(['success' => false, 'message' => 'Benutzer ist deaktiviert']); + return; + } + + $hash = $userRow->password; + $salt = substr($hash, 0, 16); + $passhash = mfLoginController::generatePasswordHash($password, $salt); + + if ($passhash !== $hash) { + sleep(1); + self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']); + return; + } + + // Check if 2FA is required + if ($userRow->twofactor !== "0") { + // Generate and send 2FA code + $twoFactor = new UserTwofactor($userRow->id); + $twoFactor->sendCode(); + + // Store pending auth in session for 2FA verification + $_SESSION['mobileapp_2fa_pending'] = [ + 'user_id' => $userRow->id, + 'username' => $userRow->username, + 'remember_me' => $rememberMe, + 'timestamp' => time() + ]; + + // Determine delivery method for UI feedback + $deliveryMethod = $userRow->twofactor == 1 ? 'email' : 'sms'; + $maskedTarget = $deliveryMethod === 'email' + ? $this->maskEmail($userRow->email) + : $this->maskPhone($userRow->mobile); + + self::returnJson([ + 'success' => false, + 'requires2FA' => true, + 'deliveryMethod' => $deliveryMethod, + 'maskedTarget' => $maskedTarget, + 'message' => 'Verifizierungscode wurde gesendet' + ]); + return; + } + + // No 2FA - complete login directly + $this->completeLogin($userRow, $rememberMe); + } + + /** + * POST /MobileApp/auth/verify2fa + * + * Step 2 of authentication - verify the 2FA code + */ + protected function authVerify2FA() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405); + return; + } + + $postData = json_decode(file_get_contents('php://input'), true) ?? []; + $code = $postData['code'] ?? ''; + + // Check for pending 2FA session + if (!isset($_SESSION['mobileapp_2fa_pending'])) { + self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']); + return; + } + + $pending = $_SESSION['mobileapp_2fa_pending']; + + // Check if pending session is expired (10 minutes max) + if (time() - $pending['timestamp'] > 600) { + unset($_SESSION['mobileapp_2fa_pending']); + self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]); + return; + } + + if (!$code || strlen($code) !== 5) { + self::returnJson(['success' => false, 'message' => 'Bitte gib den 5-stelligen Code ein']); + return; + } + + $db = FronkDB::singleton(); + $userId = intval($pending['user_id']); + + // Get user's 2FA code and timestamp + $res = $db->select(MFUSERTABLE, "twofactorcode, twofactortimestamp, username", "id = {$userId}"); + if (!$db->num_rows($res)) { + unset($_SESSION['mobileapp_2fa_pending']); + self::returnJson(['success' => false, 'message' => 'Benutzer nicht gefunden']); + return; + } + + $userRow = $db->fetch_object($res); + $storedCode = $userRow->twofactorcode; + $codeTimestamp = intval($userRow->twofactortimestamp); + + // Check if code is expired (5 minutes) + if (time() - $codeTimestamp > 300) { + self::returnJson(['success' => false, 'message' => 'Code abgelaufen. Bitte neuen Code anfordern.', 'codeExpired' => true]); + return; + } + + // Verify code + if ($code !== $storedCode) { + sleep(1); // Rate limiting + self::returnJson(['success' => false, 'message' => 'Ungültiger Code']); + return; + } + + // Clear the 2FA code + $twoFactor = new UserTwofactor($userId); + $twoFactor->removeCode(); + + // Clear pending session + unset($_SESSION['mobileapp_2fa_pending']); + + // Get full user row for login completion + $res = $db->select(MFUSERTABLE, "*", "id = {$userId}"); + $userRow = $db->fetch_object($res); + + // Complete login + $this->completeLogin($userRow, $pending['remember_me']); + } + + /** + * POST /MobileApp/auth/resend2fa + * + * Resend the 2FA code + */ + protected function authResend2FA() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405); + return; + } + + // Check for pending 2FA session + if (!isset($_SESSION['mobileapp_2fa_pending'])) { + self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']); + return; + } + + $pending = $_SESSION['mobileapp_2fa_pending']; + + // Check if pending session is expired (10 minutes max) + if (time() - $pending['timestamp'] > 600) { + unset($_SESSION['mobileapp_2fa_pending']); + self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]); + return; + } + + // Resend 2FA code + $twoFactor = new UserTwofactor($pending['user_id']); + $twoFactor->sendCode(); + + self::returnJson([ + 'success' => true, + 'message' => 'Neuer Code wurde gesendet' + ]); + } + + /** + * Complete the login process after password (and optionally 2FA) verification + */ + protected function completeLogin($userRow, $rememberMe) { + $db = FronkDB::singleton(); + + $db->update(MFUSERTABLE, [ + 'ip' => $_SERVER['REMOTE_ADDR'], + 'sessionid' => session_id() + ], "id = {$userRow->id}"); + + $_SESSION[MFAPPNAME . '_username'] = $userRow->username; + $_SESSION[MFAPPNAME . '_ip'] = $_SERVER['REMOTE_ADDR']; + + if ($rememberMe) { + UserToken::generateToken($userRow->id); + } + + $user = new User(); + $user->loadMe(); + + self::returnJson([ + 'success' => true, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + ] + ]); + } + + /** + * Mask email address for privacy (e.g., j***@example.com) + */ + protected function maskEmail($email) { + if (!$email) return '***'; + $parts = explode('@', $email); + if (count($parts) !== 2) return '***'; + $local = $parts[0]; + $domain = $parts[1]; + $masked = strlen($local) > 1 ? $local[0] . str_repeat('*', min(5, strlen($local) - 1)) : '*'; + return $masked . '@' . $domain; + } + + /** + * Mask phone number for privacy (e.g., +43***123) + */ + protected function maskPhone($phone) { + if (!$phone) return '***'; + $phone = preg_replace('/\s+/', '', $phone); + if (strlen($phone) < 6) return '***'; + return substr($phone, 0, 3) . str_repeat('*', strlen($phone) - 6) . substr($phone, -3); + } + + /** + * POST /MobileApp/auth/logout + */ + protected function authLogout() { + mfLoginController::staticLogout(); + self::returnJson(['success' => true]); + } + + /** + * GET /MobileApp/auth/check + */ + protected function authCheck() { + if (mfLoginController::isLoggedIn()) { + $user = new User(); + $user->loadMe(); + + if ($user->id) { + self::returnJson([ + 'authenticated' => true, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + ] + ]); + return; + } + } + + UserToken::checkToken(); + + if (isset($_SESSION[MFAPPNAME . '_username']) && $_SESSION[MFAPPNAME . '_username']) { + $user = new User(); + $user->loadMe(); + + if ($user->id) { + self::returnJson([ + 'authenticated' => true, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + ] + ]); + return; + } + } + + self::returnJson(['authenticated' => false]); + } + + /** + * Handle API calls to module endpoints + * /MobileApp/{module}/{submodule}/{endpoint} + */ + protected function handleApiCall($module, $submodule, $endpoint) { + // Normalize names + $moduleName = ucfirst(strtolower($module)); + $submoduleName = ucfirst(strtolower($submodule)); + + // Check authentication for API calls + if (!$this->user || !$this->user->id) { + self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401); + return; + } + + // Build handler path + $handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php"; + + if (!file_exists($handlerFile)) { + self::returnJson(['success' => false, 'error' => "Module not found: {$moduleName}/{$submoduleName}"], 404); + return; + } + + require_once $handlerFile; + + $handlerClass = "{$submoduleName}Handler"; + + if (!class_exists($handlerClass)) { + self::returnJson(['success' => false, 'error' => "Handler class not found"], 500); + return; + } + + $handler = new $handlerClass($this->request, $this->user, $this); + + // Check permissions + if (!$handler->checkPermission()) { + self::returnJson(['success' => false, 'error' => 'Permission denied'], 403); + return; + } + + // Route to method + $method = $endpoint . 'Action'; + if (method_exists($handler, $method)) { + return $handler->$method(); + } + + if (method_exists($handler, $endpoint)) { + return $handler->$endpoint(); + } + + self::returnJson(['success' => false, 'error' => "Endpoint not found: {$endpoint}"], 404); + } +} diff --git a/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php new file mode 100644 index 000000000..75c1b36f3 --- /dev/null +++ b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php @@ -0,0 +1,443 @@ + 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']); + + $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 + */ + 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 + */ + public function getArticleAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + $articleId = null; + + if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + $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; + } + + $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 + */ + 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 categories + */ + 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 already scanned + */ + 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 scan + */ + 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; + } + + $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; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $db = $this->db(); + + if ($overwrite && $overwriteItemId) { + $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; + $db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}"); + $finalQuantity = $quantity; + + WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'overwrittenItemId' => $overwriteItemId, + ]); + + $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; + } + + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId, + 'overwrittenById' => null + ]); + + if ($existing) { + $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 { + $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; + } + + $stocktake->updateProgress(); + + 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 my scans + */ + 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 + */ + 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(); + + $totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}"); + $totalRow = $totalResult->fetch_assoc(); + $totalScanned = intval($totalRow['count']); + + $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, + ] + ]); + } +} diff --git a/application/MobileApp/Modules/Lager/Movement/MovementHandler.php b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php new file mode 100644 index 000000000..19451ad01 --- /dev/null +++ b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php @@ -0,0 +1,346 @@ + 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 = []; + + foreach ($allLocations as $location) { + $title = strtolower($location->title); + if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') { + $locations[] = [ + 'id' => $location->id, + 'title' => $location->title, + ]; + } + } + + 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; + + 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; + } + + $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 + * GET /MobileApp/Lager/Movement/searchArticles?query=X + */ + public function searchArticlesAction() { + $query = $this->request->query ?? ''; + + $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}%')"; + } else { + 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.', + ]; + } + + 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; + + $categories = WarehouseMovementModel::getReasonCategories($type); + + if ($type && is_array($categories)) { + $items = []; + foreach ($categories as $key => $label) { + $items[] = ['value' => $key, 'text' => $label]; + } + self::returnJson(['success' => true, 'categories' => $items]); + } else { + self::returnJson(['success' => true, 'categories' => $categories]); + } + } + + /** + * 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); + + if (!$articleId || !$locationId) { + self::returnJson(['success' => true, 'currentStock' => 0]); + return; + } + + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0; + + self::returnJson(['success' => true, 'currentStock' => $currentStock]); + } + + /** + * Submit a stock movement + * POST /MobileApp/Lager/Movement/submitMovement + */ + public function submitMovementAction() { + $postData = $this->getPostData(); + + $movementType = $postData['movementType'] ?? ''; + $articleId = intval($postData['articleId'] ?? 0); + $locationId = intval($postData['locationId'] ?? 0); + $quantity = floatval($postData['quantity'] ?? 0); + $reasonCategory = $postData['reasonCategory'] ?? ''; + $note = $postData['note'] ?? null; + + // Validate required fields + if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) { + self::returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']); + return; + } + + if ($articleId <= 0) { + self::returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']); + return; + } + + if ($locationId <= 0) { + self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']); + return; + } + + if ($quantity <= 0) { + self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); + return; + } + + if (empty($reasonCategory)) { + self::returnJson(['success' => false, 'message' => 'Bitte Grund auswählen']); + return; + } + + // Get article info + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $db = $this->db(); + + // Find or create WarehouseItem for this article at this location + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; + $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0; + + // Calculate new quantity based on movement type + // Note: Negative stock is allowed (items can be taken out even if stock is 0) + switch ($movementType) { + case 'IN': + $newQty = $currentQty + $quantity; + break; + case 'OUT': + $newQty = $currentQty - $quantity; + // Negative stock is allowed - no validation needed + break; + case 'ADJUSTMENT': + // For adjustment, quantity is the new absolute value + $newQty = $quantity; + break; + default: + $newQty = $currentQty; + } + + // Update or create WarehouseItem + $warehouseItemId = null; + 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 the movement record + $noteEscaped = $note ? "'" . $db->escape($note) . "'" : "NULL"; + $db->query("INSERT INTO WarehouseMovement + (movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, note, userId, createBy, `create`) + VALUES ('{$movementType}', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, '{$db->escape($reasonCategory)}', {$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}"); + + // Get type label for message + $typeLabels = ['IN' => 'Einbuchung', 'OUT' => 'Ausbuchung', 'ADJUSTMENT' => 'Korrektur']; + $typeLabel = $typeLabels[$movementType] ?? $movementType; + + self::returnJson([ + 'success' => true, + 'message' => "{$typeLabel} erfolgreich: {$quantity} x {$article->title}", + 'movement' => [ + 'id' => $movementId, + 'movementNumber' => $movementNumber, + 'movementType' => $movementType, + 'articleId' => $articleId, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'quantityBefore' => $currentQty, + 'quantityAfter' => $newQty, + ] + ]); + } + + /** + * 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); + + $db = $this->db(); + + $whereClause = "m.userId = {$this->user->id}"; + if ($locationId > 0) { + $whereClause .= " AND m.warehouseLocationId = {$locationId}"; + } + + $result = $db->query("SELECT m.*, wa.articleNumber, wa.title as articleTitle, wa.unit, wl.title as locationTitle + FROM WarehouseMovement m + LEFT JOIN WarehouseArticle wa ON wa.id = m.articleId + LEFT JOIN WarehouseLocation wl ON wl.id = m.warehouseLocationId + WHERE {$whereClause} + ORDER BY m.`create` DESC + LIMIT {$limit}"); + + $movements = []; + while ($row = $result->fetch_assoc()) { + $movements[] = [ + 'id' => intval($row['id']), + 'movementNumber' => $row['movementNumber'], + 'movementType' => $row['movementType'], + 'articleId' => intval($row['articleId']), + 'articleNumber' => $row['articleNumber'], + 'articleTitle' => $row['articleTitle'], + 'unit' => $row['unit'] ?? 'Stk.', + 'locationTitle' => $row['locationTitle'], + 'quantity' => floatval($row['quantity']), + 'quantityBefore' => floatval($row['quantityBefore']), + 'quantityAfter' => floatval($row['quantityAfter']), + 'reasonCategory' => $row['reasonCategory'], + 'note' => $row['note'], + 'create' => date('d.m.Y H:i', $row['create']), + ]; + } + + 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'], + ['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'minus-circle', 'color' => 'red'], + ['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'edit', 'color' => 'yellow'], + ]; + + self::returnJson(['success' => true, 'types' => $types]); + } +} diff --git a/application/MobileApp/Shared/MobileAppBaseHandler.php b/application/MobileApp/Shared/MobileAppBaseHandler.php new file mode 100644 index 000000000..508d77e7d --- /dev/null +++ b/application/MobileApp/Shared/MobileAppBaseHandler.php @@ -0,0 +1,113 @@ +request = $request; + $this->user = $user; + $this->controller = $controller; + } + + /** + * Check if user has required permission + * @return bool + */ + public function checkPermission() { + // If no permission required, allow access + if (!$this->requiredPermission) { + return true; + } + + // If no user, deny access + if (!$this->user || !$this->user->id) { + return false; + } + + // Check permission + return $this->user->can($this->requiredPermission); + } + + /** + * Render the app view + * Override in subclass if custom rendering needed + */ + public function renderView() { + $layout = $this->controller->layout(); + + // Set template + if ($this->viewTemplate) { + $layout->setTemplate($this->viewTemplate); + } else { + $layout->setTemplate("MobileApp/{$this->appName}"); + } + + // Set default JS globals + $layout->set("JSGlobals", $this->getJSGlobals()); + } + + /** + * Get JS globals to pass to frontend + * Override in subclass to add app-specific globals + */ + protected function getJSGlobals() { + $globals = [ + 'BASE_PATH' => '/MobileApp/' . $this->appName, + 'APP_NAME' => $this->appName, + ]; + + if ($this->user && $this->user->id) { + $globals['USER_ID'] = $this->user->id; + $globals['USER_NAME'] = $this->user->name; + } + + return $globals; + } + + /** + * Return JSON response (shorthand) + */ + protected static function returnJson($data, $statusCode = 200) { + mfBaseController::returnJson($data, $statusCode); + } + + /** + * Get POST data from JSON body + */ + protected function getPostData() { + return json_decode(file_get_contents('php://input'), true) ?? []; + } + + /** + * Get database instance + */ + protected function db() { + return FronkDB::singleton(); + } +} diff --git a/application/Pop/PopController.php b/application/Pop/PopController.php index 5bb6f1e4a..e03af32a7 100644 --- a/application/Pop/PopController.php +++ b/application/Pop/PopController.php @@ -17,6 +17,25 @@ class PopController extends mfBaseController } } + private function getMapCategories() + { + $categories = []; + foreach (PopModel::$categoryArray as $id => $cat) { + $categories[] = [ + 'id' => $id, + 'name' => $cat['name'], + 'icon' => 'assets/img/markers/pop_' . $id . '.png', + ]; + } + + $categories[] = [ + 'id' => null, + 'name' => 'Unbekannt', + 'icon' => 'assets/img/markers/pop_unknown.png', + ]; + return $categories; + } + protected function indexAction() { $networks = array_map(function ($network) { @@ -30,7 +49,7 @@ class PopController extends mfBaseController return [ "id" => $pop->id, "name" => $pop->name, - "category" => $pop->category, + "category" => $pop->category ?: 99, "networkArea" => $pop->networks, "location" => $pop->location, "state" => $pop->state, @@ -45,6 +64,8 @@ class PopController extends mfBaseController ]; }, PopModel::getAlladv()); + $categories = $this->getMapCategories(); + $JSGlobals = ["BASE_URL" => self::getUrl(""), "DASHBOARD_URL" => self::getUrl("Dashboard"), "MFAPPNAME" => MFAPPNAME_SLUG, @@ -55,11 +76,20 @@ class PopController extends mfBaseController ], "NETWORKS" => $networks, "POPS" => $pops, + "CATEGORIES" => $categories, "IS_ADMIN" => $this->me->is("Admin"), + "MAPBOX_TOKEN" => TT_MAPBOX_TILE_API_TOKEN, ]; $this->layout()->set("vueViewName", "Pop"); $this->layout()->set("JSGlobals", $JSGlobals); + $this->layout()->set("additionalCSS", [ + "assets/css/leaflet.css", + ]); + $this->layout()->set("additionalJS", [ + "assets/js/leaflet.js", + "assets/js/leaflet.MakiMarkers.js" + ]); $this->layout()->setTemplate("VueViews/Vue"); } @@ -112,6 +142,7 @@ class PopController extends mfBaseController { $network_id = 90; $this->layout()->set("network_id", $network_id); + $this->layout()->set("categories", $this->getMapCategories()); $this->layout()->setTemplate("Pop/Map"); } @@ -258,28 +289,23 @@ class PopController extends mfBaseController $home_id = $this->request->home_id; if (!$fiber_id && !$home_id) { - return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige Faser-ID oder Home-ID'])); + return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige ID'])); } - if ($home_id) { + if ($home_id && !$fiber_id) { $sql = "SELECT id FROM FiberPlanFiber WHERE home_id = '" . $db->escape($home_id) . "' LIMIT 1"; $res = $db->query($sql); if ($db->num_rows($res)) { $row = $db->fetch_array($res); $fiber_id = $row['id']; - } else { - return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Keine Faser für Home-ID gefunden'])); } } $fiber = new FiberPlanFiber($fiber_id); - if (!$fiber->id) { return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Faser nicht gefunden'])); } - $this->log->debug("Lade Faser-Strecke für Faser ID: $fiber_id"); - $details = $fiber->toArray(); $details['customer_cable_type'] = $fiber->customer_cable_type; $details['customer_cable_fiber_nr'] = $fiber->customer_cable_fiber_nr; @@ -293,26 +319,217 @@ class PopController extends mfBaseController if ($fiber->address || $fiber->home_id) { $customerGps = $this->geocodeAddress($fiber->address, $fiber->home_id); - if ($customerGps) { - $details['customer_gps'] = $customerGps; - $this->log->debug("GPS für Kunde gefunden: " . json_encode($customerGps)); - } + if ($customerGps) $details['customer_gps'] = $customerGps; } $debug = []; - $debug['start_fiber'] = [ - 'id' => $fiber->id, - 'fiber_nr_cable' => $fiber->fiber_nr_cable, - 'branch_type' => $fiber->branch_type, - 'branch_cable_nr' => $fiber->branch_cable_nr, - 'branch_fiber_nr' => $fiber->branch_fiber_nr - ]; if ($home_id) { - $this->log->debug("=== MODUS: Rückwärts-Trace (von home_id) ==="); + $cableChain = $this->buildCompleteCableChain($fiber); + if (count($cableChain) > 0) { + $mainCable = $cableChain[0]['cable']; + $mainFiber = $cableChain[0]['fiber']; + $cable_route_data = FiberPlanCableModel::getCableRoute($mainCable->id); + $cable_route_array = []; + foreach ($cable_route_data as $station) $cable_route_array[] = $station['name']; + $allFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null); + $fibersArray = []; + foreach ($allFibers as $f) { + $fibersArray[] = [ + 'id' => $f->id, 'fiber_nr_cable' => $f->fiber_nr_cable, + 'fiber_color' => $f->fiber_color, 'fiber_color_hex' => $f->fiber_color_hex, + 'bundle_nr' => $f->bundle_nr, 'bundle_color' => $f->bundle_color, 'bundle_color_hex' => $f->bundle_color_hex, + 'branch_type' => $f->branch_type, 'branch_cable_nr' => $f->branch_cable_nr, + 'branch_from_location' => $f->branch_from_location, 'branch_fiber_nr' => $f->branch_fiber_nr + ]; + } + + $branchPoints = []; + $sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type + FROM FiberPlanDispatcher WHERE network_id = 90 AND object_type = 4 AND gps_lat IS NOT NULL"; + $res = $db->query($sql); + while ($data = $db->fetch_array($res)) { + $branchPoints[] = ['id' => $data['id'], 'name' => $data['name'], 'gps_lat' => $data['gps_lat'], 'gps_long' => $data['gps_long'], 'object_type' => intval($data['object_type']), 'type' => $data['type']]; + } + + $details['cable_info'] = [ + 'id' => $mainCable->id, 'description' => $mainCable->description, 'fibers' => $fibersArray, + 'diameter' => $mainCable->diameter, 'cable_route_array' => $cable_route_array, + 'cable_route_full' => $cable_route_data, 'coordinates' => $mainCable->coordinates, + 'location' => $mainFiber->location, 'branch_points' => $branchPoints + ]; + + $allCablesForMatching = []; + foreach ($cableChain as $chainItem) { + $c = $chainItem['cable']; + $coords = json_decode($c->coordinates, true); + if ($coords) $allCablesForMatching[] = ['id' => $c->id, 'description' => $c->description, 'coordinates' => $coords]; + } + $sql = "SELECT id, description, coordinates FROM FiberPlanCable WHERE network_id = 90 AND coordinates IS NOT NULL AND coordinates != '' AND coordinates != '[]'"; + $res = $db->query($sql); + while ($c = $db->fetch_array($res)) { + $coords = json_decode($c['coordinates'], true); + if ($coords) { + $exists = false; foreach($allCablesForMatching as $ex) { if($ex['id'] == $c['id']) $exists=true; } + if(!$exists) $allCablesForMatching[] = ['id' => $c['id'], 'description' => $c['description'], 'coordinates' => $coords]; + } + } + $details['all_cables'] = $allCablesForMatching; + } + + if (count($cableChain) > 1) { + $details['branch_path'] = $this->buildBranchPathFromChain($cableChain, 1, $debug); + } + + } else { + $this->log->debug("=== MODUS: Vorwärts-Trace ==="); + + if ($fiber->cable_id) { + $cable = new FiberPlanCable($fiber->cable_id); + if ($cable->id) { + $cable_route_data = FiberPlanCableModel::getCableRoute($cable->id); + $cable_route_array = []; + foreach ($cable_route_data as $station) $cable_route_array[] = $station['name']; + + $branchPoints = []; + $sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type + FROM FiberPlanDispatcher WHERE network_id = 90 AND object_type = 4 AND gps_lat IS NOT NULL"; + $res = $db->query($sql); + while ($data = $db->fetch_array($res)) { + $branchPoints[] = ['id' => $data['id'], 'name' => $data['name'], 'gps_lat' => $data['gps_lat'], 'gps_long' => $data['gps_long'], 'object_type' => intval($data['object_type']), 'type' => $data['type']]; + } + + $allFibers = FiberPlanFiberModel::getByCableAndSheet($cable->id, null); + $fibersArray = []; + foreach ($allFibers as $f) { + $fibersArray[] = [ + 'id' => $f->id, 'fiber_nr_cable' => $f->fiber_nr_cable, 'fiber_color' => $f->fiber_color, 'fiber_color_hex' => $f->fiber_color_hex, + 'bundle_nr' => $f->bundle_nr, 'bundle_color' => $f->bundle_color, 'bundle_color_hex' => $f->bundle_color_hex, + 'branch_type' => $f->branch_type, 'branch_cable_nr' => $f->branch_cable_nr, + 'branch_from_location' => $f->branch_from_location, 'branch_fiber_nr' => $f->branch_fiber_nr + ]; + } + + $details['cable_info'] = [ + 'id' => $cable->id, 'description' => $cable->description, 'fibers' => $fibersArray, + 'diameter' => $cable->diameter, 'cable_route_array' => $cable_route_array, + 'cable_route_full' => $cable_route_data, 'coordinates' => $cable->coordinates, + 'location' => $fiber->location, 'branch_points' => $branchPoints + ]; + } + } + + if ($fiber->branch_type === 'Abzweigkabel' && $fiber->branch_cable_nr) { + $details['branch_path'] = $this->traceBranchPath($fiber, 0, 10, $debug); + } + + $allCablesForMatching = []; + $sql = "SELECT id, description, coordinates FROM FiberPlanCable WHERE network_id = 90 AND coordinates IS NOT NULL AND coordinates != '' AND coordinates != '[]'"; + $res = $db->query($sql); + while ($c = $db->fetch_array($res)) { + $coords = json_decode($c['coordinates'], true); + if ($coords) $allCablesForMatching[] = ['id' => $c['id'], 'description' => $c['description'], 'coordinates' => $coords]; + } + $details['all_cables'] = $allCablesForMatching; + } + + $details['debug'] = $debug; + return mfBaseController::returnJson(mfResponse::Ok(['fiber' => $details])); + } + + protected function getAllFiberPathsForHomeAction() + { + $db = FronkDB::singleton(); + $home_id = $this->request->home_id; + + if (!$home_id) { + return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige Home-ID'])); + } + + $sql = "SELECT id FROM FiberPlanFiber WHERE home_id = '" . $db->escape($home_id) . "'"; + $res = $db->query($sql); + + $fiberIds = []; + if ($db->num_rows($res)) { + while ($row = $db->fetch_array($res)) { + $fiberIds[] = $row['id']; + } + } + + if (empty($fiberIds)) { + return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Keine Fasern für Home-ID gefunden'])); + } + + $globalBranchPoints = []; + $sqlBP = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type + FROM FiberPlanDispatcher + WHERE network_id = 90 AND object_type IN (1,2,3,4) + AND gps_lat IS NOT NULL AND gps_long IS NOT NULL"; + $resBP = $db->query($sqlBP); + if ($db->num_rows($resBP)) { + while ($data = $db->fetch_array($resBP)) { + $globalBranchPoints[] = [ + 'id' => $data['id'], + 'name' => $data['name'], + 'gps_lat' => $data['gps_lat'], + 'gps_long' => $data['gps_long'], + 'object_type' => intval($data['object_type']), + 'type' => $data['type'] + ]; + } + } + + $globalCablesWithCoords = []; + $sqlCables = "SELECT id, description, coordinates + FROM FiberPlanCable + WHERE network_id = 90 + AND coordinates IS NOT NULL + AND coordinates != '' + AND coordinates != '[]'"; + $resCables = $db->query($sqlCables); + while ($cableData = $db->fetch_array($resCables)) { + $coords = json_decode($cableData['coordinates'], true); + if ($coords && is_array($coords) && count($coords) > 0) { + $globalCablesWithCoords[] = [ + 'id' => $cableData['id'], + 'description' => $cableData['description'], + 'coordinates' => $coords + ]; + } + } + + $allPaths = []; + + foreach ($fiberIds as $fiber_id) { + $fiber = new FiberPlanFiber($fiber_id); + if (!$fiber->id) continue; + $details = $fiber->toArray(); + $details['customer_cable_type'] = $fiber->customer_cable_type; + $details['customer_cable_fiber_nr'] = $fiber->customer_cable_fiber_nr; + $details['customer_connector_type'] = $fiber->customer_connector_type; + $details['customer_cable_spec'] = $fiber->customer_cable_spec; + $details['customer_fiber_range'] = $fiber->customer_fiber_range; + $details['bundle_nr'] = $fiber->bundle_nr; + $details['bundle_color'] = $fiber->bundle_color; + $details['bundle_color_hex'] = $fiber->bundle_color_hex; + $details['fiber_nr_bundle'] = $fiber->fiber_nr_bundle; + + if ($fiber->address || $fiber->home_id) { + $customerGps = $this->geocodeAddress($fiber->address, $fiber->home_id); + if ($customerGps) { + $details['customer_gps'] = $customerGps; + } + } + + $debug = []; + $debug['start_fiber'] = [ + 'id' => $fiber->id, + 'fiber_nr_cable' => $fiber->fiber_nr_cable, + 'branch_type' => $fiber->branch_type + ]; $cableChain = $this->buildCompleteCableChain($fiber); - $debug['cable_chain_count'] = count($cableChain); + $debug['cable_chain'] = array_map(function($item) { return [ 'cable_id' => $item['cable']->id, @@ -333,12 +550,17 @@ class PopController extends mfBaseController $cable_route_array[] = $station['name']; } - $allFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null); + $allMainFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null); $fibersArray = []; - foreach ($allFibers as $f) { + foreach ($allMainFibers as $f) { $fibersArray[] = [ 'id' => $f->id, 'fiber_nr_cable' => $f->fiber_nr_cable, + 'fiber_color' => $f->fiber_color, + 'fiber_color_hex' => $f->fiber_color_hex, + 'bundle_nr' => $f->bundle_nr, + 'bundle_color' => $f->bundle_color, + 'bundle_color_hex' => $f->bundle_color_hex, 'branch_type' => $f->branch_type, 'branch_cable_nr' => $f->branch_cable_nr, 'branch_from_location' => $f->branch_from_location, @@ -346,26 +568,6 @@ class PopController extends mfBaseController ]; } - $branchPoints = []; - $sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type - FROM FiberPlanDispatcher - WHERE network_id = 90 AND object_type = 4 - AND gps_lat IS NOT NULL AND gps_long IS NOT NULL"; - $res = $db->query($sql); - - if ($db->num_rows($res)) { - while ($data = $db->fetch_array($res)) { - $branchPoints[] = [ - 'id' => $data['id'], - 'name' => $data['name'], - 'gps_lat' => $data['gps_lat'], - 'gps_long' => $data['gps_long'], - 'object_type' => intval($data['object_type']), - 'type' => $data['type'] - ]; - } - } - $details['cable_info'] = [ 'id' => $mainCable->id, 'description' => $mainCable->description, @@ -375,139 +577,54 @@ class PopController extends mfBaseController 'cable_route_full' => $cable_route_data, 'coordinates' => $mainCable->coordinates, 'location' => $mainFiber->location, - 'branch_points' => $branchPoints + 'branch_points' => $globalBranchPoints ]; + $allCablesForMatching = []; foreach ($cableChain as $chainItem) { - $cable = $chainItem['cable']; - - $coords = $cable->coordinates; + $c = $chainItem['cable']; + $coords = $c->coordinates; if (is_string($coords)) { $coords = json_decode($coords, true); } - if ($coords && is_array($coords) && count($coords) > 0) { $allCablesForMatching[] = [ - 'id' => $cable->id, - 'description' => $cable->description, + 'id' => $c->id, + 'description' => $c->description, 'coordinates' => $coords ]; } } - $sql = "SELECT id, description, coordinates - FROM FiberPlanCable - WHERE network_id = 90 - AND coordinates IS NOT NULL - AND coordinates != '' - AND coordinates != '[]'"; - $res = $db->query($sql); - - while ($cableData = $db->fetch_array($res)) { - $coords = json_decode($cableData['coordinates'], true); - - if ($coords && is_array($coords) && count($coords) > 0) { - $exists = false; - foreach ($allCablesForMatching as $existing) { - if ($existing['id'] == $cableData['id']) { - $exists = true; - break; - } - } - - if (!$exists) { - $allCablesForMatching[] = [ - 'id' => $cableData['id'], - 'description' => $cableData['description'], - 'coordinates' => $coords - ]; + foreach ($globalCablesWithCoords as $gc) { + $exists = false; + foreach ($allCablesForMatching as $existing) { + if ($existing['id'] == $gc['id']) { + $exists = true; + break; } } + if (!$exists) { + $allCablesForMatching[] = $gc; + } } $details['all_cables'] = $allCablesForMatching; - $this->log->debug("Hausanschluss-Matching: " . count($allCablesForMatching) . " Kabel verfügbar"); } if (count($cableChain) > 1) { $details['branch_path'] = $this->buildBranchPathFromChain($cableChain, 1, $debug); } - } else { - $this->log->debug("=== MODUS: Vorwärts-Trace (von fiber_id) ==="); + $details['debug'] = $debug; - if ($fiber->cable_id) { - $cable = new FiberPlanCable($fiber->cable_id); - if ($cable->id) { - $cable_route_data = FiberPlanCableModel::getCableRoute($cable->id); - $cable_route_array = []; - foreach ($cable_route_data as $station) { - $cable_route_array[] = $station['name']; - } - - $branchPoints = []; - $sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type - FROM FiberPlanDispatcher - WHERE network_id = 90 AND object_type = 4 - AND gps_lat IS NOT NULL AND gps_long IS NOT NULL"; - $res = $db->query($sql); - - if ($db->num_rows($res)) { - while ($data = $db->fetch_array($res)) { - $branchPoints[] = [ - 'id' => $data['id'], - 'name' => $data['name'], - 'gps_lat' => $data['gps_lat'], - 'gps_long' => $data['gps_long'], - 'object_type' => intval($data['object_type']), - 'type' => $data['type'] - ]; - } - } - - $allFibers = FiberPlanFiberModel::getByCableAndSheet($cable->id, null); - $fibersArray = []; - foreach ($allFibers as $f) { - $fibersArray[] = [ - 'id' => $f->id, - 'fiber_nr_cable' => $f->fiber_nr_cable, - 'branch_type' => $f->branch_type, - 'branch_cable_nr' => $f->branch_cable_nr, - 'branch_from_location' => $f->branch_from_location, - 'branch_fiber_nr' => $f->branch_fiber_nr - ]; - } - - $details['cable_info'] = [ - 'id' => $cable->id, - 'description' => $cable->description, - 'fibers' => $fibersArray, - 'diameter' => $cable->diameter, - 'cable_route_array' => $cable_route_array, - 'cable_route_full' => $cable_route_data, - 'coordinates' => $cable->coordinates, - 'location' => $fiber->location, - 'branch_points' => $branchPoints - ]; - - if ($cable->cable_route) { - $routeArray = json_decode($cable->cable_route, true); - if (is_array($routeArray)) { - $details['cable_info']['cable_route_array'] = $routeArray; - } - } - } - } - - if ($fiber->branch_type === 'Abzweigkabel' && $fiber->branch_cable_nr) { - $details['branch_path'] = $this->traceBranchPath($fiber, 0, 10, $debug); - } + $allPaths[] = [ + 'fiber' => $details + ]; } - $details['debug'] = $debug; - - return mfBaseController::returnJson(mfResponse::Ok(['fiber' => $details])); + return mfBaseController::returnJson(mfResponse::Ok(['paths' => $allPaths])); } private function buildCompleteCableChain($endFiber) @@ -530,10 +647,13 @@ class PopController extends mfBaseController ]); while ($depth < $maxDepth) { + $currentFiberNr = intval($currentFiber->fiber_nr_cable); + $sql = "SELECT * FROM FiberPlanFiber - WHERE branch_type = 'Abzweigkabel' - AND branch_cable_nr = '" . $db->escape($currentCable->description) . "' - LIMIT 1"; + WHERE branch_type = 'Abzweigkabel' + AND branch_cable_nr = '" . $db->escape($currentCable->description) . "' + AND branch_fiber_nr = $currentFiberNr + LIMIT 1"; $this->log->debug("Depth $depth: Suche Parent-Faser für Kabel: {$currentCable->description}"); @@ -1267,6 +1387,9 @@ class PopController extends mfBaseController case "getFiberPath": return $this->getFiberPathAction(); break; + case "getAllFiberPathsForHome": + return $this->getAllFiberPathsForHomeAction(); + break; case "saveCableFibers": return $this->saveCableFibersAction(); break; @@ -1276,6 +1399,9 @@ class PopController extends mfBaseController case "getNetworkMapData": return $this->getNetworkMapDataAction(); break; + case "getSplicePlanForElement": + return $this->getSplicePlanForElementAction(); + break; default: $return = false; } @@ -1459,7 +1585,7 @@ class PopController extends mfBaseController $cables = []; $cableRes = $db->select( "FiberPlanCable", - "id, description, fibers, diameter, state, coordinates", + "id, description, fibers, diameter, state, coordinates, level, cable_type, status", "network_id=$network_id" ); @@ -1491,7 +1617,10 @@ class PopController extends mfBaseController 'coordinates' => $convertedCoords, 'fibers' => $cableData->fibers, 'diameter' => $cableData->diameter, - 'state' => $cableData->state + 'state' => $cableData->state, + 'level' => $cableData->level, + 'cable_type' => $cableData->cable_type, + 'status' => $cableData->status ]; } } @@ -1648,4 +1777,126 @@ class PopController extends mfBaseController 'customerConnections' => $customerConnections ])); } + + protected function getSplicePlanForElementAction() + { + $id = $this->request->id; + if (!is_numeric($id)) { + return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Invalid ID'])); + } + + $db = FronkDB::singleton(); + + $dispatcherRes = $db->select("FiberPlanDispatcher", "*", "id=$id"); + if (!$db->num_rows($dispatcherRes)) { + return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Verteiler nicht gefunden'])); + } + $dispatcher = $db->fetch_object($dispatcherRes); + $dispatcherName = $dispatcher->description; + + $cableIds = []; + $sql = "SELECT DISTINCT cable_id FROM FiberPlanCableStation WHERE station_type='dispatcher' AND station_id=$id"; + $res = $db->query($sql); + if ($db->num_rows($res)) { + while ($row = $db->fetch_array($res)) { + $cableIds[] = $row['cable_id']; + } + } + + $result = []; + + if (!empty($cableIds)) { + $cableIdsStr = implode(',', $cableIds); + + $cableMap = []; + $cableRes = $db->select("FiberPlanCable", "id, description", "id IN ($cableIdsStr)"); + while ($c = $db->fetch_object($cableRes)) { + $cableMap[$c->id] = $c->description; + } + + $escapedName = $db->escape($dispatcherName); + $sqlFibers = "SELECT * FROM FiberPlanFiber WHERE cable_id IN ($cableIdsStr) AND (branch_from_location = '$escapedName' OR location = '$escapedName')"; + + $fiberRes = $db->query($sqlFibers); + + $rawFibers = []; + $targetCableNames = []; + + while ($fiber = $db->fetch_object($fiberRes)) { + $rawFibers[] = $fiber; + if ($fiber->branch_cable_nr) { + $targetCableNames[$fiber->branch_cable_nr] = true; + } + } + + $targetColorMap = []; + + if (!empty($targetCableNames)) { + $namesList = []; + foreach (array_keys($targetCableNames) as $name) { + $namesList[] = "'" . $db->escape($name) . "'"; + } + $namesStr = implode(',', $namesList); + $targetCablesRes = $db->select("FiberPlanCable", "id, description", "description IN ($namesStr)"); + $targetCableIds = []; + $targetCableIdToName = []; + while ($tc = $db->fetch_object($targetCablesRes)) { + $targetCableIds[] = $tc->id; + $targetCableIdToName[$tc->id] = $tc->description; + } + + if (!empty($targetCableIds)) { + $tcIdsStr = implode(',', $targetCableIds); + $targetFibersRes = $db->select("FiberPlanFiber", "cable_id, fiber_nr_cable, fiber_color, fiber_color_hex", "cable_id IN ($tcIdsStr)"); + while ($tf = $db->fetch_object($targetFibersRes)) { + $cName = $targetCableIdToName[$tf->cable_id] ?? null; + if ($cName) { + $targetColorMap[$cName][$tf->fiber_nr_cable] = [ + 'color' => $tf->fiber_color, + 'hex' => $tf->fiber_color_hex + ]; + } + } + } + } + + foreach ($rawFibers as $fiber) { + $targetColorInfo = null; + if ($fiber->branch_cable_nr && $fiber->branch_fiber_nr) { + $targetColorInfo = $targetColorMap[$fiber->branch_cable_nr][$fiber->branch_fiber_nr] ?? null; + } + + $result[] = [ + 'cable_name' => $cableMap[$fiber->cable_id] ?? 'Unknown', + 'fiber_nr' => $fiber->fiber_nr_cable, + 'fiber_color' => $fiber->fiber_color, + 'fiber_color_hex' => $fiber->fiber_color_hex, + 'bundle_color' => $fiber->bundle_color, + 'bundle_color_hex' => $fiber->bundle_color_hex, + + 'target_cable' => $fiber->branch_cable_nr, + 'target_fiber' => $fiber->branch_fiber_nr, + 'target_fiber_color' => $targetColorInfo['color'] ?? null, + 'target_fiber_color_hex' => $targetColorInfo['hex'] ?? null, + 'target_bundle_color' => $fiber->branch_bundle_color, + 'target_bundle_color_hex' => $fiber->branch_bundle_color_hex, + + 'connector' => $fiber->connector_nr, + 'description' => $fiber->comment, + 'home_id' => $fiber->home_id, + 'address' => $fiber->address ?? null, + 'customer_cable_type' => $fiber->customer_cable_type ?? null, + 'customer_cable_fiber_nr' => $fiber->customer_cable_fiber_nr ?? null, + 'customer_connector_type' => $fiber->customer_connector_type ?? null, + 'customer_cable_spec' => $fiber->customer_cable_spec ?? null, + 'customer_fiber_range' => $fiber->customer_fiber_range ?? null, + ]; + } + } + + return mfBaseController::returnJson(mfResponse::Ok([ + 'dispatcher' => $dispatcher, + 'connections' => $result + ])); + } } \ No newline at end of file diff --git a/application/User/UserController.php b/application/User/UserController.php index 06c1ba42e..eb9f27e23 100644 --- a/application/User/UserController.php +++ b/application/User/UserController.php @@ -12,6 +12,9 @@ class UserController extends mfBaseController { private $me; + // User IDs allowed to manage (add/edit/delete) users + private const ALLOWED_USER_MANAGER_IDS = [2, 5, 9, 6, 89, 145, 24]; + protected function init($request = null) { $this->needlogin = true; @@ -24,6 +27,11 @@ class UserController extends mfBaseController if ($_SERVER['REQUEST_METHOD'] === 'POST') $this->postData = json_decode(file_get_contents('php://input'), true); } + private function canManageUsers(): bool + { + return in_array($this->me->id, self::ALLOWED_USER_MANAGER_IDS); + } + protected function indexAction($request) { if (!$this->isAdmin()) { @@ -32,6 +40,7 @@ class UserController extends mfBaseController Helper::renderVue($this, "User", "Benutzer", [ "IS_ADMIN" => $this->me->isAdmin(), + "CAN_MANAGE_USERS" => $this->canManageUsers(), "USERS" => array_map(fn($user) => [ "username" => $user->username, "name" => $user->name, @@ -53,6 +62,7 @@ class UserController extends mfBaseController protected function formAction() { if (!$this->isAdmin()) $this->redirect("Dashboard"); + if (!$this->canManageUsers()) $this->redirect("User"); $id = $this->request->id; $user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User(); @@ -178,6 +188,7 @@ class UserController extends mfBaseController protected function generateApikeyAction($request) { if (!$this->isAdmin()) $this->redirect("Dashboard"); + if (!$this->canManageUsers()) $this->redirect("User"); $id = $request['id']; if (!is_numeric($id) || $id < 1) { @@ -207,6 +218,11 @@ class UserController extends mfBaseController unset($r->address_id); } + // Only allowed users can create/edit other users + if ($this->isAdmin() && !$this->canManageUsers()) { + self::redirect('User'); + } + if (!$id && !$r->username) self::redirect('User'); $user = new User($id); @@ -569,7 +585,7 @@ class UserController extends mfBaseController } protected function impersonateAction() { - if(!$this->me->isAdmin() || $this->me->address_id != 1) { + if(!$this->me->isAdmin() || $this->me->address_id != 1 || !$this->canManageUsers()) { header("HTTP/1.1 403 Forbidden"); exit; } @@ -590,6 +606,10 @@ class UserController extends mfBaseController protected function sendLoginEmailAction() { + if (!$this->canManageUsers()) { + self::sendError("Keine Berechtigung."); + } + $id = $this->request->id; if (!$id || !is_numeric($id)) { self::sendError("Benutzer-ID fehlt oder ist ungültig."); diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php index e0c5f160e..aa09bdc00 100644 --- a/application/WarehouseArticle/WarehouseArticleController.php +++ b/application/WarehouseArticle/WarehouseArticleController.php @@ -2,7 +2,7 @@ class WarehouseArticleController extends TTCrud { protected string $headerTitle = 'Artikel'; - protected $createText = 'Artikel erstellen'; + protected $createText = false; protected string $singleText = 'Artikel'; protected bool $reopenOnCreate = true; @@ -12,7 +12,7 @@ class WarehouseArticleController extends TTCrud { ['key' => 'articleNumber', 'text' => 'Nr.', 'required' => true], ['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]], ['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']], - ['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => false], + ['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]], ['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false], ['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']], ['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']], @@ -32,10 +32,13 @@ class WarehouseArticleController extends TTCrud { protected array $autocompleteColumns = ['articleNumber', 'title', 'description']; protected array $permissionCheck = ['WarehouseUser']; - protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']]; + protected array $additionalActions = [ + ['key' => 'printLabel','title' => 'Label drucken','class' => 'fas fa-print text-secondary'], + ['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary'] + ]; // @formatter:on - protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true]; + protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_PAGE_TITLE' => true]; protected function prepareCrudConfig() { $categories = array_map(fn($category) => ['value' => $category->id, 'text' => $category->name], WarehouseCategory::getAll()); @@ -50,15 +53,19 @@ class WarehouseArticleController extends TTCrud { $this->additionalJSVariables['WAREHOUSE_ADMIN'] = false; } - protected function beforeCreate() { + protected function beforeCreate($postData): bool { if (!in_array($this->user->id, [2, 5, 6, 145, 14])) self::sendError("Sie haben keine Berechtigung, Artikel zu erstellen."); + + $this->validateArticleNumber($postData); return true; } protected function beforeUpdate($postData): bool { if (!in_array($this->user->id, [2, 5, 6, 145, 14])) self::sendError("Sie haben keine Berechtigung, Artikel zu bearbeiten."); + + $this->validateArticleNumber($postData, $postData['id'] ?? null); (new WarehouseHistoryController)->create($postData, $this->mod); return true; } @@ -81,6 +88,38 @@ class WarehouseArticleController extends TTCrud { self::updateSellPrices($postData['id']); } + /** + * Validate article number for duplicates and correct category prefix + */ + private function validateArticleNumber(array $postData, ?int $excludeId = null): void { + $articleNumber = $postData['articleNumber'] ?? ''; + $categoryId = $postData['category_id'] ?? null; + + if (empty($articleNumber)) { + self::sendError("Artikelnummer ist erforderlich."); + } + + // Check for duplicate article number + $existingArticles = WarehouseArticleModel::getAll(['articleNumber' => $articleNumber]); + foreach ($existingArticles as $existing) { + if ($excludeId === null || $existing->id != $excludeId) { + self::sendError("Artikelnummer '{$articleNumber}' existiert bereits (Artikel ID: {$existing->id})."); + } + } + + // Validate category prefix + if ($categoryId) { + $category = WarehouseCategory::get($categoryId); + if ($category && $category->articleNumberPrefix) { + $expectedPrefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT); + $articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix)); + if ($articlePrefix !== $expectedPrefix) { + self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen."); + } + } + } + } + public static function updateSellPrices(int $id): void { // Added return type hint $a = WarehouseArticleModel::get($id); if (!$a instanceof WarehouseArticleModel) throw new Exception("Invalid article type"); @@ -131,6 +170,41 @@ class WarehouseArticleController extends TTCrud { self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); } + protected function getNextArticleNumberAction() { + $categoryId = intval($this->request->categoryId ?? 0); + if (!$categoryId) self::sendError("Kategorie nicht angegeben"); + + $category = WarehouseCategory::get($categoryId); + if (!$category) self::sendError("Kategorie nicht gefunden"); + if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix"); + + $prefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT); + $db = FronkDB::singleton(); + + // Get all existing article numbers with this prefix, sorted + $result = $db->query("SELECT CAST(articleNumber AS UNSIGNED) as num FROM WarehouseArticle WHERE articleNumber LIKE '{$prefix}%' ORDER BY num ASC"); + $existingNumbers = []; + while ($row = $db->fetch_array($result)) { + $existingNumbers[] = intval($row['num']); + } + + // Start from prefix * 10000 + 1 (e.g., 1800 -> 18000001) + $startNumber = intval($prefix) * 10000 + 1; + $nextNumber = $startNumber; + + // Find first gap + foreach ($existingNumbers as $num) { + if ($num == $nextNumber) { + $nextNumber++; + } else if ($num > $nextNumber) { + // Found a gap + break; + } + } + + self::returnJson(['success' => true, 'articleNumber' => str_pad($nextNumber, 8, '0', STR_PAD_LEFT)]); + } + protected function autocompleteAction() { $textKey = property_exists($this->model, 'name') ? 'name' : 'title'; if (strlen($this->request->searchedID) > 0) { @@ -163,4 +237,55 @@ class WarehouseArticleController extends TTCrud { return ['value' => $item->id, 'text' => $item->$textKey]; }, $data)); } + + protected function printLabelAction() { + $articleId = $this->request->id; + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::sendError("Artikel nicht gefunden", 404); + } + + $pdf_vars = [ + 'articleId' => $article->id, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title + ]; + + $pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars); + $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + + $filename = $pdf->render($wkhtmltopdfArgs); + + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="label-' . $article->articleNumber . '.pdf"'); + readfile($filename); + die(); + } + + protected function printLabelsByCategoryAction() { + $categoryId = intval($this->request->categoryId); + if (!$categoryId) { + self::sendError("Kategorie nicht angegeben", 400); + } + + $articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']); + if (empty($articles)) { + self::sendError("Keine Artikel in dieser Kategorie gefunden", 404); + } + + $pdf_vars = ['articles' => $articles]; + $pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars); + $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + + $filename = $pdf->render($wkhtmltopdfArgs); + + $category = WarehouseCategory::get($categoryId); + $categoryName = $category ? $category->name : 'category-' . $categoryId; + + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"'); + readfile($filename); + die(); + } } diff --git a/application/WarehouseCategory/WarehouseCategory.php b/application/WarehouseCategory/WarehouseCategory.php index aa6447a6f..129b19f18 100644 --- a/application/WarehouseCategory/WarehouseCategory.php +++ b/application/WarehouseCategory/WarehouseCategory.php @@ -3,7 +3,7 @@ class WarehouseCategory extends TTCrudBaseModel { public int $id; public string $name; public string $description; - public ?int $articleNumberPrefix; + public ?string $articleNumberPrefix; public int $create; public int $create_by; public ?int $edit; diff --git a/application/WarehouseCategory/WarehouseCategoryController.php b/application/WarehouseCategory/WarehouseCategoryController.php index d79cba66f..40fe2a231 100644 --- a/application/WarehouseCategory/WarehouseCategoryController.php +++ b/application/WarehouseCategory/WarehouseCategoryController.php @@ -9,20 +9,86 @@ class WarehouseCategoryController extends TTCrud { protected array $columns = [ ['key' => 'name', 'text' => 'Name', 'required' => true,], ['key' => 'description', 'text' => 'Beschreibung', 'required' => true], - ['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => true], + ['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']], ['key' => 'create', 'text' => 'Erstellt am', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], ['key' => 'create_by', 'text' => 'Erstellt von', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]], ]; // @formatter:on - protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']]; + protected array $additionalActions = [ + ['key' => 'printLabels', 'title' => 'Labels drucken', 'class' => 'fas fa-print text-primary'], + ['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary'] + ]; + + protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true]; + + public function printLabelsAction() { + $categoryId = intval($this->request->id); + $articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']); + + if (empty($articles)) { + echo "Keine Artikel in dieser Kategorie."; + die(); + } + + $pdf_vars = [ + 'articles' => $articles + ]; + + $pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars); + $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + + $filename = $pdf->render($wkhtmltopdfArgs); + + $category = WarehouseCategory::get($categoryId); + $categoryName = $category ? $category->name : 'category-' . $categoryId; + + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"'); + readfile($filename); + die(); + } + + protected function beforeCreate(): bool { + $this->postData['articleNumberPrefix'] = $this->getNextFreePrefix(); + return true; + } protected function beforeUpdate($postData): bool { + // Preserve existing prefix - don't allow changes + $existing = WarehouseCategory::get($postData['id']); + if ($existing) { + $this->postData['articleNumberPrefix'] = $existing->articleNumberPrefix; + } (new WarehouseHistoryController)->create($postData, $this->mod); return true; } + private function getNextFreePrefix(): string { + $db = FronkDB::singleton(); + $result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1"); + $row = $db->fetch_array($result); + + if ($row && $row['articleNumberPrefix']) { + $lastPrefix = intval($row['articleNumberPrefix']); + // Skip special ranges (9900+) + if ($lastPrefix >= 9900) { + // Find highest non-special prefix + $result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL AND CAST(articleNumberPrefix AS UNSIGNED) < 9900 ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1"); + $row = $db->fetch_array($result); + $lastPrefix = $row ? intval($row['articleNumberPrefix']) : 1800; + } + $nextPrefix = $lastPrefix + 100; + // Skip 9900+ range + if ($nextPrefix >= 9900) $nextPrefix = 9900; + } else { + $nextPrefix = 1900; + } + + return str_pad($nextPrefix, 4, '0', STR_PAD_LEFT); + } + protected function getHistoryAction() { self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); } diff --git a/application/WarehouseLocation/WarehouseLocationModel.php b/application/WarehouseLocation/WarehouseLocationModel.php index 44eab9211..00a66f571 100644 --- a/application/WarehouseLocation/WarehouseLocationModel.php +++ b/application/WarehouseLocation/WarehouseLocationModel.php @@ -3,7 +3,7 @@ class WarehouseLocationModel extends TTCrudBaseModel { public int $id; public string $title; - public string $description; + public ?string $description = null; public int $assignedTo; public int $createBy; public int $create; diff --git a/application/WarehouseMovement/WarehouseMovementController.php b/application/WarehouseMovement/WarehouseMovementController.php new file mode 100644 index 000000000..df04de7a9 --- /dev/null +++ b/application/WarehouseMovement/WarehouseMovementController.php @@ -0,0 +1,258 @@ + 'movementNumber', 'text' => 'Bewegungs-Nr.', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 10]], + ['key' => 'movementType', 'text' => 'Typ', 'required' => true, + 'modal' => ['type' => 'select', 'items' => []], + 'table' => ['priority' => 9, 'filter' => 'iconSelect', 'filterOptions' => [ + ['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'fas fa-plus-circle text-success'], + ['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'fas fa-minus-circle text-danger'], + ['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'fas fa-edit text-warning'], + ]]], + ['key' => 'articleId', 'text' => 'Artikel', 'required' => true, + 'modal' => ['type' => 'articleSelect'], + 'table' => ['priority' => 8, 'sortable' => false, 'filter' => 'text']], + ['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true, + 'modal' => ['type' => 'select', 'items' => []], + 'table' => ['priority' => 7, 'filter' => 'select']], + ['key' => 'quantity', 'text' => 'Menge', 'required' => true, + 'modal' => ['type' => 'number', 'step' => '0.01', 'min' => '0.01'], + 'table' => ['priority' => 6, 'filter' => false]], + ['key' => 'quantityBefore', 'text' => 'Bestand vorher', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 5, 'filter' => false]], + ['key' => 'quantityAfter', 'text' => 'Bestand nachher', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 4, 'filter' => false]], + ['key' => 'reasonCategory', 'text' => 'Grund', 'required' => true, + 'modal' => ['type' => 'select', 'items' => [], 'dependsOn' => 'movementType'], + 'table' => ['priority' => 3, 'filter' => false]], + ['key' => 'note', 'text' => 'Notiz', 'required' => false, + 'modal' => ['type' => 'textarea'], + 'table' => ['priority' => 2, 'filter' => false]], + ['key' => 'create', 'text' => 'Erstellt', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 1, 'filter' => 'dateRange']], + ]; + + protected array $additionalActions = []; + + protected array $permissionCheck = ['WarehouseUser']; + + protected array $infoMessages = [ + 'create' => 'Lagerbewegung wurde erstellt', + 'update' => 'Lagerbewegung wurde aktualisiert', + 'delete' => 'Lagerbewegung wurde gelöscht', + 'noChanges' => 'Keine Änderungen', + ]; + + public function prepareCrudConfig() { + // Populate movement type dropdown + $movementTypes = [ + ['value' => 'IN', 'text' => 'Einbuchung'], + ['value' => 'OUT', 'text' => 'Ausbuchung'], + ['value' => 'ADJUSTMENT', 'text' => 'Korrektur'], + ]; + + // Populate locations dropdown (Office + Außenlager only) + $allLocations = WarehouseLocationModel::getAll(); + $locations = []; + foreach ($allLocations as $location) { + $title = strtolower($location->title); + if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') { + $locations[] = ['value' => $location->id, 'text' => $location->title]; + } + } + + // Get all reason categories for initial load + $allReasons = WarehouseMovementModel::getReasonCategories(); + $reasonItems = []; + foreach ($allReasons as $type => $categories) { + foreach ($categories as $key => $label) { + $reasonItems[] = ['value' => $key, 'text' => $label, 'group' => $type]; + } + } + + foreach ($this->columns as &$col) { + if ($col['key'] === 'movementType') { + $col['modal']['items'] = $movementTypes; + } + if ($col['key'] === 'warehouseLocationId') { + $col['modal']['items'] = $locations; + $col['table']['filterOptions'] = $locations; + } + if ($col['key'] === 'reasonCategory') { + $col['modal']['items'] = $reasonItems; + } + } + + $this->additionalJSVariables['REASON_CATEGORIES'] = $allReasons; + } + + protected function beforeCreate(): bool { + // Validate required fields + $movementType = $this->postData['movementType'] ?? ''; + $articleId = intval($this->postData['articleId'] ?? 0); + $locationId = intval($this->postData['warehouseLocationId'] ?? 0); + $quantity = floatval($this->postData['quantity'] ?? 0); + + if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) { + $this->returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']); + return false; + } + + if ($articleId <= 0) { + $this->returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']); + return false; + } + + if ($locationId <= 0) { + $this->returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']); + return false; + } + + if ($quantity <= 0) { + $this->returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); + return false; + } + + // Find or create WarehouseItem for this article at this location + $db = FronkDB::singleton(); + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; + $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0; + + // Calculate new quantity based on movement type + // Note: Negative stock is allowed (items can be taken out even if stock is 0) + switch ($movementType) { + case 'IN': + $newQty = $currentQty + $quantity; + break; + case 'OUT': + $newQty = $currentQty - $quantity; + // Negative stock is allowed - no validation needed + break; + case 'ADJUSTMENT': + // For adjustment, quantity is the new absolute value + $newQty = $quantity; + break; + default: + $newQty = $currentQty; + } + + // Store before/after quantities + $this->postData['quantityBefore'] = $currentQty; + $this->postData['quantityAfter'] = $newQty; + $this->postData['userId'] = $this->user->id; + + // Update or create WarehouseItem + if ($warehouseItem) { + $db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}"); + $this->postData['warehouseItemId'] = $warehouseItem->id; + } else { + $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`) + VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")"); + $this->postData['warehouseItemId'] = $db->insert_id(); + } + + return true; + } + + protected function afterCreate($postData) { + // Generate movement number + $movement = WarehouseMovementModel::get($postData['id']); + if ($movement) { + $movementNumber = WarehouseMovementModel::generateMovementNumber(); + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movement->id}"); + } + } + + protected function customRowsHandler($rows) { + return array_map(fn($row) => $this->formatRow((array)$row), $rows); + } + + protected function formatRow($row) { + // Format movement type with badge + $typeLabels = [ + 'IN' => 'Einbuchung', + 'OUT' => 'Ausbuchung', + 'ADJUSTMENT' => 'Korrektur', + ]; + $row['movementType'] = $typeLabels[$row['movementType']] ?? $row['movementType']; + + // Format article + if (!empty($row['articleId'])) { + $article = ArticleModel::get($row['articleId']); + if ($article) { + $row['articleId'] = "{$article->articleNumber}
    {$article->title}"; + } + } + + // Format quantities + $row['quantityBefore'] = $row['quantityBefore'] !== null ? number_format((float)$row['quantityBefore'], 2, ',', '.') : '-'; + $row['quantityAfter'] = $row['quantityAfter'] !== null ? number_format((float)$row['quantityAfter'], 2, ',', '.') : '-'; + $row['quantity'] = number_format((float)$row['quantity'], 2, ',', '.'); + + // Format reason category + $row['reasonCategory'] = WarehouseMovementModel::getReasonCategories()[$row['movementType']][$row['reasonCategory']] ?? $row['reasonCategory']; + + // Format create date + if (!empty($row['create'])) { + $row['create'] = date('d.m.Y H:i', $row['create']); + } + + return $row; + } + + /** + * Get reason categories for a specific movement type + */ + protected function getReasonCategoriesAction() { + $type = $this->request->type ?? null; + $categories = WarehouseMovementModel::getReasonCategories($type); + + if ($type && is_array($categories)) { + $items = []; + foreach ($categories as $key => $label) { + $items[] = ['value' => $key, 'text' => $label]; + } + self::returnJson(['success' => true, 'categories' => $items]); + } else { + self::returnJson(['success' => true, 'categories' => $categories]); + } + } + + /** + * Get current stock for an article at a location + */ + protected function getCurrentStockAction() { + $articleId = intval($this->request->articleId ?? 0); + $locationId = intval($this->request->locationId ?? 0); + + if (!$articleId || !$locationId) { + self::returnJson(['success' => false, 'currentStock' => 0]); + return; + } + + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0; + + self::returnJson(['success' => true, 'currentStock' => $currentStock]); + } +} diff --git a/application/WarehouseMovement/WarehouseMovementModel.php b/application/WarehouseMovement/WarehouseMovementModel.php new file mode 100644 index 000000000..23cfd4407 --- /dev/null +++ b/application/WarehouseMovement/WarehouseMovementModel.php @@ -0,0 +1,137 @@ +query("SELECT movementNumber FROM WarehouseMovement + WHERE movementNumber LIKE '{$prefix}%' + ORDER BY movementNumber DESC LIMIT 1"); + + if ($row = $result->fetch_assoc()) { + $lastNumber = intval(substr($row['movementNumber'], -6)); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT); + } + + /** + * Get reason categories for a movement type + */ + public static function getReasonCategories(?string $type = null): array { + $categories = [ + 'IN' => [ + 'Warenlieferung' => 'Warenlieferung', + 'Rueckgabe' => 'Rückgabe', + 'Gefunden' => 'Gefunden/Inventurdifferenz', + 'UmlagerungEingang' => 'Umlagerung (Eingang)', + 'Erstbestand' => 'Erstbestand', + 'Sonstiges' => 'Sonstiges' + ], + 'OUT' => [ + 'Verbrauch' => 'Verbrauch', + 'Beschaedigung' => 'Beschädigung/Defekt', + 'Verlust' => 'Verlust/Schwund', + 'UmlagerungAusgang' => 'Umlagerung (Ausgang)', + 'Entsorgung' => 'Entsorgung', + 'Sonstiges' => 'Sonstiges' + ], + 'ADJUSTMENT' => [ + 'Inventurkorrektur' => 'Inventurkorrektur', + 'Buchungsfehler' => 'Buchungsfehler', + 'Systemkorrektur' => 'Systemkorrektur', + 'SonstigeKorrektur' => 'Sonstige Korrektur' + ] + ]; + + if ($type && isset($categories[$type])) { + return $categories[$type]; + } + + return $categories; + } + + /** + * Get movement type labels + */ + public static function getMovementTypes(): array { + return [ + 'IN' => 'Einbuchung', + 'OUT' => 'Ausbuchung', + 'ADJUSTMENT' => 'Korrektur' + ]; + } + + /** + * Get article object + */ + public function getArticle(): ?ArticleModel { + return ArticleModel::get($this->articleId); + } + + /** + * Get location object + */ + public function getLocation(): ?WarehouseLocationModel { + return WarehouseLocationModel::get($this->warehouseLocationId); + } + + /** + * Get user who made the movement + */ + public function getUser(): ?UserModel { + return UserModel::get($this->userId); + } + + /** + * Get warehouse item if linked + */ + public function getWarehouseItem(): ?WarehouseItemModel { + if (!$this->warehouseItemId) return null; + return WarehouseItemModel::get($this->warehouseItemId); + } + + /** + * Get formatted movement type label + */ + public function getMovementTypeLabel(): string { + $types = self::getMovementTypes(); + return $types[$this->movementType] ?? $this->movementType; + } + + /** + * Get formatted reason category label + */ + public function getReasonCategoryLabel(): string { + $allCategories = self::getReasonCategories(); + foreach ($allCategories as $typeCategories) { + if (isset($typeCategories[$this->reasonCategory])) { + return $typeCategories[$this->reasonCategory]; + } + } + return $this->reasonCategory; + } +} diff --git a/application/WarehouseOffer/WarehouseOfferController.php b/application/WarehouseOffer/WarehouseOfferController.php index 8658804f4..771e3ae58 100644 --- a/application/WarehouseOffer/WarehouseOfferController.php +++ b/application/WarehouseOffer/WarehouseOfferController.php @@ -56,7 +56,7 @@ class WarehouseOfferController extends TTCrud $this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT); $this->postData['status'] = 'new'; $this->postData['version'] = 1; - $this->postData['validity'] = 14; + $this->postData['validity'] = 31; $this->postData['alternativePositions'] = json_encode([]); return true; } @@ -366,10 +366,13 @@ class WarehouseOfferController extends TTCrud $version = $this->request->version ?? null; $offerData = null; + $versionDate = null; // Date when this version was created (for validity calculation) + if ($version) { $historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version); if ($historyEntry && !empty($historyEntry->data)) { $offerData = json_decode($historyEntry->data); + $versionDate = $historyEntry->create; // Use version creation date } } @@ -377,6 +380,10 @@ class WarehouseOfferController extends TTCrud $offer = WarehouseOfferModel::get($id); if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden'); $offerData = $offer; + + // Get latest history entry for current version's date + $latestHistory = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $offer->version); + $versionDate = $latestHistory ? $latestHistory->create : $offer->create; } @@ -432,11 +439,12 @@ class WarehouseOfferController extends TTCrud "alternativeTotal" => $alternativeTotal, "offerNumber" => $offerData->offerNumber, "offerDate" => $offerData->create, + "versionDate" => $versionDate ?? $offerData->create, // Date for validity calculation "offerEditorName" => $editor ? $editor->name : 'Unbekannt', "includeTax" => true, "vatRate" => 0.20, "offerText" => $offerData->notes ?? '', - "validity" => $offerData->validity ?? 14, + "validity" => $offerData->validity ?? 31, "closingText" => $offerData->closingText ?? '', "bank_iban" => TT_INVOICE_BANK_IBAN, "bank_bic" => TT_INVOICE_BANK_BIC, diff --git a/application/WarehouseStocktake/WarehouseStocktakeController.php b/application/WarehouseStocktake/WarehouseStocktakeController.php new file mode 100644 index 000000000..da775dab8 --- /dev/null +++ b/application/WarehouseStocktake/WarehouseStocktakeController.php @@ -0,0 +1,462 @@ + 'stocktakeNumber', 'text' => 'Inventur-Nr.', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 10]], + ['key' => 'title', 'text' => 'Titel', 'required' => true, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 9]], + ['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true, + 'modal' => ['type' => 'select', 'items' => []], + 'table' => ['priority' => 8, 'filter' => 'select']], + ['key' => 'status', 'text' => 'Status', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 7, 'filter' => 'iconSelect', 'filterOptions' => [ + ['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary'], + ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success'], + ['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger'], + ]]], + ['key' => 'progress', 'text' => 'Fortschritt', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 6, 'sortable' => false, 'filter' => false]], + ['key' => 'startedAt', 'text' => 'Gestartet', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 5, 'filter' => false]], + ['key' => 'description', 'text' => 'Beschreibung', 'required' => false, + 'modal' => ['type' => 'textarea'], + 'table' => false], + ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, + 'modal' => false, + 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], + ]; + + protected array $additionalActions = [ + ['key' => 'startStocktake', 'title' => 'Inventur starten', 'class' => 'fas fa-play text-success'], + ['key' => 'viewProgress', 'title' => 'Fortschritt anzeigen', 'class' => 'fas fa-chart-line text-primary'], + ['key' => 'completeStocktake', 'title' => 'Inventur abschließen', 'class' => 'fas fa-check text-success'], + ['key' => 'applyToStock', 'title' => 'Auf Lager anwenden', 'class' => 'fas fa-boxes text-warning'], + ['key' => 'exportReport', 'title' => 'Excel Export', 'class' => 'fas fa-download text-secondary'], + ['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-secondary'], + ]; + + protected array $additionalJSVariables = []; + + protected array $statusOptions = [ + ['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary', 'color' => 'secondary'], + ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary', 'color' => 'primary'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success', 'color' => 'success'], + ['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger', 'color' => 'danger'], + ]; + + protected array $permissionCheck = ['WarehouseUser']; + + protected array $infoMessages = [ + 'create' => 'Inventur wurde erstellt', + 'update' => 'Inventur wurde aktualisiert', + 'delete' => 'Inventur wurde gelöscht', + 'noChanges' => 'Keine Änderungen', + ]; + + public function prepareCrudConfig() { + // Populate locations dropdown + $locations = array_map(function($location) { + return ['value' => $location->id, 'text' => $location->title]; + }, WarehouseLocationModel::getAll()); + + foreach ($this->columns as &$col) { + if ($col['key'] === 'warehouseLocationId') { + $col['modal']['items'] = $locations; + $col['table']['filterOptions'] = $locations; + } + } + + $this->additionalJSVariables['STATUS_ITEMS'] = $this->statusOptions; + } + + protected function beforeCreate(): bool { + // Set default values + $this->postData['status'] = 'planned'; + $this->postData['totalItems'] = 0; + $this->postData['totalScannedItems'] = 0; + return true; + } + + protected function afterCreate($postData) { + // Generate stocktake number + $stocktake = WarehouseStocktakeModel::get($postData['id']); + if ($stocktake) { + $stocktakeNumber = WarehouseStocktakeModel::generateStocktakeNumber(); + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET stocktakeNumber = '{$stocktakeNumber}' WHERE id = {$stocktake->id}"); + + // Log creation + WarehouseStocktakeLogModel::log($stocktake->id, 'created', null, ['title' => $stocktake->title]); + } + } + + protected function beforeUpdate($postData): bool { + (new WarehouseHistoryController)->create($postData, $this->mod); + return true; + } + + protected function customRowsHandler($rows) { + return array_map(fn($row) => $this->formatRow((array)$row), $rows); + } + + protected function formatRow($row) { + // Keep raw status for frontend conditional logic (don't modify 'status' - table needs raw value for filter) + $row['rawStatus'] = $row['status']; + + // Don't modify warehouseLocationId - table uses items to display the text + // Don't modify status - table uses filterOptions to display + + // Format progress (no filter on this column) + $row['progress'] = "{$row['totalScannedItems']} Artikel gescannt"; + + // Format startedAt (no filter on this column) + if ($row['startedAt']) { + $row['startedAt'] = date('d.m.Y H:i', $row['startedAt']); + } else { + $row['startedAt'] = '-'; + } + + return $row; + } + + /** + * Start a stocktake - changes status to in_progress + */ + protected function startStocktakeAction() { + $id = intval($this->postData['id'] ?? 0); + 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; + } + + if ($stocktake->status !== 'planned') { + self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "Geplant" gestartet werden']); + return; + } + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET + status = 'in_progress', + startedAt = " . time() . ", + startedBy = {$this->user->id} + WHERE id = {$id}"); + + WarehouseStocktakeLogModel::log($id, 'started', null, ['startedBy' => $this->user->name]); + + self::returnJson(['success' => true, 'message' => 'Inventur wurde gestartet']); + } + + /** + * Complete a stocktake - changes status to completed + */ + protected function completeStocktakeAction() { + $id = intval($this->postData['id'] ?? 0); + 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; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "In Bearbeitung" abgeschlossen werden']); + return; + } + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET + status = 'completed', + completedAt = " . time() . ", + completedBy = {$this->user->id} + WHERE id = {$id}"); + + WarehouseStocktakeLogModel::log($id, 'completed', null, ['completedBy' => $this->user->name]); + + self::returnJson(['success' => true, 'message' => 'Inventur wurde abgeschlossen']); + } + + /** + * Get progress data for live updates + */ + protected function getProgressAction() { + $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; + } + + // Get items via direct SQL to avoid any ORM issues + $db = FronkDB::singleton(); + $result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName, + CASE WHEN si.overwrittenById IS NOT NULL THEN 1 ELSE 0 END as isOverwritten + FROM WarehouseStocktakeItem si + LEFT JOIN WarehouseArticle a ON si.articleId = a.id + LEFT JOIN Worker w ON si.scannedBy = w.id + WHERE si.stocktakeId = {$id} + ORDER BY si.`create` DESC"); + + $formattedItems = []; + $totalValue = 0; + $totalQuantity = 0; + while ($row = $result->fetch_assoc()) { + $unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0; + $quantity = (float)$row['countedQuantity']; + $lineTotal = $unitPrice * $quantity; + $isOverwritten = (bool)$row['isOverwritten']; + + // Only count non-overwritten items in totals + if (!$isOverwritten) { + $totalValue += $lineTotal; + $totalQuantity += $quantity; + } + + $formattedItems[] = [ + 'id' => (int)$row['id'], + 'articleId' => (int)$row['articleId'], + 'articleNumber' => $row['articleNumber'] ?? '', + 'articleTitle' => $row['articleTitle'] ?? 'Unbekannt', + 'countedQuantity' => $quantity, + 'unitPrice' => $unitPrice, + 'lineTotal' => $lineTotal, + 'rack' => $row['rack'], + 'shelf' => $row['shelf'], + 'note' => $row['note'], + 'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null, + 'scannedBy' => $row['scannedByName'], + 'isOverwritten' => $isOverwritten, + ]; + } + + $location = $stocktake->getLocation(); + + self::returnJson([ + 'success' => true, + 'stocktake' => [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'status' => $stocktake->status, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ], + 'items' => $formattedItems, + 'summary' => [ + 'totalValue' => $totalValue, + 'totalQuantity' => $totalQuantity, + ], + ]); + } + + /** + * Apply stocktake results to actual warehouse stock + */ + protected function applyToStockAction() { + $id = intval($this->postData['id'] ?? 0); + 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; + } + + if ($stocktake->status !== 'completed') { + self::returnJson(['success' => false, 'message' => 'Inventur muss abgeschlossen sein, um die Bestände anzupassen']); + return; + } + + $db = FronkDB::singleton(); + $items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]); + $appliedCount = 0; + $createdCount = 0; + + foreach ($items as $item) { + // Check if a WarehouseItem already exists for this article at this location + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $item->articleId, + 'warehouseLocationId' => $stocktake->warehouseLocationId + ]); + + if (count($existingItems) > 0) { + // Update existing item + $existingItem = $existingItems[0]; + $oldQuantity = $existingItem->quantity; + + $db->query("UPDATE WarehouseItem SET + quantity = {$item->countedQuantity}, + rack = " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ", + shelf = " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . " + WHERE id = {$existingItem->id}"); + + // Log history + (new WarehouseHistoryController)->create([ + 'id' => $existingItem->id, + 'quantity' => $item->countedQuantity, + 'rack' => $item->rack, + 'shelf' => $item->shelf, + ], 'WarehouseItem'); + + $appliedCount++; + } else { + // Create new WarehouseItem + $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, rack, shelf, createBy, `create`) + VALUES ({$item->articleId}, {$stocktake->warehouseLocationId}, {$item->countedQuantity}, + " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ", + " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . ", + {$this->user->id}, " . time() . ")"); + + $createdCount++; + } + } + + WarehouseStocktakeLogModel::log($id, 'applied_to_stock', null, [ + 'appliedCount' => $appliedCount, + 'createdCount' => $createdCount, + 'appliedBy' => $this->user->name + ]); + + self::returnJson([ + 'success' => true, + 'message' => "Bestände angepasst: {$appliedCount} aktualisiert, {$createdCount} neu erstellt" + ]); + } + + /** + * Export stocktake report to Excel + */ + protected function exportReportAction() { + $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; + } + + // Get items via direct SQL to include price and overwritten status + $db = FronkDB::singleton(); + $result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName + FROM WarehouseStocktakeItem si + LEFT JOIN WarehouseArticle a ON si.articleId = a.id + LEFT JOIN Worker w ON si.scannedBy = w.id + WHERE si.stocktakeId = {$id} + ORDER BY si.`create` ASC"); + + $rows = []; + $totalSum = 0; + + while ($row = $result->fetch_assoc()) { + $unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0; + $quantity = (float)$row['countedQuantity']; + $lineTotal = $unitPrice * $quantity; + $isOverwritten = !empty($row['overwrittenById']); + + // Skip overwritten items in calculation but show them + if (!$isOverwritten) { + $totalSum += $lineTotal; + } + + $rows[] = [ + 'Artikel Titel' => $row['articleTitle'] ?? 'Unbekannt', + 'Artikel Nummer' => $row['articleNumber'] ?? '', + 'Einzelpreis' => number_format($unitPrice, 2, ',', '.') . ' €', + 'Anzahl' => $quantity, + 'Gesamtsumme' => number_format($lineTotal, 2, ',', '.') . ' €', + 'Gescannt am' => $row['scannedAt'] ? date('d.m.Y H:i', $row['scannedAt']) : '', + 'Gescannt von' => $row['scannedByName'] ?? '', + 'Status' => $isOverwritten ? 'Überschrieben' : '', + ]; + } + + // Add summary row + $rows[] = [ + 'Artikel Titel' => '', + 'Artikel Nummer' => '', + 'Einzelpreis' => '', + 'Anzahl' => 'SUMME:', + 'Gesamtsumme' => number_format($totalSum, 2, ',', '.') . ' €', + 'Gescannt am' => '', + 'Gescannt von' => '', + 'Status' => '', + ]; + + $filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv"; + $csv = Helper::arrayToCsv($rows); + + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + echo "\xEF\xBB\xBF"; // UTF-8 BOM + echo $csv; + exit; + } + + /** + * Get history for a stocktake + */ + protected function getHistoryAction() { + $this->prepareCrudConfig(); + self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); + } + + /** + * Get logs for a stocktake + */ + protected function getLogsAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $logs = WarehouseStocktakeLogModel::getLogsForStocktake($id); + $formattedLogs = []; + + foreach ($logs as $log) { + $user = UserModel::get($log->userId); + $formattedLogs[] = [ + 'id' => $log->id, + 'action' => $log->action, + 'details' => $log->details ? json_decode($log->details, true) : null, + 'userName' => $user ? $user->name : 'Unbekannt', + 'create' => date('d.m.Y H:i:s', $log->create), + ]; + } + + self::returnJson(['success' => true, 'logs' => $formattedLogs]); + } +} diff --git a/application/WarehouseStocktake/WarehouseStocktakeModel.php b/application/WarehouseStocktake/WarehouseStocktakeModel.php new file mode 100644 index 000000000..c36c8c071 --- /dev/null +++ b/application/WarehouseStocktake/WarehouseStocktakeModel.php @@ -0,0 +1,74 @@ +query("SELECT stocktakeNumber FROM WarehouseStocktake + WHERE stocktakeNumber LIKE '{$prefix}%' + ORDER BY stocktakeNumber DESC LIMIT 1"); + + if ($row = $result->fetch_assoc()) { + $lastNumber = intval(substr($row['stocktakeNumber'], -6)); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT); + } + + /** + * Get location object + */ + public function getLocation(): ?WarehouseLocationModel { + return WarehouseLocationModel::get($this->warehouseLocationId); + } + + /** + * Get user who started the stocktake + */ + public function getStartedByUser(): ?UserModel { + if (!$this->startedBy) return null; + return UserModel::get($this->startedBy); + } + + /** + * Get items for this stocktake + */ + public function getItems(): array { + return WarehouseStocktakeItemModel::getAll(['stocktakeId' => $this->id]); + } + + /** + * Update progress counters + */ + public function updateProgress(): void { + $items = $this->getItems(); + $this->totalScannedItems = count($items); + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET totalScannedItems = {$this->totalScannedItems} WHERE id = {$this->id}"); + } +} diff --git a/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php b/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php new file mode 100644 index 000000000..e6dd8ac6e --- /dev/null +++ b/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php @@ -0,0 +1,195 @@ + 'articleId', 'text' => 'Artikel', 'required' => true, + 'modal' => ['type' => 'autocomplete', 'apiUrl' => '/WarehouseArticle/autocomplete'], + 'table' => ['priority' => 10]], + ['key' => 'countedQuantity', 'text' => 'Menge', 'required' => true, + 'modal' => ['type' => 'number'], + 'table' => ['priority' => 9]], + ['key' => 'rack', 'text' => 'Regal', 'required' => false, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 8]], + ['key' => 'shelf', 'text' => 'Fach', 'required' => false, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 7]], + ['key' => 'note', 'text' => 'Notiz', 'required' => false, + 'modal' => ['type' => 'textarea'], + 'table' => ['priority' => 6]], + ['key' => 'scannedAt', 'text' => 'Gescannt am', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 5]], + ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, + 'modal' => false, + 'table' => ['filter' => false, 'sortable' => false]], + ]; + + protected array $permissionCheck = ['WarehouseUser']; + + protected function formatRow($row) { + // Format article + if ($row['articleId']) { + $article = WarehouseArticleModel::get($row['articleId']); + $row['articleId'] = $article ? "[{$article->articleNumber}] {$article->title}" : 'Unbekannt'; + } + + // Format scannedAt + if ($row['scannedAt']) { + $row['scannedAt'] = date('d.m.Y H:i', $row['scannedAt']); + } else { + $row['scannedAt'] = '-'; + } + + return $row; + } + + /** + * Add item via scan (used by PWA) + */ + protected function scanItemAction() { + $stocktakeId = intval($this->request->stocktakeId); + $articleId = intval($this->request->articleId); + $quantity = floatval($this->request->quantity); + $rack = $this->request->rack ?? null; + $shelf = $this->request->shelf ?? null; + $note = $this->request->note ?? null; + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + 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; + } + + // Check if this article was already scanned in this stocktake + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId + ]); + + $db = FronkDB::singleton(); + + 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->me->id} + WHERE id = {$existing->id}"); + + $itemId = $existing->id; + $message = "Artikel aktualisiert: {$article->title} (Neue Menge: {$newQuantity})"; + } 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->me->id}, {$this->me->id}, " . time() . ")"); + + $itemId = $db->insert_id; + $message = "Artikel hinzugefügt: {$article->title} (Menge: {$quantity})"; + } + + // Update stocktake progress + $stocktake->updateProgress(); + + // Log the scan + WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'rack' => $rack, + 'shelf' => $shelf, + ]); + + self::returnJson([ + 'success' => true, + 'message' => $message, + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $existing ? ($existing->countedQuantity + $quantity) : $quantity, + 'rack' => $rack, + 'shelf' => $shelf, + ], + 'totalScanned' => $stocktake->totalScannedItems + 1, + ]); + } + + /** + * Get article info by QR code or article number + */ + protected function getArticleByCodeAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + // Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article) + // Also accept WH: for backwards compatibility + $articleId = null; + 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, + 'description' => $article->description ?? '', + 'unit' => $article->unit ?? 'Stk.', + ] + ]); + } +} diff --git a/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php b/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php new file mode 100644 index 000000000..ea289249d --- /dev/null +++ b/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php @@ -0,0 +1,39 @@ +articleId); + } + + /** + * Get the stocktake object + */ + public function getStocktake(): ?WarehouseStocktakeModel { + return WarehouseStocktakeModel::get($this->stocktakeId); + } + + /** + * Get user who scanned this item + */ + public function getScannedByUser(): ?User { + if (!$this->scannedBy) return null; + return UserModel::getOne($this->scannedBy); + } +} diff --git a/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php b/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php new file mode 100644 index 000000000..2a09602c6 --- /dev/null +++ b/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php @@ -0,0 +1,43 @@ +get("me"); + $logUserId = $userId ?? ($me ? $me->id : 0); + + $log = new self(); + $log->stocktakeId = $stocktakeId; + $log->stocktakeItemId = $stocktakeItemId; + $log->action = $action; + $log->details = $details ? json_encode($details) : null; + $log->userId = $logUserId; + $log->create = time(); + + $db = FronkDB::singleton(); + $db->query("INSERT INTO WarehouseStocktakeLog (stocktakeId, stocktakeItemId, action, details, userId, `create`) + VALUES ({$log->stocktakeId}, " . ($log->stocktakeItemId ? $log->stocktakeItemId : "NULL") . ", + '{$db->escape($log->action)}', " . ($log->details ? "'{$db->escape($log->details)}'" : "NULL") . ", + {$log->userId}, {$log->create})"); + + $log->id = $db->insert_id; + return $log; + } + + /** + * Get logs for a stocktake + */ + public static function getLogsForStocktake(int $stocktakeId): array { + return self::getAll(['stocktakeId' => $stocktakeId], 0, 0, ['create' => 'DESC']); + } +} diff --git a/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php b/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php new file mode 100644 index 000000000..64a8b2e1b --- /dev/null +++ b/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php @@ -0,0 +1,494 @@ +needlogin = true; + + $me = mfValuecache::singleton()->get("me"); + if (!$me) { + $me = new User(); + $me->loadMe(); + mfValuecache::singleton()->set("me", $me); + } + $this->me = $me; + $this->user = $me; + $this->layout()->set("me", $me); + + // Check permission + if (!$me->can('WarehouseUser')) { + $this->redirect("Dashboard"); + } + } + + /** + * Main PWA View + */ + public function indexAction() { + $this->layout()->setTemplate("VueViews/WarehouseStocktakePWA"); + $this->layout()->set("JSGlobals", [ + 'BASE_PATH' => '/WarehouseStocktakePWA', + 'USER_ID' => $this->user->id, + 'USER_NAME' => $this->user->name, + ]); + } + + /** + * Logout + */ + protected function logoutAction() { + mfLoginController::staticLogout(); + $this->redirect('/WarehouseStocktakePWA'); + } + + /** + * Get active stocktakes that user can participate in + */ + protected 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 + */ + protected 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 + */ + protected 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 + */ + protected function searchArticlesAction() { + $query = $this->request->query ?? ''; + $categoryId = intval($this->request->categoryId ?? 0); + + $db = FronkDB::singleton(); + $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 + */ + protected function getCategoriesAction() { + $db = FronkDB::singleton(); + $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 + */ + protected 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 = FronkDB::singleton(); + $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 + */ + protected function submitScanAction() { + $postData = json_decode(file_get_contents('php://input'), true) ?? []; + + $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 = FronkDB::singleton(); + + // 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; + $isOverwrite = true; + + // Log the overwrite + WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'overwrittenItemId' => $overwriteItemId, + ]); + + // Update stocktake progress (don't increase count since we're replacing) + $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 + */ + protected function getMyScansAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $db = FronkDB::singleton(); + $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 + */ + protected 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 = FronkDB::singleton(); + + // 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, + ] + ]); + } +} diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php index 4ef7f3c82..fcfce3a08 100644 --- a/application/WorkorderBase/WorkorderBaseController.php +++ b/application/WorkorderBase/WorkorderBaseController.php @@ -161,7 +161,8 @@ class WorkorderBaseController extends TTCrud $networks = NetworkModel::search(['owner_id' => $config->addressId]); if (empty($networks)) continue; - $tenantCampaigns = array_map(fn($n) => $n->id, PreordercampaignModel::getAll(['network_id' => array_map(fn($n) => $n->id, $networks)])); + $networkIds = array_map(fn($n) => $n->id, $networks); + $tenantCampaigns = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds])); if (empty($tenantCampaigns)) continue; $filters['preordercampaign_id'] = $tenantCampaigns; @@ -228,22 +229,25 @@ class WorkorderBaseController extends TTCrud continue; } - $tenantCampaignIds = array_column(PreordercampaignModel::getAll(['network_id' => array_column($networks, 'id')]), 'id'); + $networkIds = array_map(fn($n) => $n->id, $networks); + $tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds])); if (empty($tenantCampaignIds)) { continue; } $activeFilters['preordercampaign_id'] = $tenantCampaignIds; - $activePreorderIds = array_column(PreorderModel::searchActive($activeFilters), 'id'); + $activePreorderIds = array_map(fn($p) => $p->id, PreorderModel::searchActive($activeFilters)); $activePreorderIdsSet = array_flip($activePreorderIds); $statusesToCheck = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved']; - $allTenantPreorders = PreorderModel::getAll(['preordercampaign_id' => $tenantCampaignIds]); + // Get ALL preorders for tenant (including deleted/cancelled) to ensure their workorders get archived + // Note: Not passing 'deleted' filter means all preorders are returned regardless of deleted status + $allTenantPreorders = PreorderModel::search(['preordercampaign_id' => $tenantCampaignIds]); if(empty($allTenantPreorders)) continue; - $allTenantPreorderIds = array_column($allTenantPreorders, 'id'); + $allTenantPreorderIds = array_map(fn($p) => $p->id, $allTenantPreorders); $workordersToCheck = WorkorderModel::getAll([ 'status' => $statusesToCheck, diff --git a/db/migrations/20251215120000_create_warehouse_stocktake_tables.php b/db/migrations/20251215120000_create_warehouse_stocktake_tables.php new file mode 100644 index 000000000..c8e6f6faa --- /dev/null +++ b/db/migrations/20251215120000_create_warehouse_stocktake_tables.php @@ -0,0 +1,70 @@ +getEnvironment() == "thetool") { + // 1. Main Stocktake Session Table + $stocktake = $this->table('WarehouseStocktake'); + $stocktake->addColumn('stocktakeNumber', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('title', 'string', ['limit' => 255]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('warehouseLocationId', 'integer', ['signed' => true]) + ->addColumn('status', 'enum', ['values' => ['planned', 'in_progress', 'completed', 'cancelled'], 'default' => 'planned']) + ->addColumn('startedAt', 'integer', ['null' => true]) + ->addColumn('completedAt', 'integer', ['null' => true]) + ->addColumn('startedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('completedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('totalItems', 'integer', ['default' => 0]) + ->addColumn('totalScannedItems', 'integer', ['default' => 0]) + ->addColumn('notes', 'text', ['null' => true]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeNumber'], ['unique' => true]) + ->addIndex(['status']) + ->addIndex(['warehouseLocationId']) + ->create(); + + // 2. Individual Stocktake Items + $stocktakeItem = $this->table('WarehouseStocktakeItem'); + $stocktakeItem->addColumn('stocktakeId', 'integer', ['signed' => true]) + ->addColumn('articleId', 'integer', ['signed' => false]) + ->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('countedQuantity', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => 0]) + ->addColumn('rack', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('shelf', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('note', 'text', ['null' => true]) + ->addColumn('scannedAt', 'integer', ['null' => true]) + ->addColumn('scannedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeId']) + ->addIndex(['articleId']) + ->create(); + + // 3. Activity Log + $stocktakeLog = $this->table('WarehouseStocktakeLog'); + $stocktakeLog->addColumn('stocktakeId', 'integer', ['signed' => true]) + ->addColumn('stocktakeItemId', 'integer', ['null' => true, 'signed' => true]) + ->addColumn('action', 'string', ['limit' => 50]) + ->addColumn('details', 'text', ['null' => true]) + ->addColumn('userId', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeId']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WarehouseStocktakeLog')->drop()->save(); + $this->table('WarehouseStocktakeItem')->drop()->save(); + $this->table('WarehouseStocktake')->drop()->save(); + } + } +} \ No newline at end of file diff --git a/db/migrations/20251215150000_warehouse_category_set_prefixes.php b/db/migrations/20251215150000_warehouse_category_set_prefixes.php new file mode 100644 index 000000000..bbba8f3c4 --- /dev/null +++ b/db/migrations/20251215150000_warehouse_category_set_prefixes.php @@ -0,0 +1,55 @@ +table('WarehouseCategory'); + if (!$table->hasColumn('articleNumberPrefix')) { + $table->addColumn('articleNumberPrefix', 'string', ['limit' => 4, 'null' => true, 'after' => 'description']) + ->update(); + } + + if ($this->getEnvironment() == "thetool") { + $prefixes = [ + 1 => '1901', // Dienstleistungen + 3 => '9980', // EStmk Shop + 4 => '1400', // GPON OLTs und Bridges + 21 => '9990', // Import nicht erfolgreich + 5 => '1700', // Kabel-TV und Zubehör + 6 => '0700', // Kupferverkabelung und Schränke + 7 => '0400', // LWL Aussen- und Universalkabel + 8 => '0600', // LWL Boxen, Muffen und Gehäuse + 9 => '0900', // LWL Leitungsbau + 10 => '0500', // LWL Pigtails und Kupplungen + 11 => '0800', // LWL Splitter, Filter und Dämpfer + 12 => '1600', // Netzteile, USV, Akkus + 13 => '0300', // Patchkabel Kupfer + 14 => '0200', // Patchkabel LWL Multimode + 15 => '0100', // Patchkabel LWL Singlemode + 16 => '1000', // Richtfunk und WLAN + 17 => '1100', // Router und Zubehör + 18 => '1300', // SFP und Konverter + 19 => '1200', // Switches und Zubehör + 20 => '1500', // Telefonie und Zubehör + 2 => '1800', // Elektromaterial etc. (no articles, assign next free) + ]; + + foreach ($prefixes as $categoryId => $prefix) { + $this->execute("UPDATE WarehouseCategory SET articleNumberPrefix = '{$prefix}' WHERE id = {$categoryId}"); + } + } + } + + public function down(): void + { + $table = $this->table('WarehouseCategory'); + + if ($table->hasColumn('articleNumberPrefix')) { + $table->removeColumn('articleNumberPrefix')->update(); + } + } +} diff --git a/db/migrations/20251217120000_warehouse_stocktake_item_add_overwritten.php b/db/migrations/20251217120000_warehouse_stocktake_item_add_overwritten.php new file mode 100644 index 000000000..9d99da9a4 --- /dev/null +++ b/db/migrations/20251217120000_warehouse_stocktake_item_add_overwritten.php @@ -0,0 +1,25 @@ +getEnvironment() == "thetool") { + $table = $this->table('WarehouseStocktakeItem'); + $table->addColumn('overwrittenById', 'integer', ['null' => true, 'signed' => true, 'after' => 'scannedBy']) + ->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WarehouseStocktakeItem'); + $table->removeColumn('overwrittenById') + ->update(); + } + } +} diff --git a/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php b/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php new file mode 100644 index 000000000..c2a09bfe7 --- /dev/null +++ b/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php @@ -0,0 +1,39 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoice"); + + $table->addColumn("lock", "integer", [ + "null" => false, + "default" => 0, + "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, + "after" => "status" + ]); + + $table->addColumn("exported", "integer", [ + "null" => false, + "default" => 0, + "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, + "after" => "lock" + ]); + + $table->save(); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $table = $this->table("ManualInvoice"); + $table->removeColumn("lock")->save(); + $table->removeColumn("exported")->save(); + } + } +} diff --git a/db/migrations/20260113130000_create_warehouse_lagerbewegung.php b/db/migrations/20260113130000_create_warehouse_lagerbewegung.php new file mode 100644 index 000000000..fea8726bf --- /dev/null +++ b/db/migrations/20260113130000_create_warehouse_lagerbewegung.php @@ -0,0 +1,42 @@ +getEnvironment() == "thetool") { + $lagerbewegung = $this->table('WarehouseLagerbewegung'); + $lagerbewegung + ->addColumn('movementNumber', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('movementType', 'enum', ['values' => ['IN', 'OUT', 'ADJUSTMENT']]) + ->addColumn('articleId', 'integer', ['signed' => false]) + ->addColumn('warehouseLocationId', 'integer', ['signed' => true]) + ->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => true]) + ->addColumn('quantity', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addColumn('quantityBefore', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true]) + ->addColumn('quantityAfter', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true]) + ->addColumn('reasonCategory', 'string', ['limit' => 50]) + ->addColumn('note', 'text', ['null' => true]) + ->addColumn('userId', 'integer', ['signed' => false]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['movementNumber'], ['unique' => true]) + ->addIndex(['articleId']) + ->addIndex(['warehouseLocationId']) + ->addIndex(['movementType']) + ->addIndex(['userId']) + ->addIndex(['create']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WarehouseLagerbewegung')->drop()->save(); + } + } +} diff --git a/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php b/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php new file mode 100644 index 000000000..b368d3754 --- /dev/null +++ b/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php @@ -0,0 +1,21 @@ +getEnvironment() == "thetool") { + $this->table('WarehouseLagerbewegung')->rename('WarehouseMovement')->save(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WarehouseMovement')->rename('WarehouseLagerbewegung')->save(); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 2748ee516..c81fccc3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,8 +32,6 @@ services: image: adminer ports: - "8088:8080" - volumes: - - ./docker/adminer/php.ini:/etc/php/7.4/cli/conf.d/php.local.ini phpmyadmin: image: phpmyadmin @@ -41,11 +39,30 @@ services: - "8081:80" environment: - PMA_HOST=db - - PMA_UPLOAD_LIMIT=1G - - UPLOAD_LIMIT=1G - MYSQL_ROOT_PASSWORD=junghan5 depends_on: - db + db-downloader: + build: + context: ./docker/db-downloader + dockerfile: Dockerfile + ports: + - "8082:8082" + # volumes: + # - ./docker/db-downloader/ssh-keys:/app/ssh-keys:ro + environment: + - SCP_HOST=thetool-dbbackup.xinon.at + - SCP_PORT=22 + - SCP_USERNAME=xinon + - SCP_DEFAULT_PATH=/opt/backup/mysql + - DB_HOST=db + - DB_PORT=3306 + - DB_USER=root + - DB_PASSWORD=junghan5 + - DB_AVAILABLE=thetool,addressdb + depends_on: + - db + volumes: vendor: diff --git a/docker/db-downloader/Dockerfile b/docker/db-downloader/Dockerfile new file mode 100644 index 000000000..760d57da8 --- /dev/null +++ b/docker/db-downloader/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim-bookworm + +RUN apt-get update && apt-get install -y \ + mariadb-client \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY templates/ ./templates/ +COPY static/ ./static/ + +RUN mkdir -p /app/downloads /app/ssh-keys + +ENV FLASK_APP=app:app +ENV FLASK_ENV=production +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8082 + +CMD ["gunicorn", "--bind", "0.0.0.0:8082", "--workers", "2", "--threads", "4", "app:app"] diff --git a/docker/db-downloader/app.py b/docker/db-downloader/app.py new file mode 100644 index 000000000..d9988ee74 --- /dev/null +++ b/docker/db-downloader/app.py @@ -0,0 +1,444 @@ +import os +import stat +import uuid +import gzip +import struct +import subprocess +import threading +import time +from datetime import datetime +from flask import Flask, render_template, request, jsonify, session +import paramiko + + +# ============================================================================= +# Configuration +# ============================================================================= +class Config: + SCP_HOST = os.getenv('SCP_HOST', 'localhost') + SCP_PORT = int(os.getenv('SCP_PORT', 22)) + SCP_USERNAME = os.getenv('SCP_USERNAME', 'root') + SCP_DEFAULT_PATH = os.getenv('SCP_DEFAULT_PATH', '/backups') + + DB_HOST = os.getenv('DB_HOST', 'db') + DB_PORT = int(os.getenv('DB_PORT', 3306)) + DB_USER = os.getenv('DB_USER', 'root') + DB_PASSWORD = os.getenv('DB_PASSWORD', '') + DB_AVAILABLE = os.getenv('DB_AVAILABLE', 'thetool,addressdb').split(',') + + DOWNLOAD_PATH = '/app/downloads' + SSH_KEYS_PATH = '/app/ssh-keys' + SECRET_KEY = os.getenv('SECRET_KEY', os.urandom(24).hex()) + + +# ============================================================================= +# SFTP Client +# ============================================================================= +class SFTPClient: + def __init__(self, host, port, username): + self.host = host + self.port = port + self.username = username + self.client = None + self.sftp = None + + def connect_password(self, password): + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.client.connect( + hostname=self.host, port=self.port, username=self.username, + password=password, look_for_keys=False, allow_agent=False + ) + self.sftp = self.client.open_sftp() + + def connect_key(self, key_path, passphrase=None): + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.client.connect( + hostname=self.host, port=self.port, username=self.username, + key_filename=key_path, passphrase=passphrase, + look_for_keys=False, allow_agent=False + ) + self.sftp = self.client.open_sftp() + + def list_directory(self, path): + entries = [] + for entry in self.sftp.listdir_attr(path): + is_dir = stat.S_ISDIR(entry.st_mode) + entries.append({ + 'name': entry.filename, + 'size': entry.st_size, + 'size_human': self._human_size(entry.st_size), + 'mtime': entry.st_mtime, + 'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'), + 'is_dir': is_dir, + 'is_sql': entry.filename.endswith(('.sql', '.sql.gz')), + 'path': os.path.join(path, entry.filename) + }) + return sorted(entries, key=lambda x: (not x['is_dir'], -x['mtime'])) + + def get_file_info(self, path): + entry = self.sftp.stat(path) + return { + 'name': os.path.basename(path), + 'size': entry.st_size, + 'size_human': self._human_size(entry.st_size), + 'mtime': entry.st_mtime, + 'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'), + 'path': path + } + + def download_file(self, remote_path, local_path, callback=None): + self.sftp.get(remote_path, local_path, callback=callback) + + def close(self): + if self.sftp: + self.sftp.close() + if self.client: + self.client.close() + + @staticmethod + def _human_size(size): + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} PB" + + +# ============================================================================= +# Database Restore +# ============================================================================= +class DatabaseRestore: + def __init__(self): + self.host = Config.DB_HOST + self.port = Config.DB_PORT + self.user = Config.DB_USER + self.password = Config.DB_PASSWORD + self.available_dbs = Config.DB_AVAILABLE + self.cancelled = False + + def cancel(self): + self.cancelled = True + + @staticmethod + def get_gzip_uncompressed_size(filepath): + with open(filepath, 'rb') as f: + f.seek(-4, 2) + return struct.unpack(' 0 else 0, 'downloaded': transferred, 'total': total}) + + client.download_file(remote_file, local_file, callback=download_progress) + client.close() + + if jobs[job_id].get('cancelled'): + jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'}) + if os.path.exists(local_file): + os.remove(local_file) + return + + jobs[job_id].update({'progress': 45, 'message': 'Download complete. Preparing restore...', 'status': 'restoring'}) + jobs[job_id]['progress'] = 50 + jobs[job_id]['message'] = f'Clearing database {target_db}...' + + restorer = DatabaseRestore() + restorers[job_id] = restorer + + uncompressed_size = restorer.get_gzip_uncompressed_size(local_file) if local_file.endswith('.gz') else os.path.getsize(local_file) + + def restore_progress(bytes_processed): + if jobs[job_id].get('cancelled'): + restorer.cancel() + pct = 50 + min(45, int((bytes_processed / uncompressed_size) * 45)) if uncompressed_size > 0 else 50 + jobs[job_id].update({'progress': pct, 'message': f'Restoring to {target_db}... ({bytes_processed // (1024*1024)} MB / {uncompressed_size // (1024*1024)} MB)'}) + + result = restorer.restore_from_file(local_file, target_db, progress_callback=restore_progress) + + if os.path.exists(local_file): + os.remove(local_file) + + jobs[job_id].update({ + 'status': 'completed', 'progress': 100, + 'message': f'Restore complete! Dropped {result["tables_dropped"]} tables and imported {result["file"]}', + 'completed_at': time.time(), 'duration': time.time() - jobs[job_id]['started_at'] + }) + + except Exception as e: + error_msg = str(e) + if 'cancelled' in error_msg.lower(): + jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'}) + else: + jobs[job_id].update({'status': 'error', 'error': error_msg, 'message': f'Error: {error_msg}'}) + if os.path.exists(local_file): + os.remove(local_file) + finally: + restorers.pop(job_id, None) + + +@app.route('/api/status/') +def status(job_id): + if job_id not in jobs: + return jsonify({'success': False, 'error': 'Job not found'}), 404 + job = jobs[job_id].copy() + job['success'] = True + if 'started_at' in job: + elapsed = (job.get('completed_at') or time.time()) - job['started_at'] + job['elapsed'] = f'{int(elapsed // 60)}m {int(elapsed % 60)}s' + return jsonify(job) + + +@app.route('/api/jobs', methods=['GET']) +def list_jobs(): + return jsonify({'success': True, 'jobs': dict(jobs)}) + + +@app.route('/api/cancel/', methods=['POST']) +def cancel(job_id): + if job_id not in jobs: + return jsonify({'success': False, 'error': 'Job not found'}), 404 + if jobs[job_id]['status'] in ('completed', 'error', 'cancelled'): + return jsonify({'success': False, 'error': 'Job already finished'}), 400 + jobs[job_id]['cancelled'] = True + if job_id in restorers: + restorers[job_id].cancel() + return jsonify({'success': True, 'message': 'Cancel signal sent'}) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8082, debug=True) diff --git a/docker/db-downloader/requirements.txt b/docker/db-downloader/requirements.txt new file mode 100644 index 000000000..973a9f474 --- /dev/null +++ b/docker/db-downloader/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +gunicorn==21.2.0 +paramiko==3.4.0 +mysql-connector-python==8.2.0 +python-dotenv==1.0.0 diff --git a/docker/db-downloader/static/app.js b/docker/db-downloader/static/app.js new file mode 100644 index 000000000..c949e1164 --- /dev/null +++ b/docker/db-downloader/static/app.js @@ -0,0 +1,457 @@ +// DB Restore Tool - Frontend JavaScript + +let currentPath = ''; +let selectedFile = null; +let isConnected = false; +let currentJobId = null; +let pollInterval = null; + +// Initialize +document.addEventListener('DOMContentLoaded', function() { + loadAvailableKeys(); + setupEventListeners(); +}); + +function setupEventListeners() { + // Auth type toggle + document.getElementById('auth-type').addEventListener('change', function() { + const passwordAuth = document.getElementById('password-auth'); + const keyAuth = document.getElementById('key-auth'); + if (this.value === 'password') { + passwordAuth.classList.remove('hidden'); + keyAuth.classList.add('hidden'); + } else { + passwordAuth.classList.add('hidden'); + keyAuth.classList.remove('hidden'); + } + }); + + // Connect form + document.getElementById('connect-form').addEventListener('submit', function(e) { + e.preventDefault(); + connect(); + }); + + // Disconnect button + document.getElementById('disconnect-btn').addEventListener('click', disconnect); + + // Restore button + document.getElementById('restore-btn').addEventListener('click', startRestore); + + // Cancel button + document.getElementById('cancel-btn').addEventListener('click', cancelRestore); +} + +async function loadAvailableKeys() { + try { + const response = await fetch('/api/keys'); + const data = await response.json(); + const select = document.getElementById('key-file'); + select.innerHTML = ''; + data.keys.forEach(key => { + const option = document.createElement('option'); + option.value = key; + option.textContent = key; + select.appendChild(option); + }); + } catch (error) { + console.error('Failed to load keys:', error); + } +} + +async function connect() { + const authType = document.getElementById('auth-type').value; + const password = document.getElementById('password').value; + const keyFile = document.getElementById('key-file').value; + const keyPassphrase = document.getElementById('key-passphrase').value; + + const btn = document.getElementById('connect-btn'); + btn.disabled = true; + btn.textContent = 'Connecting...'; + + try { + const response = await fetch('/api/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auth_type: authType, + password: password, + key_file: keyFile, + key_passphrase: keyPassphrase + }) + }); + + const data = await response.json(); + + if (data.success) { + isConnected = true; + currentPath = data.path; + showStatus('Connected to ' + data.host, 'success'); + renderFiles(data.files, data.path); + updateBreadcrumb(data.path); + + // Toggle buttons + btn.classList.add('hidden'); + document.getElementById('disconnect-btn').classList.remove('hidden'); + + // Clear password field for security + document.getElementById('password').value = ''; + } else { + showStatus('Connection failed: ' + data.error, 'error'); + } + } catch (error) { + showStatus('Connection error: ' + error.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Connect'; + } +} + +async function disconnect() { + try { + await fetch('/api/disconnect', { method: 'POST' }); + } catch (e) {} + + isConnected = false; + selectedFile = null; + currentPath = ''; + + // Reset UI + document.getElementById('connect-btn').classList.remove('hidden'); + document.getElementById('disconnect-btn').classList.add('hidden'); + document.getElementById('file-browser').innerHTML = ` +
    + + + +

    Connect to browse remote files

    +
    + `; + document.getElementById('breadcrumb').innerHTML = 'Not connected'; + document.getElementById('selected-file-info').innerHTML = '

    No file selected

    '; + document.getElementById('restore-btn').disabled = true; + + hideStatus(); +} + +async function browse(path) { + if (!isConnected) return; + + try { + const response = await fetch('/api/browse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path }) + }); + + const data = await response.json(); + + if (data.success) { + currentPath = data.path; + renderFiles(data.files, data.path); + updateBreadcrumb(data.path); + } else { + showStatus('Browse failed: ' + data.error, 'error'); + } + } catch (error) { + showStatus('Browse error: ' + error.message, 'error'); + } +} + +function renderFiles(files, path) { + const container = document.getElementById('file-browser'); + + if (files.length === 0) { + container.innerHTML = '
    Empty directory
    '; + return; + } + + let html = '
    '; + + // Parent directory link + if (path !== '/') { + const parentPath = path.split('/').slice(0, -1).join('/') || '/'; + html += ` +
    + + + + .. +
    + `; + } + + files.forEach(file => { + const isSelected = selectedFile && selectedFile.path === file.path; + const selectedClass = isSelected ? 'selected' : ''; + + if (file.is_dir) { + html += ` +
    + + + + ${file.name} +
    + `; + } else if (file.is_sql) { + html += ` +
    + + + + + ${file.name} + ${file.size_human} + ${file.mtime_human} +
    + `; + } else { + html += ` +
    + + + + ${file.name} + ${file.size_human} +
    + `; + } + }); + + html += '
    '; + container.innerHTML = html; +} + +function selectFile(file) { + selectedFile = file; + document.getElementById('selected-file-info').innerHTML = ` +

    ${file.name}

    +

    ${file.size_human} - ${file.mtime_human}

    + `; + document.getElementById('restore-btn').disabled = false; + + // Auto-detect target database from filename + const filename = file.name.toLowerCase(); + const targetDbSelect = document.getElementById('target-db'); + const availableDbs = Array.from(targetDbSelect.options).map(o => o.value); + + for (const db of availableDbs) { + if (filename.includes(db.toLowerCase())) { + targetDbSelect.value = db; + break; + } + } + + // Re-render to show selection + browse(currentPath); +} + +function updateBreadcrumb(path) { + const parts = path.split('/').filter(p => p); + let html = `/`; + + let currentPathBuild = ''; + parts.forEach((part, index) => { + currentPathBuild += '/' + part; + const isLast = index === parts.length - 1; + html += ` + / + ${part} + `; + }); + + document.getElementById('breadcrumb').innerHTML = html; +} + +async function startRestore() { + if (!selectedFile) return; + + const targetDb = document.getElementById('target-db').value; + + if (!confirm(`Are you sure you want to restore ${selectedFile.name} to database "${targetDb}"?\n\nThis will DROP ALL TABLES in the database!`)) { + return; + } + + const btn = document.getElementById('restore-btn'); + btn.disabled = true; + btn.textContent = 'Starting...'; + + try { + const response = await fetch('/api/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file: selectedFile.path, + database: targetDb + }) + }); + + const data = await response.json(); + + if (data.success) { + currentJobId = data.job_id; + showProgressPanel(); + startPolling(); + } else { + showStatus('Restore failed: ' + data.error, 'error'); + btn.disabled = false; + btn.textContent = 'Start Restore'; + } + } catch (error) { + showStatus('Restore error: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = 'Start Restore'; + } +} + +function showProgressPanel() { + document.getElementById('progress-panel').classList.remove('hidden'); + document.getElementById('progress-bar').style.width = '0%'; + document.getElementById('progress-bar').classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600'); + document.getElementById('progress-bar').classList.add('bg-blue-600'); + document.getElementById('progress-percent').textContent = '0%'; + document.getElementById('progress-status').textContent = 'Starting...'; + document.getElementById('progress-message').textContent = 'Initializing...'; + document.getElementById('cancel-btn').classList.remove('hidden'); + document.getElementById('cancel-btn').disabled = false; + document.getElementById('cancel-btn').textContent = 'Cancel Restore'; +} + +function startPolling() { + if (pollInterval) clearInterval(pollInterval); + + pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/status/${currentJobId}`); + const data = await response.json(); + + if (data.success) { + updateProgress(data); + + if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') { + stopPolling(); + document.getElementById('restore-btn').disabled = false; + document.getElementById('restore-btn').textContent = 'Start Restore'; + + if (data.status === 'completed') { + showStatus('Restore completed successfully!', 'success'); + } else if (data.status === 'cancelled') { + showStatus('Restore was cancelled', 'error'); + } else { + showStatus('Restore failed: ' + data.error, 'error'); + } + } + } + } catch (error) { + console.error('Polling error:', error); + } + }, 250); +} + +function stopPolling() { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } +} + +async function cancelRestore() { + if (!currentJobId) return; + + if (!confirm('Are you sure you want to cancel the restore?')) { + return; + } + + const btn = document.getElementById('cancel-btn'); + btn.disabled = true; + btn.textContent = 'Cancelling...'; + + try { + const response = await fetch(`/api/cancel/${currentJobId}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + showStatus('Cancellation requested...', 'error'); + } else { + showStatus('Cancel failed: ' + data.error, 'error'); + btn.disabled = false; + btn.textContent = 'Cancel Restore'; + } + } catch (error) { + showStatus('Cancel error: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = 'Cancel Restore'; + } +} + +function updateProgress(data) { + const bar = document.getElementById('progress-bar'); + const percent = document.getElementById('progress-percent'); + const status = document.getElementById('progress-status'); + const message = document.getElementById('progress-message'); + const elapsed = document.getElementById('progress-elapsed'); + const cancelBtn = document.getElementById('cancel-btn'); + + bar.style.width = data.progress + '%'; + percent.textContent = data.progress + '%'; + + // Update status label + const statusLabels = { + 'starting': 'Starting', + 'downloading': 'Downloading', + 'restoring': 'Restoring', + 'completed': 'Completed', + 'error': 'Error', + 'cancelled': 'Cancelled' + }; + status.textContent = statusLabels[data.status] || data.status; + + // Update color based on status + bar.classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600'); + if (data.status === 'completed') { + bar.classList.remove('bg-blue-600'); + bar.classList.add('bg-green-600'); + } else if (data.status === 'error') { + bar.classList.remove('bg-blue-600'); + bar.classList.add('bg-red-600'); + } else if (data.status === 'cancelled') { + bar.classList.remove('bg-blue-600'); + bar.classList.add('bg-yellow-600'); + } + + // Show/hide cancel button based on job status + if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') { + cancelBtn.classList.add('hidden'); + } else { + cancelBtn.classList.remove('hidden'); + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel Restore'; + } + + message.textContent = data.message || ''; + if (data.elapsed) { + elapsed.textContent = 'Elapsed: ' + data.elapsed; + } +} + +function showStatus(message, type) { + const status = document.getElementById('connection-status'); + status.classList.remove('hidden', 'bg-green-100', 'bg-red-100', 'text-green-800', 'text-red-800'); + + if (type === 'success') { + status.classList.add('bg-green-100', 'text-green-800'); + } else if (type === 'error') { + status.classList.add('bg-red-100', 'text-red-800'); + } + + status.textContent = message; +} + +function hideStatus() { + document.getElementById('connection-status').classList.add('hidden'); +} diff --git a/docker/db-downloader/templates/index.html b/docker/db-downloader/templates/index.html new file mode 100644 index 000000000..2ea9fed98 --- /dev/null +++ b/docker/db-downloader/templates/index.html @@ -0,0 +1,187 @@ + + + + + + DB Restore Tool + + + + +
    + +
    +

    Database Restore Tool

    +

    Browse and restore database backups from remote server

    +
    + +
    + +
    +
    +

    + + + + Connection +

    + + + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + + +
    +
    + + +
    +
    + + + + + + + +
    +
    + + +
    +

    + + + + Restore +

    + +
    +

    No file selected

    +
    + +
    + + +
    + +
    +

    + Warning: This will DROP all tables in the selected database before restoring! +

    +
    + + +
    +
    + + +
    +
    +

    + + + + Remote Browser +

    + + + + + +
    +
    + + + +

    Connect to browse remote files

    +
    +
    +
    + + + +
    +
    +
    + + + + diff --git a/lib/XinonProject/XinonProject.php b/lib/XinonProject/XinonProject.php index f32895350..5d4c93936 100644 --- a/lib/XinonProject/XinonProject.php +++ b/lib/XinonProject/XinonProject.php @@ -66,8 +66,10 @@ class XinonProject { if (!is_null($overrideQueryParams)) $queryParams = $overrideQueryParams; + $url = $baseUrl . '?' . http_build_query($queryParams); + curl_setopt_array($curl, array( - CURLOPT_URL => $baseUrl . '?' . http_build_query($queryParams), + CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, @@ -84,7 +86,7 @@ class XinonProject { $json = json_decode($response, true); - return $json['_embedded']['elements']; + return $json['_embedded']['elements'] ?? []; } } ?> diff --git a/public/.htaccess b/public/.htaccess index efdafabbc..7485068b2 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -25,6 +25,32 @@ RewriteCond %{REQUEST_FILENAME} !-l RewriteRule ^api/(v\d+)/([^/]+)(/.+)$ index.php?action=Api&apiv=$1&apicall=$2&apiparams=$3 [QSA] +# MobileApp routing: /MobileApp/{module}/{submodule}/{action} +# Example: /MobileApp/Lager/Inventur/getActiveStocktakes +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-l +RewriteRule ^MobileApp/([^/]+)/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2&endpoint=$3 [QSA,L] + +# /MobileApp/{module}/{submodule} - e.g., /MobileApp/Lager/Inventur +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-l +RewriteRule ^MobileApp/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2 [QSA,L] + +# /MobileApp/{module} - e.g., /MobileApp/auth or /MobileApp/Lager +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-l +RewriteRule ^MobileApp/([^/]+)/?$ index.php?action=MobileApp&module=$1 [QSA,L] + +# /MobileApp - Main app +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-l +RewriteRule ^MobileApp/?$ index.php?action=MobileApp [QSA,L] + + # regular web calls RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d diff --git a/public/img/markers/marker-pop-b.png b/public/img/markers/marker-pop-b.png new file mode 100644 index 000000000..e0d6262c8 Binary files /dev/null and b/public/img/markers/marker-pop-b.png differ diff --git a/public/img/markers/marker-pop-bl.png b/public/img/markers/marker-pop-bl.png new file mode 100644 index 000000000..51b0e3849 Binary files /dev/null and b/public/img/markers/marker-pop-bl.png differ diff --git a/public/img/markers/marker-pop-o.png b/public/img/markers/marker-pop-o.png new file mode 100644 index 000000000..c57d8095b Binary files /dev/null and b/public/img/markers/marker-pop-o.png differ diff --git a/public/img/markers/marker-pop-v.png b/public/img/markers/marker-pop-v.png new file mode 100644 index 000000000..9df7a59d6 Binary files /dev/null and b/public/img/markers/marker-pop-v.png differ diff --git a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css index 579e4069b..e45b7ba09 100644 --- a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css +++ b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css @@ -524,6 +524,54 @@ border: 1px solid #c9e6d8; } +/* ===== Copy From Section ===== */ +.tt-scope .copy-from-section { + background: #f8fafc; + border-radius: 8px; + padding: 12px 16px; + border: 1px dashed var(--tt-border); +} + +.tt-scope .copy-from-row { + display: flex; + align-items: center; + gap: 10px; +} + +.tt-scope .copy-select { + flex: 1; + max-width: 350px; +} + +.tt-scope .copy-select select { + width: 100%; +} + +.tt-scope .copy-btn { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.tt-scope .copy-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tt-scope .copy-hint { + font-size: 11px; + color: var(--tt-muted); + margin-top: 6px; +} + +.tt-scope .form-divider { + border: none; + height: 1px; + background: var(--tt-border); + margin: 4px 0 0 0; +} + /* ===== Utilities ===== */ .tt-scope .mono { font-family: var(--tt-mono); } .tt-scope .muted { color: var(--tt-muted); } diff --git a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js index ecc100c17..35cadf513 100644 --- a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js +++ b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js @@ -124,8 +124,7 @@ const ADBNetzgebiet = { @@ -134,7 +133,16 @@ const ADBNetzgebiet = { + + @@ -274,6 +282,25 @@ const ADBNetzgebiet = {
    + + + +
    + + Lade Log... +
    +
    + + Kein Log vorhanden. +
    +
    {{ rimoLogContent }}
    + +
    `, @@ -294,6 +321,16 @@ const ADBNetzgebiet = { historyItems: [], historyTitle: 'Verlauf', expandedIds: {}, + + // RIMO Import + importStatus: {}, + showRimoLogModal: false, + rimoLogContent: '', + rimoLogTitle: '', + rimoLogStatus: 'idle', + rimoLogInterval: null, + statusInterval: null, + freigabeLabels: { interest: 'Interest', provision: 'Provision', order: 'Order', reorder: 'Reorder' }, freigabeOptions: [ { key: 'interest', label: 'Interest' }, @@ -367,9 +404,24 @@ const ADBNetzgebiet = { filteredNetzgebiete() { if (this.currentPage > this.totalPages) this.currentPage = 1; } }, - async mounted() { await this.fetchNetzgebiete(); }, + async mounted() { + await this.fetchNetzgebiete(); + this.fetchImportStatus(); + this.statusInterval = setInterval(this.fetchImportStatus, 15000); // Poll every 15s + }, + + beforeDestroy() { + clearInterval(this.statusInterval); + clearInterval(this.rimoLogInterval); + }, methods: { + formatConsentName(name) { + if (name && name.startsWith('Glasfaserprojekt')) { + return name.replace('Glasfaserprojekt', 'Glasfaserprojekt
    '); + } + return name; + }, debouncedFilter() { clearTimeout(this.filterDebounce); this.filterDebounce = setTimeout(() => this.currentPage = 1, 300); @@ -419,6 +471,25 @@ const ADBNetzgebiet = { }; this.showEditModal = true; }, + async copyNetzgebiet(item) { + const n = item.netzgebiet; + let options = {}; + try { options = JSON.parse(n.options || '{}'); } catch {} + let freigabeArr = []; + try { freigabeArr = JSON.parse(n.freigabe || '[]') || []; } catch {} + const freigabeObj = {}; + ['interest', 'provision', 'order', 'reorder'].forEach(f => freigabeObj[f] = freigabeArr.includes(f)); + this.editItem = { + id: null, + name: '', + extref: '', + source: n.source || '', + source_id: '', + freigabe: freigabeObj, + options: { ...this.defaultOptions, ...options } + }; + this.showEditModal = true; + }, async saveNetzgebiet() { if (!this.editItem?.name) return; this.isSaving = true; @@ -451,6 +522,96 @@ const ADBNetzgebiet = { } catch { window.notify?.('error', 'Verlauf konnte nicht geladen werden.'); } finally { this.historyLoading = false; } }, + + // RIMO Import Methods + async fetchImportStatus() { + const rimoIds = this.netzgebiete + .filter(item => item.netzgebiet.source && item.netzgebiet.source.startsWith('rimo-')) + .map(item => item.netzgebiet.id); + if (!rimoIds.length) return; + try { + const response = await axios.post(window.TT_CONFIG.GET_RIMO_IMPORT_STATUS_URL, { ids: rimoIds }); + if (response.data.success) { + this.importStatus = response.data.data; + } + } catch (error) { + console.error("Could not fetch RIMO import statuses.", error); + } + }, + handleRimoImportClick(item) { + const status = this.importStatus[item.netzgebiet.id]?.status || 'idle'; + if (status === 'running') { + this.openRimoLogModal(item); + } else if (status === 'cooldown') { + const remaining = this.importStatus[item.netzgebiet.id]?.remaining || 0; + window.notify?.('info', `Bitte warten Sie noch ${Math.ceil(remaining / 60)} Minuten.`); + this.openRimoLogModal(item); + } else { + this.startRimoImport(item); + } + }, + getImportButtonTitle(id) { + const status = this.importStatus[id]?.status || 'idle'; + if (status === 'running') return 'Import-Log anzeigen'; + if (status === 'cooldown') return 'Manueller RIMO-Import (Abkühlphase)'; + return 'Manuellen RIMO-Import starten'; + }, + getImportButtonDisabled(id) { + const status = this.importStatus[id]?.status || 'idle'; + return false; // Never truly disabled, just changes action + }, + getImportButtonIcon(id) { + const status = this.importStatus[id]?.status || 'idle'; + if (status === 'running') return 'fa-spinner fa-spin'; + if (status === 'cooldown') return 'fa-hourglass-half'; + return 'fa-rocket'; + }, + async startRimoImport(item) { + try { + const response = await axios.get(window.TT_CONFIG.START_RIMO_IMPORT_URL + '?id=' + item.netzgebiet.id); + if (response.data.success) { + window.notify?.('success', 'RIMO Import gestartet.'); + this.importStatus[item.netzgebiet.id] = { status: 'running' }; + this.openRimoLogModal(item); + } else { + window.notify?.('error', response.data.message || 'Import konnte nicht gestartet werden.'); + } + } catch (error) { + window.notify?.('error', 'Fehler beim Starten des Imports.'); + console.error(error); + } + }, + openRimoLogModal(item) { + this.rimoLogTitle = `RIMO Import: ${item.netzgebiet.name}`; + this.showRimoLogModal = true; + this.fetchRimoLog(item); // initial fetch + this.rimoLogInterval = setInterval(() => this.fetchRimoLog(item), 3000); + }, + closeRimoLogModal() { + this.showRimoLogModal = false; + clearInterval(this.rimoLogInterval); + this.rimoLogContent = ''; + this.rimoLogTitle = ''; + this.rimoLogStatus = 'idle'; + }, + async fetchRimoLog(item) { + try { + const response = await axios.get(window.TT_CONFIG.GET_RIMO_IMPORT_LOG_URL + '?id=' + item.netzgebiet.id); + if (response.data.success) { + this.rimoLogContent = response.data.data.log; + this.rimoLogStatus = response.data.data.status; + // If no longer running, stop polling + if (this.rimoLogStatus !== 'running') { + clearInterval(this.rimoLogInterval); + this.fetchImportStatus(); // refresh overall status + } + } + } catch (error) { + console.error('Could not fetch RIMO log.', error); + clearInterval(this.rimoLogInterval); + } + }, + translateAction(action) { return { create: 'Erstellt', update: 'Geändert', delete: 'Gelöscht' }[action] || action; }, translateField(field) { return { name: 'Name', extref: 'ExtRef', source: 'Quelle', source_id: 'Source ID', diff --git a/public/js/pages/AddressTickets/AddressTickets.js b/public/js/pages/AddressTickets/AddressTickets.js index fafa8f562..983931ac1 100644 --- a/public/js/pages/AddressTickets/AddressTickets.js +++ b/public/js/pages/AddressTickets/AddressTickets.js @@ -1,13 +1,15 @@ Vue.component('AddressTickets', { template: `
    - -

    Tickets

    -
    + +

    Tickets - {{ customerName }} ({{ customerNumber }})

    +
    + Keine Tickets gefunden. +
    +
    - @@ -16,7 +18,6 @@ Vue.component('AddressTickets', { - @@ -30,7 +31,10 @@ Vue.component('AddressTickets', {

    Lieferscheine

    -
    +
    + Keine Lieferscheine gefunden. +
    +
    Kundennummer Erstellt am Betreff Letztes Update
    {{ ticket.customField7 }} {{ formatDate(ticket.createdAt) }} {{ ticket.subject }} {{ formatDate(ticket.updatedAt) }}
    @@ -65,10 +69,15 @@ Vue.component('AddressTickets', { return {window: window}; }, computed: { + customerName() { + return this.window.TT_CONFIG?.CUSTOMER_NAME || ''; + }, + customerNumber() { + return this.window.TT_CONFIG?.CUSTOMER_NUMBER || ''; + }, tickets() { return (this.window.TT_CONFIG?.TICKETS || []).map(t => ({ id: t.id, - customField7: t.customField7, createdAt: t.createdAt, subject: t.subject, updatedAt: t.updatedAt, diff --git a/public/js/pages/ManualInvoice/ManualInvoice.js b/public/js/pages/ManualInvoice/ManualInvoice.js index 4ce7a72cd..a8699e002 100644 --- a/public/js/pages/ManualInvoice/ManualInvoice.js +++ b/public/js/pages/ManualInvoice/ManualInvoice.js @@ -170,7 +170,7 @@ Vue.component('manual-invoice-modal', { - + @@ -208,6 +208,12 @@ Vue.component('manual-invoice-modal', { billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}], positionsConfig: { fields: { + article_id: { + type: 'autocomplete', + label: 'Artikel (optional)', + apiUrl: '/WarehouseArticle/autocomplete', + customFieldReference: 'WarehouseArticle' + }, product_name: { type: 'input', label: 'Bezeichnung' }, product_info: { type: 'input', label: 'Zusatzinfo' }, amount: { type: 'input', label: 'Menge', inputType: 'number' }, @@ -330,6 +336,28 @@ Vue.component('manual-invoice-modal', { if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); } }, togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; }, + async onArticleSelected(articleId) { + if (!articleId) return; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getArticleVatInfo?article_id=${articleId}`); + if (data.success && this.$refs.positionsManager) { + // Update the formData in the positions manager + const pm = this.$refs.positionsManager; + if (data.article) { + pm.$set(pm.formData, 'product_name', data.article.title); + pm.$set(pm.formData, 'product_info', data.article.description || ''); + } + pm.$set(pm.formData, 'vatrate', parseFloat(data.vatrate) || 20); + pm.$set(pm.formData, 'fibu_cost_account', data.fibu_cost_account); + pm.$set(pm.formData, 'fibu_cost_account_legacy', data.fibu_cost_account_legacy); + pm.$set(pm.formData, 'fibu_taxcode', data.fibu_taxcode); + // Store vatgroup_id on invoice level if needed + this.invoiceData.vatgroup_id = data.vatgroup_id; + } + } catch (e) { + console.error('Error fetching article VAT info:', e); + } + }, debouncedPreviewUpdate() { clearTimeout(this.previewDebounceTimer); this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000); diff --git a/public/js/pages/Pop/Pop.css b/public/js/pages/Pop/Pop.css new file mode 100644 index 000000000..0dff802dd --- /dev/null +++ b/public/js/pages/Pop/Pop.css @@ -0,0 +1,10 @@ +.fa-map-location-dot:before +{ + color: #d80000; +} +.fa-map-location-dot:after +{ + color: #147d00; + opacity: 0.9; + +} \ No newline at end of file diff --git a/public/js/pages/Pop/PopMap.js b/public/js/pages/Pop/PopMap.js new file mode 100644 index 000000000..87f13abb4 --- /dev/null +++ b/public/js/pages/Pop/PopMap.js @@ -0,0 +1,383 @@ +Vue.component('pop-map-modal', { + template: ` +
    + +
    + `, + data() { + return { + map: null, + popLayer: null, + searchQuery: '', + filteredPops: [], + showSuggestions: false, + selectedIndex: -1, + categories: { + 1: 'Outdoor (Kasten/Schrank)', + 2: 'Indoor (Keller Gebäude)', + 3: 'Sender/Funk (Sendemast)', + 4: 'Container (Garage, Container)', + 99: 'Unbekannt' + }, + states: { + 1: "Planung (Innenleben)", + 2: "Bauphase (Schrank)", + 3: "Grobdoku", + 4: "in Betrieb", + 5: "von Techniker abgenommen (Altbestand)" + }, + categoryImages: { + 1: 'img/markers/marker-pop.png', + 2: 'img/markers/marker-pop-o.png', + 3: 'img/markers/marker-pop-b.png', + 4: 'img/markers/marker-pop-v.png', + 99: 'img/markers/marker-pop-bl.png' + }, + categoryColors: { + 1: '#a1dfa0', + 2: '#f8b767', + 3: '#a9b8ec', + 4: '#f89797', + 99: '#808080' + }, + visibleCategories: { + 1: true, + 2: true, + 3: true, + 4: true, + 99: true + }, + categoryCounts: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 99: 0 + }, + allPops: [], + markers: [] + }; + }, + computed: { + totalCount() { + return Object.values(this.categoryCounts).reduce((acc, count) => acc + count, 0); + }, + allCategoriesSelected: { + get() { + return Object.keys(this.categories).every(key => this.visibleCategories[key]); + }, + set(value) { + Object.keys(this.categories).forEach(key => { + this.visibleCategories[key] = value; + }); + this.updateMap(false); + } + } + }, + mounted() { + const popsObj = window.TT_CONFIG.POPS || {}; + this.allPops = Object.values(popsObj); + + this.calculateCounts(); + $(document).on('shown.bs.modal', '#popMapModal', this.initMap); + + document.addEventListener('click', this.handleClickOutside); + }, + beforeDestroy() { + $(document).off('shown.bs.modal', '#popMapModal', this.initMap); + document.removeEventListener('click', this.handleClickOutside); + }, + methods: { + calculateCounts() { + for (let key in this.categoryCounts) { + this.categoryCounts[key] = 0; + } + + this.allPops.forEach(pop => { + const gps = pop.gps; + if (!gps) return; + const parts = gps.split(','); + if (parts.length !== 2) return; + const lat = parseFloat(parts[0]); + const lng = parseFloat(parts[1]); + if (isNaN(lat) || isNaN(lng) || (lat === 0 && lng === 0)) return; + + const category = pop.category || 99; + if (this.categoryCounts.hasOwnProperty(category)) { + this.categoryCounts[category]++; + } else { + this.categoryCounts[99]++; + } + }); + }, + open() { + $('#popMapModal').modal('show'); + }, + initMap() { + if (this.map) { + setTimeout(() => { + this.map.invalidateSize(); + }, 100); + return; + } + + if (typeof L === 'undefined' || !L.MakiMarkers) { + console.error('Leaflet or MakiMarkers not loaded'); + return; + } + + L.MakiMarkers.accessToken = window.TT_CONFIG.MAPBOX_TOKEN; + + this.map = L.map('pop-map').setView([51.1657, 10.4515], 6); + + const standardLayer = L.tileLayer('https://mapsneu.wien.gv.at/basemap/{id}/normal/google3857/{z}/{y}/{x}.{imgtype}', { + maxZoom: 19, + id: "geolandbasemap", + imgtype: "png", + attribution: 'Basemap.at' + }); + + const satelliteLayer = L.tileLayer('https://mapsneu.wien.gv.at/basemap/{id}/normal/google3857/{z}/{y}/{x}.{imgtype}', { + maxZoom: 19, + id: "bmaporthofoto30cm", + imgtype: "jpeg", + attribution: 'Basemap.at' + }); + + standardLayer.addTo(this.map); + + const baseMaps = { + "Karte": standardLayer, + "Satellit": satelliteLayer + }; + + L.control.layers(baseMaps).addTo(this.map); + + this.popLayer = L.featureGroup().addTo(this.map); + + this.updateMap(); + }, + updateMap(shouldFit = true) { + if (!this.map) return; + + this.popLayer.clearLayers(); + this.markers = []; + + const bounds = L.latLngBounds(); + let hasMarkers = false; + + this.allPops.forEach(pop => { + const category = pop.category || 99; + + if (!this.visibleCategories[category]) return; + + const gps = pop.gps; + if (!gps) return; + + const parts = gps.split(','); + if (parts.length !== 2) return; + + const lat = parseFloat(parts[0]); + const lng = parseFloat(parts[1]); + + if (isNaN(lat) || isNaN(lng) || (lat === 0 && lng === 0)) return; + + let iconUrl = this.categoryImages[category] || this.categoryImages[99]; + let color = this.categoryColors[category] || '#808080'; + + const marker = L.marker([lat, lng], { + icon: L.MakiMarkers.icon({ + icon: 'village', + color: color, + size: 'l' + }) + }); + + let categoryName = this.categories[category] || 'Unbekannt'; + let stateText = this.states[pop.state] || pop.state || '-'; + + const popupContent = ` +
    +
    ${pop.name}
    +
    +
    Kategorie: ${categoryName}
    +
    Status: ${stateText}
    +
    Info: ${pop.location || '-'}
    +
    + GPS: ${lat.toFixed(6)}, ${lng.toFixed(6)} + Google Maps +
    + +
    + `; + + marker.bindPopup(popupContent); + marker.popData = pop; + + this.popLayer.addLayer(marker); + this.markers.push(marker); + bounds.extend([lat, lng]); + hasMarkers = true; + }); + + if (shouldFit === true && hasMarkers && !this.searchQuery) { + this.map.fitBounds(bounds, {padding: [50, 50]}); + } + }, + filterPops() { + const query = this.searchQuery.toLowerCase().trim(); + this.selectedIndex = -1; + + if (query.length < 1) { + this.filteredPops = []; + this.showSuggestions = false; + return; + } + + this.filteredPops = this.allPops.filter(pop => + pop.name.toLowerCase().includes(query) || + (pop.location && pop.location.toLowerCase().includes(query)) + ).slice(0, 10); + + this.showSuggestions = true; + }, + moveSelection(step) { + if (!this.showSuggestions || this.filteredPops.length === 0) return; + + this.selectedIndex += step; + + if (this.selectedIndex < 0) { + this.selectedIndex = this.filteredPops.length - 1; + } else if (this.selectedIndex >= this.filteredPops.length) { + this.selectedIndex = 0; + } + }, + handleEnter() { + if (this.showSuggestions && this.selectedIndex >= 0 && this.selectedIndex < this.filteredPops.length) { + this.selectPop(this.filteredPops[this.selectedIndex]); + } else { + this.searchPop(); + } + }, + selectPop(pop) { + this.searchQuery = pop.name; + this.showSuggestions = false; + this.selectedIndex = -1; + this.searchPop(); + }, + handleClickOutside(event) { + if (!event.target.closest('.input-group')) { + this.showSuggestions = false; + this.selectedIndex = -1; + } + }, + searchPop() { + const query = this.searchQuery.toLowerCase().trim(); + if (!query) { + this.clearSearch(); + return; + } + + this.showSuggestions = false; + this.selectedIndex = -1; + + let found = this.markers.find(m => m.popData.name.toLowerCase().includes(query)); + + if (!found) { + const hiddenPop = this.allPops.find(p => p.name.toLowerCase().includes(query)); + if (hiddenPop) { + const category = hiddenPop.category || 99; + if (!this.visibleCategories[category]) { + this.visibleCategories[category] = true; + this.updateMap(false); + found = this.markers.find(m => m.popData.id === hiddenPop.id); + } + } + } + + if (found) { + this.map.flyTo(found.getLatLng(), 15); + setTimeout(() => { + found.openPopup(); + }, 500); + } else { + alert('Kein POP gefunden (oder keine GPS Koordinaten).'); + } + }, + clearSearch() { + this.searchQuery = ''; + this.filteredPops = []; + this.showSuggestions = false; + this.selectedIndex = -1; + const bounds = L.latLngBounds(); + this.markers.forEach(m => bounds.extend(m.getLatLng())); + if (this.markers.length > 0) { + this.map.fitBounds(bounds, {padding: [50, 50]}); + } + } + } +}); \ No newline at end of file diff --git a/public/js/pages/Pop/Pop.js b/public/js/pages/Pop/PopView.js similarity index 88% rename from public/js/pages/Pop/Pop.js rename to public/js/pages/Pop/PopView.js index d4b337192..49e765e7f 100644 --- a/public/js/pages/Pop/Pop.js +++ b/public/js/pages/Pop/PopView.js @@ -11,12 +11,19 @@ Vue.component('Pop', { Pop hinzufügen + + + @@ -45,6 +52,7 @@ Vue.component('Pop', { +
    `, data() { @@ -60,7 +68,8 @@ Vue.component('Pop', { {value: '1', text: 'Outdoor (Kasten/Schrank)'}, {value: '2', text: 'Indoor (Keller Gebäude)'}, {value: '3', text: 'Sender/Funk (Sendemast)'}, - {value: '4', text: 'Container (Garage, Container)'}]}, + {value: '4', text: 'Container (Garage, Container)'}, + {value: '99', text: 'Unbekannt'}]}, {text: 'Netzgebiet', key: 'networkArea', class: 'text-center', // TODO: fix autocomplete Filter // filter: 'autocomplete', diff --git a/public/js/pages/pop/detail.js b/public/js/pages/Pop/detail/detail.js similarity index 100% rename from public/js/pages/pop/detail.js rename to public/js/pages/Pop/detail/detail.js diff --git a/public/js/pages/Pop/fiber.js b/public/js/pages/Pop/detail/fiber.js similarity index 85% rename from public/js/pages/Pop/fiber.js rename to public/js/pages/Pop/detail/fiber.js index 20ce6510f..2b7a18d4a 100644 --- a/public/js/pages/Pop/fiber.js +++ b/public/js/pages/Pop/detail/fiber.js @@ -71,7 +71,7 @@ $(document).ready(function () { if (!$menu.length) return; $menu.removeAttr('x-placement'); if ($menu.parent()[0] !== document.body) $menu.detach().appendTo('body'); - $menu.css({ display: 'block' }).addClass('show'); + $menu.css({display: 'block'}).addClass('show'); const el = $menu[0]; const w = $menu.outerWidth(), h = $menu.outerHeight(); const vw = window.innerWidth, vh = window.innerHeight; @@ -174,7 +174,7 @@ $(document).ready(function () { case 'delete': if (confirm("Kabel '" + cableName + "' wirklich entfernen?")) { - $.post(linkRemoveCable, { id: cableId }, function (response) { + $.post(linkRemoveCable, {id: cableId}, function (response) { if (response.success) { let currentSide = currentCableElement.closest('tbody').data('side'); updateAllRackViews(rackid, currentSide); @@ -308,7 +308,7 @@ $(document).ready(function () { initCableSearch(); }); -function loadFiberPlanCableDetails(cableName, fiberStart = null, fiberEnd = null) { +function loadFiberPlanCableDetails(cableName, fiberStart = null, fiberEnd = null, homeId = null) { if (cableName) { cableName = cableName.split(' ')[0]; } @@ -316,7 +316,8 @@ function loadFiberPlanCableDetails(cableName, fiberStart = null, fiberEnd = null const modalBody = $('#fiberPlanCableModalBody'); modalHistory.push({ type: 'cable', - data: cableName + data: cableName, + homeId: homeId }); modalBody.html(` @@ -352,7 +353,7 @@ function loadFiberPlanCableDetails(cableName, fiberStart = null, fiberEnd = null editBtn.attr('data-cable-id', cable.id); editBtn.attr('data-cable-name', cable.description); editBtn.show(); - renderFiberPlanCableDetails(response.result.cable, fiberStart, fiberEnd); + renderFiberPlanCableDetails(response.result.cable, fiberStart, fiberEnd, homeId); } else { $('#modal-edit-cable-btn').hide(); modalBody.html(` @@ -374,9 +375,19 @@ function loadFiberPlanCableDetails(cableName, fiberStart = null, fiberEnd = null }); } -function renderFiberPlanCableDetails(cable, fiberStart = null, fiberEnd = null) { +function renderFiberPlanCableDetails(cable, fiberStart = null, fiberEnd = null, homeId = null) { const modalBody = $('#fiberPlanCableModalBody'); + let displayFibers = cable.fiber_list; + if (homeId) { + displayFibers = displayFibers.filter(f => f.home_id === homeId); + $('#fiberPlanCableModal .modal-title').html(' Fasern für Hausanschluss: ' + homeId + + ''); + } else { + $('#fiberPlanCableModal .modal-title').html(' Kabel-Details' + + ''); + } + const usedFibers = cable.fiber_list.filter(f => f.home_id || f.branch_type).length; const freeFibers = cable.fiber_list.filter(f => !f.home_id && !f.branch_type).length; @@ -486,7 +497,7 @@ function renderFiberPlanCableDetails(cable, fiberStart = null, fiberEnd = null)
    - Fasern (${cable.fiber_list.length}) + Fasern (${displayFibers.length})
    @@ -506,8 +517,8 @@ function renderFiberPlanCableDetails(cable, fiberStart = null, fiberEnd = null) `; - if (cable.fiber_list.length > 0) { - cable.fiber_list.forEach(fiber => { + if (displayFibers.length > 0) { + displayFibers.forEach(fiber => { const colorStyle = fiber.fiber_color_hex ? `style="background-color: ${fiber.fiber_color_hex}"` : ''; const statusBadge = getFiberStatusBadge(fiber); const info = getFiberShortInfo(fiber); @@ -517,7 +528,7 @@ function renderFiberPlanCableDetails(cable, fiberStart = null, fiberEnd = null) ${(fiber.bundle_color ? '' : '') || (fiber.bundle_nr ? '' : '-')}` : '-'; // const hasPath = fiber.branch_type === 'Abzweigkabel' || fiber.home_id; - const hasPath = fiber.home_id && fiber.home_id !== '' || fiber.final_home_id ; + const hasPath = fiber.home_id && fiber.home_id !== '' || fiber.final_home_id; const actionButton = hasPath ? ` + + + + + + + `; + + $('#fiberRouteMapModal').remove(); + + const fsElement = document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement; + if (fsElement) { + $(fsElement).append(mapModalHtml); + } else { + $('body').append(mapModalHtml); + } + + $('#fiberRouteMapModal').modal('show'); + + $('#fiber-route-map').html(` +
    +
    +

    Lade Strecken...

    +
    + `); + + $.ajax({ + url: linkGetAllFiberPathsForHome, + method: 'GET', + data: {home_id: homeId}, + dataType: 'json', + success: function (response) { + if (response.status === 'OK' && response.result.paths) { + initializeMultiFiberMap(response.result.paths); + renderMultiCableRouteSchema(response.result.paths); + } else { + $('#fiber-route-map').html(` +
    + + Keine Strecken gefunden. +
    + `); + } + }, + error: function () { + $('#fiber-route-map').html(` +
    + + Fehler beim Laden der Daten. +
    + `); + } + }); +} + +function initializeMultiFiberMap(paths) { + setTimeout(function () { + // 1. Alte Karte sauber entfernen + if (window.fiberRouteMapInstance) { + window.fiberRouteMapInstance.remove(); + window.fiberRouteMapInstance = null; + } + + const mapContainer = document.getElementById('fiber-route-map'); + if (!mapContainer) return; + + mapContainer.innerHTML = ''; // Spinner entfernen + + const allBounds = L.featureGroup(); + let hasData = false; + + // 2. Neue Karte initialisieren + const map = L.map('fiber-route-map'); + window.fiberRouteMapInstance = map; + + L.tileLayer('https://mapsneu.wien.gv.at/basemap/{id}/normal/google3857/{z}/{y}/{x}.{imgtype}', { + maxZoom: 22, + id: "geolandbasemap", + imgtype: "png" + }).addTo(map); + + // 3. Daten durchlaufen und zeichnen + paths.forEach((path, pathIndex) => { + const fiber = path.fiber; + + // WICHTIG: Hier holen wir die Kabeldaten. + // Entweder aus 'all_cables' (neues PHP) oder 'chain' (altes Fallback) + let segmentsToDraw = []; + + if (fiber && fiber.all_cables) { + segmentsToDraw = fiber.all_cables; + } else if (path.chain) { + segmentsToDraw = path.chain; + } + + // Zufallsfarbe oder basierend auf Index, falls keine Farbe da ist + const defaultColor = ['#3388ff', '#ff3333', '#33ff33', '#ffaa00'][pathIndex % 4]; + + segmentsToDraw.forEach(segment => { + let routePoints = []; + + // Koordinaten parsen + if (segment.coordinates) { + let coords = segment.coordinates; + if (typeof coords === 'string') { + try { coords = JSON.parse(coords); } catch (e) {} + } + if (Array.isArray(coords) && coords.length > 0) { + routePoints = coords.map(c => [parseFloat(c.gps_lat), parseFloat(c.gps_long)]); + } + } + + if (routePoints.length > 0) { + hasData = true; + + // Linie zeichnen + const polyline = L.polyline(routePoints, { + color: fiber.fiber_color_hex || defaultColor, + weight: 4, + opacity: 0.7 + }).bindTooltip(` + ${segment.description || segment.cable_name || 'Kabel'}
    + Faser: ${fiber.fiber_nr_cable} (${fiber.fiber_color}) + `, { sticky: true }); + + allBounds.addLayer(polyline); + } + }); + + // Stationen (Verteiler/POP) zeichnen, falls vorhanden (im Hauptkabel-Info) + if (fiber.cable_info && fiber.cable_info.cable_route_full) { + fiber.cable_info.cable_route_full.forEach(station => { + if (station.gps_lat && station.gps_long) { + const lat = parseFloat(station.gps_lat); + const lng = parseFloat(station.gps_long); + + // Icon Logik + let iconColor = '#3388ff'; + let iconName = 'circle'; + + if (station.type === 'pop') { iconColor = '#acf0ab'; iconName = 'village'; } + else if (station.object_type == 4) { iconColor = '#FF6B6B'; iconName = 'cross'; } // Abzweig + else if (station.object_type == 2) { iconColor = '#ffa726'; iconName = 'square'; } // Schacht + + const marker = L.marker([lat, lng], { + icon: L.MakiMarkers.icon({ icon: iconName, color: iconColor, size: 's' }) + }).bindPopup(`${station.name}`); + + allBounds.addLayer(marker); + } + }); + } + }); + + if (hasData) { + allBounds.addTo(map); + map.fitBounds(allBounds.getBounds(), { padding: [50, 50] }); + } else { + // Fallback: Wenn keine Geodaten da sind, leere Karte auf Wien zentrieren + map.setView([48.2082, 16.3738], 12); + // Optional: Nachricht anzeigen, aber Karte nicht killen + // L.popup().setLatLng([48.2082, 16.3738]).setContent("Keine GPS-Daten für diese Fasern.").openOn(map); + } + + }, 300); // Timeout lassen, damit das Modal sicher offen ist +} + +function renderMultiCableRouteSchema(paths) { + const container = $('#fiber-route-schema'); + container.empty(); + + if (!paths || paths.length === 0) { + container.html('
    Keine Daten verfügbar
    '); + return; + } + + paths.forEach((path, pathIndex) => { + const fiber = path.fiber; + const chain = path.chain; // Chain is [POP-Side ... Home-Side] + + let html = ` +
    +
    + + Faser #${fiber.fiber_nr_cable} + (Bündel ${fiber.bundle_nr}) - HomeID: ${fiber.home_id || '?'} +
    +
    + `; + + // 1. Start Node: POP + // Attempt to find POP name from the first segment's route + let popName = 'POP'; + if (chain.length > 0 && chain[0].cable_route_full) { + const popStation = chain[0].cable_route_full.find(s => s.type === 'pop'); + if (popStation) popName = popStation.name; + } + + html += ` +
    +
    + +
    +
    + ${popName} +
    +
    + `; + + chain.forEach((segment, segIndex) => { + const isLast = segIndex === chain.length - 1; + const segmentColor = segment.fiber_color_hex || '#666'; + const fiberInfo = `F${segment.fiber_nr} / B${segment.bundle_nr}`; + + const lineClass = (segment.type === 'branch') ? 'schema-line-dashed-horizontal' : 'schema-line-horizontal'; + + // Cable Line + html += ` +
    +
    +
    + ${segment.cable_name}
    + ${fiberInfo} +
    +
    +
    + `; + + // Next Node + let nodeClass = 'station-distributor'; + let icon = 'fa-sitemap'; + let iconColor = '#3388ff'; + let label = 'Verteiler'; + + if (isLast) { + // Destination of the last cable is the Home + nodeClass = 'station-customer'; + icon = 'fa-home'; + iconColor = '#c62828'; + label = 'Haus'; + if (fiber.address) { + label += `
    ${fiber.address}`; + } + } else { + // Intermediate Node (Distributor/Branch) + // We check the next segment to see where this one ends, + // or check this segment's destination info if available. + // Often the cable route of the current segment ends at the distributor where the next segment starts. + // We can try to get the name of the last station of this cable. + + if (segment.cable_route_full && segment.cable_route_full.length > 0) { + const lastStation = segment.cable_route_full[segment.cable_route_full.length - 1]; + if (lastStation) { + label = lastStation.name; + if (lastStation.object_type == 4) { + nodeClass = 'station-branch'; + icon = 'fa-code-branch'; + iconColor = '#b71c1c'; + } else if (lastStation.object_type == 2) { + nodeClass = 'station-manhole'; + icon = 'fa-square'; + iconColor = '#e65100'; + } + } + } + + if (segment.type === 'branch' && nodeClass === 'station-distributor') { + // Fallback for branch cable end if not clear + nodeClass = 'station-branch'; + icon = 'fa-code-branch'; + iconColor = '#b71c1c'; + label = 'Abzweig'; + } + } + + html += ` +
    +
    + +
    +
    + ${label} +
    +
    + `; + }); + + html += ` +
    +
    + `; + + container.append(html); + }); +} + + function loadFiberPath(fiberId) { const modal = $('#fiberPlanCableModal'); const modalBody = $('#fiberPlanCableModalBody'); @@ -627,7 +963,7 @@ function loadFiberPath(fiberId) { $.ajax({ url: linkGetFiberPath, method: 'GET', - data: { fiber_id: fiberId }, + data: {fiber_id: fiberId}, dataType: 'json', success: function (response) { if (response.status === 'OK' && response.result.fiber) { @@ -1155,7 +1491,7 @@ function showCableRouteMap(cableId, cableName) { $.ajax({ url: linkGetCableDetails, method: 'GET', - data: { cable_name: cableName }, + data: {cable_name: cableName}, dataType: 'json', success: function (response) { if (response.status === 'OK' && response.result.cable) { @@ -1421,9 +1757,9 @@ function initializeCableRouteMap(cable) { }); } - map.fitBounds(markerGroup.getBounds(), { padding: [50, 50] }); + map.fitBounds(markerGroup.getBounds(), {padding: [50, 50]}); - const legend = L.control({ position: 'bottomright' }); + const legend = L.control({position: 'bottomright'}); legend.onAdd = function (map) { const div = L.DomUtil.create('div', 'info legend'); div.style.backgroundColor = 'white'; @@ -1448,6 +1784,7 @@ function initializeCableRouteMap(cable) { }, 300); } + function renderCableRouteSchema(cable) { const schemaContainer = $('#cable-route-schema'); @@ -1461,14 +1798,14 @@ function renderCableRouteSchema(cable) { html += '
    '; cable.cable_route_full.forEach((station, index) => { - let style = { icon: 'fa-sitemap', cssClass: 'station-distributor', color: '#3388ff', typeLabel: 'Verteiler' }; + let style = {icon: 'fa-sitemap', cssClass: 'station-distributor', color: '#3388ff', typeLabel: 'Verteiler'}; let badgeClass = 'badge-info'; if (station.type === 'pop') { - style = { icon: 'fa-building', cssClass: 'station-pop', color: '#4caf50', typeLabel: 'POP' }; + style = {icon: 'fa-building', cssClass: 'station-pop', color: '#4caf50', typeLabel: 'POP'}; badgeClass = 'badge-success'; } else if (station.object_type == 2) { // Schacht - style = { icon: 'fa-archive', cssClass: 'station-manhole', color: '#ffa726', typeLabel: 'Schacht' }; + style = {icon: 'fa-archive', cssClass: 'station-manhole', color: '#ffa726', typeLabel: 'Schacht'}; badgeClass = 'badge-warning'; } @@ -1536,6 +1873,7 @@ function renderCableRouteSchema(cable) { $station.popover('show'); }); } + function showFiberRouteMap(fiberId) { const mapModalHtml = `