diff --git a/.gitignore b/.gitignore index 7940ae3c2..d44056d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,5 @@ Thumbs.db /Layout/default/DeviceDetail/ /Layout/default/DeviceDetail/ +mobile-presentation/ +nul diff --git a/Layout/andom-tec/header.php b/Layout/andom-tec/header.php index 0a71e3700..6813341bb 100644 --- a/Layout/andom-tec/header.php +++ b/Layout/andom-tec/header.php @@ -55,6 +55,7 @@ + diff --git a/Layout/default/Address/Form.php b/Layout/default/Address/Form.php index bca16447d..168824961 100644 --- a/Layout/default/Address/Form.php +++ b/Layout/default/Address/Form.php @@ -232,12 +232,22 @@ - + + can("Fibu")): ?>
- + +
+ + Wenn Bankeinzug aktiviert ist +
+
+ + +
+
- + can("Fibu")): ?>
diff --git a/Layout/default/Address/View.php b/Layout/default/Address/View.php index 808d4f9dc..69da6f0e2 100644 --- a/Layout/default/Address/View.php +++ b/Layout/default/Address/View.php @@ -144,8 +144,12 @@ BIC bank_account_bic?> + + Manuelle Rechnungen abbuchen bis (erfordert Bankeinzug) + manual_invoice_sepa_limit, 2, ",", ".")?> € can("Fibu")): ?> + Sepa Mandatsdatum sepa_date) ? date("d.m.Y", $address->sepa_date) : ""?> 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/Invoice/Index.php b/Layout/default/Invoice/Index.php index f80df6423..2f5f74e12 100644 --- a/Layout/default/Invoice/Index.php +++ b/Layout/default/Invoice/Index.php @@ -25,22 +25,52 @@ $pagination_entity_name = "Rechnungen";
+ + +
-
- Fakt-Rechnungen Import +
+

Manuelle Rechnungen

+ +
+
+
"> +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
+
-

Filter

+

Contract Rechnungen

">
@@ -399,6 +429,22 @@ $pagination_entity_name = "Rechnungen"; todayBtn: 'linked', autoclose: true }); + $('#manual_invoice_date_from').datepicker({ + orientation: "bottom", + language: 'de', + format: "dd.mm.yyyy", + showWeekDays: true, + todayBtn: 'linked', + autoclose: true + }); + $('#manual_invoice_date_to').datepicker({ + orientation: "bottom", + language: 'de', + format: "dd.mm.yyyy", + showWeekDays: true, + todayBtn: 'linked', + autoclose: true + }); $('.datepicker').datepicker({ orientation: "bottom", language: 'de', diff --git a/Layout/default/ManualInvoice/PDF_HEADER.html b/Layout/default/ManualInvoice/PDF_HEADER.html index 075f2c9bc..49e6196c0 100644 --- a/Layout/default/ManualInvoice/PDF_HEADER.html +++ b/Layout/default/ManualInvoice/PDF_HEADER.html @@ -68,9 +68,7 @@ - + {{ qrCodeHtml }} + ctags) && count($preorder->ctags)): ?> @@ -706,6 +721,7 @@ + diff --git a/Layout/default/Voicenumber/Form.php b/Layout/default/Voicenumber/Form.php index 1d2e94ac7..ed90b52e4 100644 --- a/Layout/default/Voicenumber/Form.php +++ b/Layout/default/Voicenumber/Form.php @@ -117,6 +117,7 @@ + diff --git a/Layout/default/Voicenumberblock/include/block-detail.php b/Layout/default/Voicenumberblock/include/block-detail.php index 918bd4ebb..d9fa9ff16 100644 --- a/Layout/default/Voicenumberblock/include/block-detail.php +++ b/Layout/default/Voicenumberblock/include/block-detail.php @@ -17,14 +17,16 @@ id, $num_from) ? $num_from[$block->id] : $block->first), $block->last) as $number): ?> $block->id, 'number' => $number]) ?> - + "> @@ -40,7 +42,7 @@ Lokal - + " : "", - "{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "" : "", + "{{ leistungszeitraumHtml }}" => ($invoice->performance_period ?? '') ? "" : "", + "{{ externeReferenzHtml }}" => ($invoice->external_reference ?? '') ? "" : "", "{{ vatHtml }}" => ($invoice->uid ?? '') ? "" : "", - "{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2)) + "{{ qrCodeHtml }}" => ($invoice->total_gross ?? 0) >= 0 + ? '' + : '' ]; $headerHtml = str_replace(array_keys($replacements), array_values($replacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html")); @@ -208,8 +210,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 +222,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 +245,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 +281,9 @@ class ManualInvoiceController extends TTCrud $html .= '
'; $html .= 'XINON GmbH | www.xinon.at'; - $html .= '
'; + $html .= '
'; + $html .= ''; + $html .= ''; $mail = new PHPMailer(true); try { @@ -269,12 +298,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 +311,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(); @@ -320,10 +351,17 @@ class ManualInvoiceController extends TTCrud $me = new User(); $me->loadMe(); - // Log download in journal + // Update status to 'gesendet' (same as email) + if ($invoice->status === 'erstellt') { + $invoice->status = 'gesendet'; + $invoice->save(); + } + + // Log download in journal with status change ManualInvoiceJournalModel::create([ 'manualinvoiceId' => $id, 'text' => 'Rechnung heruntergeladen', + 'statusChange' => 'gesendet', 'createBy' => $me->id, 'create' => time() ]); @@ -349,20 +387,42 @@ 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['total_discount'] = $data['total_discount'] ?? $data['gesamtrabatt'] ?? 0; + $data['performance_period'] = $data['performance_period'] ?? $data['leistungszeitraum'] ?? null; + $data['introductory_text'] = $data['introductory_text'] ?? $data['einleitender_text'] ?? null; + $data['external_reference'] = $data['external_reference'] ?? $data['externe_referenz'] ?? null; + unset($data['gesamtrabatt'], $data['leistungszeitraum'], $data['einleitender_text'], $data['externe_referenz'], $data['billing_delivery']); + + $data['total'] = $data['total'] ?? 0; + $data['total_gross'] = $data['total_gross'] ?? 0; + $data['lock'] = 0; + $data['exported'] = 0; + + if (($data['billing_type'] ?? '') === 'sepa' && ($data['billingaddress_id'] ?? null)) { + $address = new Address($data['billingaddress_id']); + if ($address->id) { + $data['bank_account_bank'] = $address->bank_account_bank; + $data['bank_account_owner'] = $address->bank_account_owner; + $data['bank_account_iban'] = str_replace(' ', '', $address->bank_account_iban ?? ''); + $data['bank_account_bic'] = str_replace(' ', '', $address->bank_account_bic ?? ''); + if ($address->sepa_date) { + $data['sepa_date'] = date('Y-m-d', $address->sepa_date); + } + $data['sepa_id'] = 'R' . ($data['fibu_account_number'] ?? ''); + } + } + + $data['create_by'] = $me->id; + $data['edit_by'] = $me->id; + $data['create'] = time(); + $data['edit'] = time(); return true; } @@ -389,9 +449,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 @@ -432,23 +498,39 @@ class ManualInvoiceController extends TTCrud $me->loadMe(); foreach ($this->tempPositions as $position) { - // Skip empty positions - if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue; + $articleName = $position['warehousearticle_name'] ?? $position['product_name'] ?? ''; + if (empty($articleName) || ($position['amount'] ?? 0) == 0) continue; - // Map _group to position_group - $groupName = $position['_group'] ?? null; - unset($position['_group']); + $amount = floatval($position['amount']); + $price = floatval($position['price']); + $discount = floatval($position['discount'] ?? 0); + $vatrate = floatval($position['vatrate'] ?? 0); + $priceAfterDiscount = $amount * $price * (1 - $discount / 100); + $priceGross = $priceAfterDiscount * (1 + $vatrate / 100); - ManualInvoicepositionModel::create(array_merge([ + ManualInvoicepositionModel::create([ 'manualinvoice_id' => $invoiceId, - 'position_group' => $groupName, - 'unit' => 'Stk.', - 'discount' => 0, + 'position_group' => $position['_group'] ?? null, + 'matchcode' => $position['matchcode'] ?? null, + 'warehousearticle_id' => $position['warehousearticle_id'] ?? $position['product_id'] ?? 0, + 'warehousearticle_name' => $articleName, + 'product_info' => $position['product_info'] ?? '', + 'amount' => $amount, + 'unit' => $position['unit'] ?? 'Stk.', + 'price' => $price, + 'discount' => $discount, + 'price_total' => $priceAfterDiscount, + 'price_gross' => $priceGross, + 'vatrate' => $vatrate, + 'fibu_cost_account' => $position['fibu_cost_account'] ?? null, + 'fibu_cost_account_legacy' => $position['fibu_cost_account_legacy'] ?? null, + 'fibu_taxcode' => $position['fibu_taxcode'] ?? null, + 'options' => $position['options'] ?? null, 'create_by' => $me->id, 'edit_by' => $me->id, 'create' => time(), 'edit' => time() - ], $position)); + ]); } $this->tempPositions = []; } @@ -458,17 +540,13 @@ class ManualInvoiceController extends TTCrud $positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]); $subtotal = array_sum(array_column($positions, 'price_total')); + $totalDiscount = $invoice->total_discount ?? 0; + $netTotal = $subtotal * (1 - $totalDiscount / 100); - // Apply gesamtrabatt (total discount) if exists - $gesamtrabatt = $invoice->gesamtrabatt ?? 0; - $discountAmount = $subtotal * ($gesamtrabatt / 100); - $netTotal = $subtotal - $discountAmount; - - // Calculate gross total with VAT applied after discount $grossTotal = 0; foreach ($positions as $pos) { $positionNet = $pos->price_total; - $positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100); + $positionAfterDiscount = $positionNet * (1 - $totalDiscount / 100); $grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100); } @@ -530,11 +608,11 @@ class ManualInvoiceController extends TTCrud 'id' => $pos->id, 'manualinvoice_id' => $pos->manualinvoice_id, '_group' => $pos->position_group ?? '', - 'billing_id' => $pos->billing_id, - 'contract_id' => $pos->contract_id, 'matchcode' => $pos->matchcode, - 'product_id' => $pos->product_id, - 'product_name' => $pos->product_name, + 'warehousearticle_id' => $pos->warehousearticle_id, + 'warehousearticle_name' => $pos->warehousearticle_name, + 'product_id' => $pos->warehousearticle_id, + 'product_name' => $pos->warehousearticle_name, 'product_info' => $pos->product_info, 'amount' => $pos->amount, 'unit' => $pos->unit ?? 'Stk.', @@ -580,19 +658,20 @@ class ManualInvoiceController extends TTCrud foreach ($existingCredits as $credit) { foreach ($credit->getProperty('positions') as $creditPos) { - $key = $creditPos->product_id . '_' . $creditPos->matchcode; + $key = $creditPos->warehousearticle_id . '_' . $creditPos->matchcode; $creditedAmounts[$key] = ($creditedAmounts[$key] ?? 0) + abs($creditPos->amount); } } $availablePositions = []; foreach ($positions as $pos) { - $key = $pos->product_id . '_' . $pos->matchcode; + $key = $pos->warehousearticle_id . '_' . $pos->matchcode; $availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0); if ($availableAmount > 0) { $availablePositions[] = [ 'id' => $pos->id, - 'product_name' => $pos->product_name, + 'warehousearticle_name' => $pos->warehousearticle_name, + 'product_name' => $pos->warehousearticle_name, 'product_info' => $pos->product_info, 'original_amount' => $pos->amount, 'credited_amount' => $creditedAmounts[$key] ?? 0, @@ -600,7 +679,8 @@ class ManualInvoiceController extends TTCrud 'unit' => $pos->unit ?? 'Stk.', 'price' => $pos->price, 'vatrate' => $pos->vatrate, - 'product_id' => $pos->product_id, + 'warehousearticle_id' => $pos->warehousearticle_id, + 'product_id' => $pos->warehousearticle_id, 'matchcode' => $pos->matchcode, 'fibu_cost_account' => $pos->fibu_cost_account, 'fibu_taxcode' => $pos->fibu_taxcode @@ -626,6 +706,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(); @@ -634,10 +720,10 @@ class ManualInvoiceController extends TTCrud $invoiceData = [ 'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(), 'invoice_date' => time(), - 'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null, - 'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number, - 'externe_referenz' => $originalInvoice->externe_referenz ?? null, - 'gesamtrabatt' => 0, + 'performance_period' => $originalInvoice->performance_period ?? null, + 'introductory_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number, + 'external_reference' => $originalInvoice->external_reference ?? null, + 'total_discount' => 0, 'owner_id' => $originalInvoice->owner_id, 'billingaddress_id' => $originalInvoice->billingaddress_id, 'customer_number' => $originalInvoice->customer_number, @@ -663,7 +749,6 @@ class ManualInvoiceController extends TTCrud 'email' => $originalInvoice->email, 'uid' => $originalInvoice->uid, 'billing_type' => $originalInvoice->billing_type, - 'billing_delivery' => $originalInvoice->billing_delivery, 'bank_account_bank' => $originalInvoice->bank_account_bank, 'bank_account_owner' => $originalInvoice->bank_account_owner, 'bank_account_iban' => $originalInvoice->bank_account_iban, @@ -673,6 +758,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 +768,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) { @@ -688,8 +776,8 @@ class ManualInvoiceController extends TTCrud ManualInvoicepositionModel::create([ 'manualinvoice_id' => $creditInvoiceId, 'position_group' => null, - 'product_id' => $pos['product_id'], - 'product_name' => $pos['product_name'], + 'warehousearticle_id' => $pos['warehousearticle_id'] ?? $pos['product_id'] ?? 0, + 'warehousearticle_name' => $pos['warehousearticle_name'] ?? $pos['product_name'] ?? '', 'product_info' => $pos['product_info'] ?? '', 'amount' => -abs($pos['amount']), 'unit' => $pos['unit'] ?? 'Stk.', @@ -701,8 +789,6 @@ class ManualInvoiceController extends TTCrud 'matchcode' => $pos['matchcode'] ?? null, 'fibu_cost_account' => $pos['fibu_cost_account'] ?? null, 'fibu_taxcode' => $pos['fibu_taxcode'] ?? null, - 'contract_id' => 0, - 'billing_id' => null, 'create_by' => $me->id, 'edit_by' => $me->id, 'create' => time(), @@ -718,7 +804,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 +822,119 @@ 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; + } + + $vatgroupId = $article->vatgroup_id; + $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; + } + + $prices = []; + if (!empty($article->cheapestSellPrice)) { + $pricesData = json_decode($article->cheapestSellPrice, true); + if (is_array($pricesData)) { + $prices = $pricesData; + } + } + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'title' => $article->title, + 'articleNumber' => $article->articleNumber, + 'description' => $article->description, + 'vatgroup_id' => $article->vatgroup_id, + 'unit' => $article->unit + ], + 'prices' => $prices, + 'vatgroup_id' => $vatgroupId, + 'fibu_cost_account' => $vatrate->account, + 'fibu_cost_account_legacy' => $vatrate->legacy_account, + 'fibu_taxcode' => $vatrate->taxcode, + 'vatrate' => $vatrate->rate + ]); + } + + protected function getCustomerBillingInfoAction() { + $addressId = $_GET['address_id'] ?? null; + $vatgroupId = $_GET['vatgroup_id'] ?? 2; + + if (!$addressId) { + self::returnJson(['success' => false, 'message' => 'Address ID required']); + return; + } + + $address = new Address($addressId); + if (!$address->id) { + self::returnJson(['success' => false, 'message' => 'Address not found']); + return; + } + + $vatarea = 'domestic'; + if ($address->country_id) { + $country = new Country($address->country_id); + if ($country->id && $country->isocode != TT_HOMECOUNTRY_ISOCODE) { + $vatarea = $country->is_eu ? 'eu' : 'other'; + } + } + + if ($address->uid && substr(strtolower(preg_replace('/[^a-z0-9]/i', '', $address->uid)), 0, 3) == 'atu') { + $vatarea = 'domestic'; + } + + $vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]); + $taxText = $vatrate ? $vatrate->invoice_text : ''; + + $db = $this->db(); + $sepaLimit = null; + $res = $db->query("SELECT manual_invoice_sepa_limit FROM Address WHERE id = " . intval($addressId)); + if ($res && $row = $res->fetch_assoc()) { + $sepaLimit = $row['manual_invoice_sepa_limit'] ? floatval($row['manual_invoice_sepa_limit']) : null; + } + + self::returnJson([ + 'success' => true, + 'billing_type' => $address->billing_type ?: 'invoice', + 'manual_invoice_sepa_limit' => $sepaLimit, + 'vatarea' => $vatarea, + 'tax_text' => $taxText, + 'bank_account_bank' => $address->bank_account_bank, + 'bank_account_owner' => $address->bank_account_owner, + 'bank_account_iban' => $address->bank_account_iban, + 'bank_account_bic' => $address->bank_account_bic, + 'sepa_date' => $address->sepa_date, + 'sepa_id' => $address->sepa_id + ]); + } + + protected function getTaxTextAction() { + $vatgroupId = $_GET['vatgroup_id'] ?? 2; + $vatarea = $_GET['vatarea'] ?? 'domestic'; + + $vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]); + + self::returnJson([ + 'success' => true, + 'tax_text' => $vatrate ? $vatrate->invoice_text : '', + 'vatrate' => $vatrate ? $vatrate->rate : 20 + ]); + } } \ No newline at end of file diff --git a/application/ManualInvoice/ManualInvoiceModel.php b/application/ManualInvoice/ManualInvoiceModel.php index 77408527e..e92102a85 100644 --- a/application/ManualInvoice/ManualInvoiceModel.php +++ b/application/ManualInvoice/ManualInvoiceModel.php @@ -4,10 +4,10 @@ class ManualInvoiceModel extends TTCrudBaseModel { public int $id; public ?string $invoice_number; public int $invoice_date; - public ?string $leistungszeitraum; - public ?string $einleitender_text; - public ?string $externe_referenz; - public float $gesamtrabatt; + public ?string $performance_period; + public ?string $introductory_text; + public ?string $external_reference; + public float $total_discount; public int $owner_id; public int $billingaddress_id; public int $customer_number; @@ -33,7 +33,6 @@ class ManualInvoiceModel extends TTCrudBaseModel { public ?string $email; public ?string $uid; public string $billing_type; - public string $billing_delivery; public ?string $bank_account_bank; public ?string $bank_account_owner; public ?string $bank_account_iban; @@ -44,6 +43,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/ManualInvoiceposition/ManualInvoicepositionModel.php b/application/ManualInvoiceposition/ManualInvoicepositionModel.php index ddaca0479..1a3d71227 100644 --- a/application/ManualInvoiceposition/ManualInvoicepositionModel.php +++ b/application/ManualInvoiceposition/ManualInvoicepositionModel.php @@ -4,11 +4,9 @@ class ManualInvoicepositionModel extends TTCrudBaseModel { public int $id; public ?int $manualinvoice_id; public ?string $position_group; - public ?int $billing_id; - public int $contract_id; public ?string $matchcode; - public int $product_id; - public string $product_name; + public int $warehousearticle_id; + public string $warehousearticle_name; public ?string $product_info; public float $amount; public string $unit; diff --git a/application/MobileApp/MobileAppController.php b/application/MobileApp/MobileAppController.php new file mode 100644 index 000000000..3a356ceb3 --- /dev/null +++ b/application/MobileApp/MobileAppController.php @@ -0,0 +1,474 @@ +needlogin = false; + $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) { + // Check authentication for API calls + if (!$this->user || !$this->user->id) { + self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401); + return; + } + + // Find module directory (case-insensitive) + $moduleName = $this->findModuleDirectory(APPDIR . "MobileApp/Modules", $module); + if (!$moduleName) { + self::returnJson(['success' => false, 'error' => "Module not found: {$module}"], 404); + return; + } + + // Find submodule directory (case-insensitive) + $submoduleName = $this->findModuleDirectory(APPDIR . "MobileApp/Modules/{$moduleName}", $submodule); + if (!$submoduleName) { + self::returnJson(['success' => false, 'error' => "Submodule not found: {$module}/{$submodule}"], 404); + return; + } + + // Build handler path + $handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php"; + + if (!file_exists($handlerFile)) { + self::returnJson(['success' => false, 'error' => "Handler 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); + } + + /** + * Find directory with case-insensitive matching + * Required for Linux compatibility where filesystem is case-sensitive + */ + protected function findModuleDirectory($basePath, $name) { + if (!is_dir($basePath)) return null; + $dirs = scandir($basePath); + foreach ($dirs as $dir) { + if ($dir === '.' || $dir === '..') continue; + if (strtolower($dir) === strtolower($name) && is_dir($basePath . '/' . $dir)) { + return $dir; + } + } + return null; + } +} diff --git a/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php new file mode 100644 index 000000000..ddd02ca85 --- /dev/null +++ b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php @@ -0,0 +1,409 @@ + '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]); + } + + 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, + ] + ]); + } + + 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 : '', + ] + ]); + } + + 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]); + } + + 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]); + } + + 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]); + } + } + + 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, + ] + ]); + } + + 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]); + } + + 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..d3e499019 --- /dev/null +++ b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php @@ -0,0 +1,581 @@ +title); + if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') { + $locations[] = [ + 'id' => $location->id, + 'title' => $location->title, + ]; + } + } + + self::returnJson(['success' => true, 'locations' => $locations]); + } + + 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 : '', + ] + ]); + } + + 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]); + } + + 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]); + } + } + + 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]); + } + + 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, + ] + ]); + } + + 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]); + } + + 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]); + } + + public function getPendingOrdersAction() { + $db = $this->db(); + + $result = $db->query("SELECT wo.*, wd.name as distributorName + FROM WarehouseOrder wo + LEFT JOIN WarehouseDistributor wd ON wd.id = wo.distributorId + WHERE wo.status IN ('sent', 'partiallyDelivered') + ORDER BY wo.`create` DESC"); + + $orders = []; + while ($row = $result->fetch_assoc()) { + $positions = json_decode($row['positions'], true) ?: []; + $totalItems = array_sum(array_column($positions, 'amount')); + + // Calculate days since sent + $daysSinceSent = 0; + if (!empty($row['create'])) { + $daysSinceSent = floor((time() - intval($row['create'])) / 86400); + } + + $orders[] = [ + 'id' => intval($row['id']), + 'orderNumber' => $row['orderNumber'], + 'distributorName' => $row['distributorName'] ?? 'Unbekannt', + 'status' => $row['status'], + 'statusLabel' => $row['status'] === 'sent' ? 'Versendet' : 'Teilweise geliefert', + 'totalItems' => $totalItems, + 'positionCount' => count($positions), + 'daysSinceSent' => $daysSinceSent, + 'create' => date('d.m.Y', $row['create']), + ]; + } + + self::returnJson(['success' => true, 'orders' => $orders]); + } + + public function getOrderForReceivingAction() { + $orderId = intval($this->request->orderId ?? 0); + + if ($orderId <= 0) { + self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']); + return; + } + + $order = WarehouseOrderModel::get($orderId); + if (!$order) { + self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']); + return; + } + + if (!in_array($order->status, ['sent', 'partiallyDelivered'])) { + self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']); + return; + } + + $distributor = WarehouseDistributorModel::get($order->distributorId); + $positions = json_decode($order->positions, true) ?: []; + + // Get already delivered quantities from linked movements + $linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : []; + $deliveredByArticle = []; + + foreach ($linkedMovementIds as $movementId) { + $movement = WarehouseMovementModel::get($movementId); + if ($movement && $movement->movementType === 'IN') { + if (!isset($deliveredByArticle[$movement->articleId])) { + $deliveredByArticle[$movement->articleId] = 0; + } + $deliveredByArticle[$movement->articleId] += $movement->quantity; + } + } + + // Enrich positions with article details and delivered quantities + $enrichedPositions = []; + foreach ($positions as $index => $pos) { + $articleId = intval($pos['article']); + $article = WarehouseArticleModel::get($articleId); + + $orderedQty = floatval($pos['amount']); + $deliveredQty = $deliveredByArticle[$articleId] ?? 0; + $remainingQty = max(0, $orderedQty - $deliveredQty); + + $enrichedPositions[] = [ + 'index' => $index, + 'articleId' => $articleId, + 'articleNumber' => $article ? $article->articleNumber : '', + 'articleTitle' => $article ? $article->title : ($pos['article_text'] ?? 'Unbekannt'), + 'unit' => $article ? ($article->unit ?? 'Stk.') : 'Stk.', + 'orderedQty' => $orderedQty, + 'deliveredQty' => $deliveredQty, + 'remainingQty' => $remainingQty, + 'receivingQty' => $remainingQty, // Default to remaining + ]; + } + + self::returnJson([ + 'success' => true, + 'order' => [ + 'id' => $order->id, + 'orderNumber' => $order->orderNumber, + 'distributorName' => $distributor ? $distributor->name : 'Unbekannt', + 'status' => $order->status, + 'note' => $order->note, + 'create' => date('d.m.Y H:i', $order->create), + ], + 'positions' => $enrichedPositions + ]); + } + + public function submitOrderReceivingAction() { + $postData = $this->getPostData(); + + $orderId = intval($postData['orderId'] ?? 0); + $locationId = intval($postData['locationId'] ?? 0); + $positions = $postData['positions'] ?? []; + $deliveryNoteFileId = $postData['deliveryNoteFileId'] ?? null; + $note = $postData['note'] ?? null; + + // Validation + if ($orderId <= 0) { + self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']); + return; + } + + if ($locationId <= 0) { + self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']); + return; + } + + if (empty($positions)) { + self::returnJson(['success' => false, 'message' => 'Keine Positionen angegeben']); + return; + } + + $order = WarehouseOrderModel::get($orderId); + if (!$order) { + self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']); + return; + } + + if (!in_array($order->status, ['sent', 'partiallyDelivered'])) { + self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']); + return; + } + + $db = $this->db(); + $createdMovementIds = []; + $totalReceived = 0; + + // Create movements for each position with quantity > 0 + foreach ($positions as $pos) { + $articleId = intval($pos['articleId'] ?? 0); + $quantity = floatval($pos['quantity'] ?? 0); + + if ($articleId <= 0 || $quantity <= 0) { + continue; + } + + // Find or create WarehouseItem + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + + $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; + $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0; + $newQty = $currentQty + $quantity; + + // Update or create WarehouseItem + if ($warehouseItem) { + $db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}"); + $warehouseItemId = $warehouseItem->id; + } else { + $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`) + VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")"); + $warehouseItemId = $db->insert_id; + } + + // Create movement record + $movementNote = "Lagereingang aus Bestellung {$order->orderNumber}"; + if ($note) { + $movementNote .= " - " . $note; + } + $noteEscaped = "'" . $db->escape($movementNote) . "'"; + + $db->query("INSERT INTO WarehouseMovement + (movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, linkedOrderId, note, userId, createBy, `create`) + VALUES ('IN', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, 'Warenlieferung', {$orderId}, {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $movementId = $db->insert_id; + + // Generate movement number + $movementNumber = WarehouseMovementModel::generateMovementNumber(); + $db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}"); + + $createdMovementIds[] = $movementId; + $totalReceived += $quantity; + } + + if (empty($createdMovementIds)) { + self::returnJson(['success' => false, 'message' => 'Keine Mengen eingegeben']); + return; + } + + // Update order with linked movement IDs + $existingMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : []; + $allMovementIds = array_merge($existingMovementIds, $createdMovementIds); + + // Update delivery note file IDs if provided + $existingFileIds = $order->deliveryNoteFileIds ? json_decode($order->deliveryNoteFileIds, true) : []; + if ($deliveryNoteFileId) { + $existingFileIds[] = $deliveryNoteFileId; + } + + // Determine new status - check if all items are now fully delivered + $orderPositions = json_decode($order->positions, true) ?: []; + $allFullyDelivered = true; + + // Get all delivered quantities including new ones + $deliveredByArticle = []; + foreach ($allMovementIds as $movementId) { + $movement = WarehouseMovementModel::get($movementId); + if ($movement && $movement->movementType === 'IN') { + if (!isset($deliveredByArticle[$movement->articleId])) { + $deliveredByArticle[$movement->articleId] = 0; + } + $deliveredByArticle[$movement->articleId] += $movement->quantity; + } + } + + foreach ($orderPositions as $pos) { + $articleId = intval($pos['article']); + $orderedQty = floatval($pos['amount']); + $deliveredQty = $deliveredByArticle[$articleId] ?? 0; + + if ($deliveredQty < $orderedQty) { + $allFullyDelivered = false; + break; + } + } + + $newStatus = $allFullyDelivered ? 'fullyDelivered' : 'partiallyDelivered'; + + // Update order + $orderAsArray = (array)$order; + $orderAsArray['linkedMovementIds'] = json_encode($allMovementIds); + $orderAsArray['deliveryNoteFileIds'] = json_encode($existingFileIds); + $orderAsArray['status'] = $newStatus; + WarehouseOrderModel::update($orderAsArray); + + // Create log entry + $logMessage = count($createdMovementIds) . " Lagerbewegung(en) erstellt via Mobile App."; + if ($note) { + $logMessage .= "\n" . $note; + } + + WarehouseLogModel::create([ + 'table' => 'WarehouseOrder', + 'rowId' => $orderId, + 'type' => 'statusChange', + 'message' => "Status geändert auf " . ($newStatus === 'fullyDelivered' ? 'Geliefert' : 'Teilweise geliefert') . ".\n" . $logMessage, + 'createBy' => $this->user->id, + 'create' => time() + ]); + + self::returnJson([ + 'success' => true, + 'message' => "{$totalReceived} Artikel empfangen. " . count($createdMovementIds) . " Lagerbewegung(en) erstellt.", + 'newStatus' => $newStatus, + 'createdMovementIds' => $createdMovementIds + ]); + } +} diff --git a/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php b/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php new file mode 100644 index 000000000..4464462e9 --- /dev/null +++ b/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php @@ -0,0 +1,821 @@ + ShippingNote module. + * API Base: /MobileApp/Lager/ShippingNote/{action} + */ +class ShippingNoteHandler extends MobileAppBaseHandler { + + protected $requiredPermission = 'WarehouseUser'; + + // Office coordinates for distance calculation + const OFFICE_LAT = 46.99552810791587; + const OFFICE_LNG = 15.7751923956463; + + public function initializeAction() { + $db = $this->db(); + $userId = $this->user->id; + + $userCar = null; + $sql = "SELECT id, number_plate, brand, model + FROM TimerecordingCar + WHERE user_id = {$userId} + AND (retired IS NULL OR retired = 0) + LIMIT 1"; + $result = $db->query($sql); + if ($result && $row = $result->fetch_assoc()) { + $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')); + if (!$carName) $carName = $row['number_plate']; + $userCar = [ + 'id' => intval($row['id']), + 'name' => $carName, + 'plate' => $row['number_plate'], + ]; + } + + $allCars = []; + $sql = "SELECT id, number_plate, brand, model + FROM TimerecordingCar + WHERE (retired IS NULL OR retired = 0) + ORDER BY brand, model ASC"; + $result = $db->query($sql); + while ($row = $result->fetch_assoc()) { + $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')); + if (!$carName) $carName = $row['number_plate']; + $allCars[] = [ + 'id' => intval($row['id']), + 'name' => $carName, + 'plate' => $row['number_plate'], + ]; + } + + $hourTypes = [ + ['id' => '', 'name' => 'Normal'], + ['id' => '50', 'name' => '+50%'], + ['id' => '100', 'name' => '+100%'], + ['id' => 'regie', 'name' => 'Regie'], + ]; + + $currentUser = [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'firstname' => $this->user->firstname ?? '', + 'lastname' => $this->user->lastname ?? '', + ]; + + self::returnJson([ + 'success' => true, + 'userCar' => $userCar, + 'allCars' => $allCars, + 'hourTypes' => $hourTypes, + 'currentUser' => $currentUser, + ]); + } + + /** + * Get customer by GPS location (nearest within radius) + * GET /MobileApp/Lager/ShippingNote/getCustomerByLocation?lat=X&lng=Y + */ + public function getCustomerByLocationAction() { + $lat = floatval($this->request->lat ?? 0); + $lng = floatval($this->request->lng ?? 0); + $radius = intval($this->request->radius ?? 200); // default 200 meters + + if (!$lat || !$lng) { + self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']); + return; + } + + $db = $this->db(); + + // Haversine formula for distance in meters + $sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long, + (6371000 * acos( + cos(radians({$lat})) * cos(radians(gps_lat)) * + cos(radians(gps_long) - radians({$lng})) + + sin(radians({$lat})) * sin(radians(gps_lat)) + )) AS distance + FROM Address + WHERE gps_lat IS NOT NULL + AND gps_long IS NOT NULL + AND customer_number > 0 + HAVING distance < {$radius} + ORDER BY distance ASC + LIMIT 1"; + + $result = $db->query($sql); + + if ($result && $row = $result->fetch_assoc()) { + // Build display name + $displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']); + + self::returnJson([ + 'success' => true, + 'found' => true, + 'customer' => [ + 'id' => intval($row['id']), + 'customerNumber' => $row['customer_number'], + 'displayName' => $displayName, + 'company' => $row['company'], + 'firstname' => $row['firstname'], + 'lastname' => $row['lastname'], + 'street' => $row['street'], + 'zip' => $row['zip'], + 'city' => $row['city'], + 'email' => $row['email'], + 'phone' => $row['phone'], + 'distance' => round(floatval($row['distance'])), + ] + ]); + } else { + self::returnJson([ + 'success' => true, + 'found' => false, + 'message' => 'Kein Kunde in der Nähe gefunden' + ]); + } + } + + /** + * Reverse geocode coordinates to address + * GET /MobileApp/Lager/ShippingNote/reverseGeocode?lat=X&lng=Y + */ + public function reverseGeocodeAction() { + $lat = floatval($this->request->lat ?? 0); + $lng = floatval($this->request->lng ?? 0); + + if (!$lat || !$lng) { + self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']); + return; + } + + // Use Google Maps Geocoding API + $apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : ''; + if (!$apiKey) { + self::returnJson(['success' => false, 'message' => 'Google Maps API nicht konfiguriert']); + return; + } + + $url = "https://maps.googleapis.com/maps/api/geocode/json?latlng={$lat},{$lng}&key={$apiKey}&language=de"; + + $response = @file_get_contents($url); + if (!$response) { + self::returnJson(['success' => false, 'message' => 'Geocoding fehlgeschlagen']); + return; + } + + $data = json_decode($response, true); + if ($data['status'] !== 'OK' || empty($data['results'])) { + self::returnJson(['success' => false, 'message' => 'Keine Adresse gefunden']); + return; + } + + // Parse address components + $result = $data['results'][0]; + $components = $result['address_components']; + + $street = ''; + $streetNumber = ''; + $zip = ''; + $city = ''; + + foreach ($components as $comp) { + if (in_array('route', $comp['types'])) { + $street = $comp['long_name']; + } + if (in_array('street_number', $comp['types'])) { + $streetNumber = $comp['long_name']; + } + if (in_array('postal_code', $comp['types'])) { + $zip = $comp['long_name']; + } + if (in_array('locality', $comp['types'])) { + $city = $comp['long_name']; + } + } + + $fullStreet = trim($street . ' ' . $streetNumber); + + self::returnJson([ + 'success' => true, + 'address' => [ + 'street' => $fullStreet, + 'zip' => $zip, + 'city' => $city, + 'formatted' => $result['formatted_address'] ?? '', + ] + ]); + } + + /** + * Search customers by name/company + * GET /MobileApp/Lager/ShippingNote/searchCustomers?query=X + */ + public function searchCustomersAction() { + $query = trim($this->request->query ?? ''); + + if (strlen($query) < 1) { + self::returnJson(['success' => true, 'customers' => []]); + return; + } + + $db = $this->db(); + + // Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst") + $words = preg_split('/\s+/', trim($query)); + $wordConditions = []; + + foreach ($words as $word) { + if (strlen($word) < 1) continue; + $escapedWord = $db->escape($word); + $wordConditions[] = "(company LIKE '%{$escapedWord}%' + OR firstname LIKE '%{$escapedWord}%' + OR lastname LIKE '%{$escapedWord}%' + OR customer_number LIKE '%{$escapedWord}%')"; + } + + if (empty($wordConditions)) { + self::returnJson(['success' => true, 'customers' => []]); + return; + } + + $whereClause = implode(' AND ', $wordConditions); + + $sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long + FROM Address + WHERE customer_number > 0 + AND ({$whereClause}) + ORDER BY company, lastname, firstname + LIMIT 20"; + + $result = $db->query($sql); + $customers = []; + + while ($row = $result->fetch_assoc()) { + $displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']); + $customers[] = [ + 'id' => intval($row['id']), + 'customerNumber' => $row['customer_number'], + 'displayName' => $displayName, + 'company' => $row['company'], + 'firstname' => $row['firstname'], + 'lastname' => $row['lastname'], + 'street' => $row['street'], + 'zip' => $row['zip'], + 'city' => $row['city'], + 'email' => $row['email'], + 'phone' => $row['phone'], + 'gpsLat' => $row['gps_lat'] ? floatval($row['gps_lat']) : null, + 'gpsLong' => $row['gps_long'] ? floatval($row['gps_long']) : null, + ]; + } + + self::returnJson(['success' => true, 'customers' => $customers]); + } + + /** + * Search articles + * GET /MobileApp/Lager/ShippingNote/searchArticles?query=X + */ + public function searchArticlesAction() { + $query = trim($this->request->query ?? ''); + + if (strlen($query) < 1) { + self::returnJson(['success' => true, 'articles' => []]); + return; + } + + $db = $this->db(); + $escapedQuery = $db->escape($query); + + $sql = "SELECT id, articleNumber, title, unit + FROM WarehouseArticle + WHERE (isEndOfLife IS NULL OR isEndOfLife = 0) + AND (articleNumber LIKE '%{$escapedQuery}%' + OR title LIKE '%{$escapedQuery}%') + ORDER BY title ASC + LIMIT 30"; + + $result = $db->query($sql); + $articles = []; + + while ($row = $result->fetch_assoc()) { + $articles[] = [ + 'id' => intval($row['id']), + 'articleNumber' => $row['articleNumber'], + 'title' => $row['title'], + 'unit' => $row['unit'] ?? 'Stk.', + ]; + } + + self::returnJson(['success' => true, 'articles' => $articles]); + } + + /** + * Get article by QR code or article number + * GET /MobileApp/Lager/ShippingNote/getArticle?code=X + */ + public function getArticleAction() { + $code = $this->request->code ?? ''; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + $articleId = null; + + // Check for QR code format WA:ID: or WH:ID: + if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + // Try to find by article number + $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]); + if ($article) { + $articleId = $article->id; + } + } + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'unit' => $article->unit ?? 'Stk.', + ] + ]); + } + + /** + * Get user's assigned car + * GET /MobileApp/Lager/ShippingNote/getUserCar?userId=X + */ + public function getUserCarAction() { + $userId = intval($this->request->userId ?? $this->user->id); + + $db = $this->db(); + + // Get user's assigned car from TimerecordingCar (user_id is on TimerecordingCar) + $sql = "SELECT id, number_plate, brand, model + FROM TimerecordingCar + WHERE user_id = {$userId} + AND (retired IS NULL OR retired = 0) + LIMIT 1"; + + $result = $db->query($sql); + + if ($result && $row = $result->fetch_assoc()) { + $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')); + if (!$carName) $carName = $row['number_plate']; + + self::returnJson([ + 'success' => true, + 'car' => [ + 'id' => intval($row['id']), + 'name' => $carName, + 'plate' => $row['number_plate'], + ] + ]); + } else { + self::returnJson([ + 'success' => true, + 'car' => null + ]); + } + } + + /** + * Get all available cars for selection + * GET /MobileApp/Lager/ShippingNote/getAllCars + */ + public function getAllCarsAction() { + $db = $this->db(); + + $sql = "SELECT id, number_plate, brand, model + FROM TimerecordingCar + WHERE (retired IS NULL OR retired = 0) + ORDER BY brand, model ASC"; + + $result = $db->query($sql); + $cars = []; + + while ($row = $result->fetch_assoc()) { + $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? '')); + if (!$carName) $carName = $row['number_plate']; + + $cars[] = [ + 'id' => intval($row['id']), + 'name' => $carName, + 'plate' => $row['number_plate'], + ]; + } + + self::returnJson(['success' => true, 'cars' => $cars]); + } + + /** + * Get hour types for selection + * GET /MobileApp/Lager/ShippingNote/getHourTypes + */ + public function getHourTypesAction() { + // Hour types matching desktop modal + $hourTypes = [ + ['id' => '', 'name' => 'Normal'], + ['id' => '50', 'name' => '+50%'], + ['id' => '100', 'name' => '+100%'], + ['id' => 'regie', 'name' => 'Regie'], + ]; + + self::returnJson(['success' => true, 'hourTypes' => $hourTypes]); + } + + /** + * Calculate round-trip distance from office to coordinates + * GET /MobileApp/Lager/ShippingNote/calculateDistance?lat=X&lng=Y + */ + public function calculateDistanceAction() { + $lat = floatval($this->request->lat ?? 0); + $lng = floatval($this->request->lng ?? 0); + + if (!$lat || !$lng) { + self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']); + return; + } + + // Use estimation on localhost for development + $isLocalhost = in_array($_SERVER['HTTP_HOST'] ?? '', ['localhost', '127.0.0.1']); + + // Use Google Distance Matrix API for accurate driving distance + $apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : ''; + if (!$apiKey || $isLocalhost) { + // Fallback to straight-line distance * 1.3 (rough road factor) + $distance = $this->haversineDistance(self::OFFICE_LAT, self::OFFICE_LNG, $lat, $lng); + $kmOneWay = round($distance / 1000 * 1.3, 1); + $kmRoundTrip = $kmOneWay * 2; + + self::returnJson([ + 'success' => true, + 'distanceOneWay' => $kmOneWay, + 'distanceRoundTrip' => $kmRoundTrip, + 'estimated' => true + ]); + return; + } + + $origin = self::OFFICE_LAT . ',' . self::OFFICE_LNG; + $destination = "{$lat},{$lng}"; + $url = "https://maps.googleapis.com/maps/api/distancematrix/json?origins={$origin}&destinations={$destination}&mode=driving&key={$apiKey}"; + + $response = @file_get_contents($url); + if (!$response) { + self::returnJson(['success' => false, 'message' => 'Distanzberechnung fehlgeschlagen']); + return; + } + + $data = json_decode($response, true); + if ($data['status'] !== 'OK' || empty($data['rows'][0]['elements'][0]['distance'])) { + self::returnJson(['success' => false, 'message' => 'Keine Route gefunden']); + return; + } + + $distanceMeters = $data['rows'][0]['elements'][0]['distance']['value']; + $kmOneWay = round($distanceMeters / 1000, 1); + $kmRoundTrip = $kmOneWay * 2; + + self::returnJson([ + 'success' => true, + 'distanceOneWay' => $kmOneWay, + 'distanceRoundTrip' => $kmRoundTrip, + 'estimated' => false + ]); + } + + /** + * Create new shipping note + * POST /MobileApp/Lager/ShippingNote/create + */ + public function createAction() { + $postData = $this->getPostData(); + + // Validate required fields + $requiredFields = ['deliveryAddressName', 'deliveryAddressLine', 'deliveryAddressPLZ', 'deliveryAddressCity', 'note']; + foreach ($requiredFields as $field) { + if (empty($postData[$field])) { + self::returnJson(['success' => false, 'message' => "Feld '{$field}' ist erforderlich"]); + return; + } + } + + // Must have at least positions OR hoursEntries + $positions = $postData['positions'] ?? []; + $hoursEntries = $postData['hoursEntries'] ?? []; + + if (empty($positions) && empty($hoursEntries)) { + self::returnJson(['success' => false, 'message' => 'Mindestens eine Position oder Stundenbuchung erforderlich']); + return; + } + + $db = $this->db(); + + // Prepare data + $data = [ + 'status' => 'new', + 'type' => null, + 'billingAddressId' => null, + 'deliveryAddressName' => $db->escape($postData['deliveryAddressName']), + 'deliveryAddressLine' => $db->escape($postData['deliveryAddressLine']), + 'deliveryAddressPLZ' => $db->escape($postData['deliveryAddressPLZ']), + 'deliveryAddressCity' => $db->escape($postData['deliveryAddressCity']), + 'deliveryAddressEMail' => $db->escape($postData['deliveryAddressEMail'] ?? ''), + 'note' => $db->escape($postData['note']), + 'positions' => json_encode($positions), + 'hoursEntries' => json_encode($hoursEntries), + 'textElements' => json_encode($postData['textElements'] ?? []), + 'metadata' => json_encode($postData['metadata'] ?? []), + 'create' => time(), + 'createBy' => $this->user->id, + ]; + + // Generate shipping note number + $shippingNoteNumber = WarehouseShippingNoteModel::generateShippingNoteNumber(); + $data['shippingNoteNumber'] = $shippingNoteNumber; + + // Build INSERT query + $columns = implode(', ', array_map(function($k) { return "`{$k}`"; }, array_keys($data))); + $values = implode(', ', array_map(function($v) { + return $v === null ? 'NULL' : "'{$v}'"; + }, array_values($data))); + + $db->query("INSERT INTO WarehouseShippingNote ({$columns}) VALUES ({$values})"); + $id = $db->insert_id; + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Fehler beim Speichern']); + return; + } + + self::returnJson([ + 'success' => true, + 'message' => 'Lieferschein erstellt', + 'shippingNote' => [ + 'id' => $id, + 'shippingNoteNumber' => $shippingNoteNumber, + ] + ]); + } + + /** + * Sign a shipping note + * POST /MobileApp/Lager/ShippingNote/sign?id=X + */ + public function signAction() { + $id = intval($this->request->id ?? 0); + $postData = $this->getPostData(); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Lieferschein-ID fehlt']); + return; + } + + $shippingNote = WarehouseShippingNoteModel::get($id); + if (!$shippingNote) { + self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']); + return; + } + + // Check if already signed + if (!empty($shippingNote->signature) || !empty($shippingNote->signatureName)) { + self::returnJson(['success' => false, 'message' => 'Bereits unterschrieben']); + return; + } + + // Validate signature data + $signature = $postData['signature'] ?? ''; + $signatureName = $postData['signatureName'] ?? ''; + + if (empty($signature) || empty($signatureName)) { + self::returnJson(['success' => false, 'message' => 'Unterschrift und Name erforderlich']); + return; + } + + $db = $this->db(); + $signatureEscaped = $db->escape($signature); + $signatureNameEscaped = $db->escape($signatureName); + $signatureDate = date('Y-m-d'); + + $db->query("UPDATE WarehouseShippingNote + SET signature = '{$signatureEscaped}', + signatureName = '{$signatureNameEscaped}', + signatureDate = '{$signatureDate}' + WHERE id = {$id}"); + + self::returnJson([ + 'success' => true, + 'message' => 'Unterschrift gespeichert' + ]); + } + + /** + * Get my unsigned shipping notes + * GET /MobileApp/Lager/ShippingNote/getMyShippingNotes + */ + public function getMyShippingNotesAction() { + $onlyUnsigned = ($this->request->unsigned ?? '1') === '1'; + $limit = intval($this->request->limit ?? 20); + + $db = $this->db(); + + $whereClause = "createBy = {$this->user->id}"; + if ($onlyUnsigned) { + $whereClause .= " AND (signature IS NULL OR signature = '')"; + } + + $sql = "SELECT id, shippingNoteNumber, status, type, deliveryAddressName, deliveryAddressLine, + deliveryAddressPLZ, deliveryAddressCity, note, + signature, signatureName, signatureDate, `create` + FROM WarehouseShippingNote + WHERE {$whereClause} + ORDER BY `create` DESC + LIMIT {$limit}"; + + $result = $db->query($sql); + $shippingNotes = []; + + while ($row = $result->fetch_assoc()) { + $shippingNotes[] = [ + 'id' => intval($row['id']), + 'shippingNoteNumber' => $row['shippingNoteNumber'], + 'status' => $row['status'], + 'type' => $row['type'], + 'deliveryAddressName' => $row['deliveryAddressName'], + 'deliveryAddressLine' => $row['deliveryAddressLine'], + 'deliveryAddressPLZ' => $row['deliveryAddressPLZ'], + 'deliveryAddressCity' => $row['deliveryAddressCity'], + 'note' => $row['note'], + 'isSigned' => !empty($row['signature']), + 'signatureName' => $row['signatureName'], + 'signatureDate' => $row['signatureDate'], + 'create' => date('d.m.Y H:i', $row['create']), + ]; + } + + self::returnJson(['success' => true, 'shippingNotes' => $shippingNotes]); + } + + /** + * Get a single shipping note by ID + * GET /MobileApp/Lager/ShippingNote/getShippingNote?id=X + */ + public function getShippingNoteAction() { + $id = intval($this->request->id ?? 0); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'ID fehlt']); + return; + } + + $shippingNote = WarehouseShippingNoteModel::get($id); + if (!$shippingNote) { + self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'shippingNote' => [ + 'id' => intval($shippingNote->id), + 'shippingNoteNumber' => $shippingNote->shippingNoteNumber, + 'status' => $shippingNote->status, + 'type' => $shippingNote->type, + 'billingAddressId' => $shippingNote->billingAddressId, + 'deliveryAddressName' => $shippingNote->deliveryAddressName, + 'deliveryAddressLine' => $shippingNote->deliveryAddressLine, + 'deliveryAddressPLZ' => $shippingNote->deliveryAddressPLZ, + 'deliveryAddressCity' => $shippingNote->deliveryAddressCity, + 'deliveryAddressEMail' => $shippingNote->deliveryAddressEMail, + 'note' => $shippingNote->note, + 'positions' => json_decode($shippingNote->positions, true) ?? [], + 'hoursEntries' => json_decode($shippingNote->hoursEntries, true) ?? [], + 'isSigned' => !empty($shippingNote->signature), + 'signatureName' => $shippingNote->signatureName, + 'signatureDate' => $shippingNote->signatureDate, + 'create' => date('d.m.Y H:i', $shippingNote->create), + ] + ]); + } + + /** + * Get shipping note types + * GET /MobileApp/Lager/ShippingNote/getTypes + */ + public function getTypesAction() { + $types = [ + ['value' => 'V', 'text' => 'Verrechnen'], + ['value' => 'XI', 'text' => 'Xinon Intern'], + ['value' => 'XH', 'text' => 'Xinon Hersteller'], + ['value' => 'SNOPP', 'text' => 'SNOPP'], + ['value' => 'ESTMK', 'text' => 'Energie Steiermark'], + ['value' => 'SBIDI', 'text' => 'SBIDI'], + ]; + + self::returnJson(['success' => true, 'types' => $types]); + } + + /** + * Get current user info (for pre-filling forms) + * GET /MobileApp/Lager/ShippingNote/getCurrentUser + */ + public function getCurrentUserAction() { + self::returnJson([ + 'success' => true, + 'user' => [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'firstname' => $this->user->firstname ?? '', + 'lastname' => $this->user->lastname ?? '', + ] + ]); + } + + /** + * Search employees for multi-employee selection + * GET /MobileApp/Lager/ShippingNote/searchEmployees?query=X + */ + public function searchEmployeesAction() { + $query = trim($this->request->query ?? ''); + + $db = $this->db(); + + // Base query: active workers who have TimerecordingEmployee entry (= employees) + $sql = "SELECT w.id, w.name, w.email + FROM Worker w + INNER JOIN TimerecordingEmployee te ON te.user_id = w.id + WHERE w.active = 1"; + + // Add search filter if query provided + if (strlen($query) >= 1) { + // Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst") + $words = preg_split('/\s+/', trim($query)); + $wordConditions = []; + + foreach ($words as $word) { + if (strlen($word) < 1) continue; + $escapedWord = $db->escape($word); + $wordConditions[] = "(w.name LIKE '%{$escapedWord}%' OR w.email LIKE '%{$escapedWord}%')"; + } + + if (!empty($wordConditions)) { + $sql .= " AND " . implode(' AND ', $wordConditions); + } + } + + $sql .= " ORDER BY w.name ASC LIMIT 20"; + + $result = $db->query($sql); + $employees = []; + + while ($row = $result->fetch_assoc()) { + $employees[] = [ + 'id' => intval($row['id']), + 'name' => $row['name'], + 'email' => $row['email'], + ]; + } + + self::returnJson(['success' => true, 'employees' => $employees]); + } + + /** + * Helper: Calculate Haversine distance in meters + */ + private function haversineDistance($lat1, $lng1, $lat2, $lng2) { + $earthRadius = 6371000; // meters + + $dLat = deg2rad($lat2 - $lat1); + $dLng = deg2rad($lng2 - $lng1); + + $a = sin($dLat / 2) * sin($dLat / 2) + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * + sin($dLng / 2) * sin($dLng / 2); + + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + + return $earthRadius * $c; + } +} diff --git a/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php b/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php new file mode 100644 index 000000000..6861bcdc7 --- /dev/null +++ b/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php @@ -0,0 +1,1750 @@ + ['text' => 'Neu', 'color' => 'primary'], + 'assigned' => ['text' => 'Zugewiesen', 'color' => 'info'], + 'scheduled' => ['text' => 'Geplant', 'color' => 'warning'], + 'in_progress' => ['text' => 'In Bearbeitung', 'color' => 'warning'], + 'correction_requested' => ['text' => 'Korrektur angefordert', 'color' => 'danger'], + 'intervention_required' => ['text' => 'Eingriff erforderlich', 'color' => 'danger'], + 'civil_engineering_required' => ['text' => 'Tiefbau benötigt', 'color' => 'orange'], + 'civil_engineering_completed' => ['text' => 'Tiefbau abgeschlossen', 'color' => 'success'], + 'problem_solved' => ['text' => 'Problem gelöst', 'color' => 'success'], + 'documented' => ['text' => 'Dokumentiert', 'color' => 'success'], + 'completed' => ['text' => 'Abgeschlossen', 'color' => 'secondary'], + 'charged' => ['text' => 'Verrechnet', 'color' => 'purple'], + 'cancelled' => ['text' => 'Abgebrochen', 'color' => 'danger'], + 'archived' => ['text' => 'Archiviert', 'color' => 'muted'], + ]; + + /** + * Get workorders list for the company + * POST /MobileApp/Workorder/Workorder/get + */ + public function getAction() { + $postData = $this->getPostData(); + + $pagination = $postData['pagination'] ?? ['page' => 1, 'per_page' => 20]; + $filters = $postData['filters'] ?? []; + $order = $postData['order'] ?? []; + $search = trim($postData['search'] ?? ''); + + // Get company for current user + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson([ + 'success' => true, + 'workorders' => [], + 'pagination' => ['page' => 1, 'per_page' => $pagination['per_page'], 'total' => 0] + ]); + return; + } + + // Build workorders query + $workorders = WorkorderModel::getCompanyWorkorders( + $filters, + $pagination['per_page'], + ($pagination['page'] - 1) * $pagination['per_page'], + $order, + $company->id, + $search + ); + + $totalCount = WorkorderModel::countCompanyWorkorders($filters, $company->id, $search); + + // Transform for mobile app + $result = []; + foreach ($workorders as $wo) { + $result[] = $this->transformWorkorder($wo); + } + + self::returnJson([ + 'success' => true, + 'workorders' => $result, + 'pagination' => [ + 'page' => intval($pagination['page']), + 'per_page' => intval($pagination['per_page']), + 'total' => $totalCount, + 'totalPages' => ceil($totalCount / $pagination['per_page']) + ] + ]); + } + + /** + * Get single workorder details + * GET /MobileApp/Workorder/Workorder/getWorkorder?id=X + */ + public function getWorkorderAction() { + $id = intval($this->request->id ?? 0); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'ID fehlt']); + return; + } + + // Verify user has access + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson(['success' => false, 'message' => 'Keine Berechtigung']); + return; + } + + // Get workorder with full joined data + $workorder = $this->getWorkorderWithDetails($id, $company->id); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'workorder' => $this->transformWorkorder($workorder, true) + ]); + } + + /** + * Get complete workorder detail (combined endpoint) + * Returns workorder, documentation, tenant config, and checklist in one request + * GET /MobileApp/Workorder/Workorder/getWorkorderDetail?id=X + */ + public function getWorkorderDetailAction() { + $id = intval($this->request->id ?? 0); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'ID fehlt']); + return; + } + + // Verify user has access + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson(['success' => false, 'message' => 'Keine Berechtigung']); + return; + } + + // Get workorder with full joined data + $workorderData = $this->getWorkorderWithDetails($id, $company->id); + if (!$workorderData) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + $workorder = $this->transformWorkorder($workorderData, true); + + // Get tenant config + $tenantConfig = $this->getTenantConfigFromWorkorder($id); + $tenantConfigData = null; + $translationMap = []; + + if ($tenantConfig) { + $customTypes = json_decode($tenantConfig->documentationTypes, true) ?? []; + $translationMap = array_merge( + ['civil_engineering_photo' => 'Tiefbau_Foto'], + array_column($customTypes, 'text', 'value') + ); + + $tenantConfigData = [ + 'addressId' => $tenantConfig->addressId, + 'documentationTypes' => $customTypes, + 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired, + 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [], + 'requireCableLength' => (bool)$tenantConfig->requireCableLength, + 'requireCableType' => (bool)$tenantConfig->requireCableType, + 'showTechnicalData' => (bool)$tenantConfig->showTechnicalData, + ]; + } + + // Get documentation + $docs = WorkorderDocumentationModel::getAll( + ['workorderId' => $id], + null, 0, + ['key' => 'create', 'order' => 'ASC'] + ); + + $typeCounts = []; + $responseDocs = []; + + foreach ($docs as $doc) { + $file = new File($doc->fileId); + $documentTypeKey = $doc->documentType; + $typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1; + $originalFilename = $file->orig_filename ?? $file->filename; + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey; + $newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension); + + $responseDocs[] = [ + 'id' => $doc->id, + 'fileId' => $doc->fileId, + 'fileName' => $newFilename, + 'description' => $doc->description, + 'documentType' => $documentTypeKey, + 'documentTypeText' => $translationMap[$documentTypeKey] ?? $documentTypeKey, + 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt', + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc->create, + 'createFormatted' => date('d.m.Y H:i', $doc->create), + 'previewUrl' => "/File/Download/{$doc->fileId}", + ]; + } + + // Get journals + $journals = WorkorderJournalModel::getAll( + ['workorderId' => $id], + null, 0, + ['key' => 'create', 'order' => 'DESC'] + ); + + $responseJournals = []; + foreach ($journals as $journal) { + $responseJournals[] = [ + 'id' => $journal->id, + 'text' => $journal->text, + 'statusChange' => $journal->statusChange ?? null, + 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt', + 'create' => $journal->create, + 'createFormatted' => date('d.m.Y H:i', $journal->create), + ]; + } + + // Build checklist + $docTypes = $tenantConfigData['documentationTypes'] ?? []; + $uploadedTypes = array_column((array)$docs, 'documentType'); + $uploadedTypeCounts = array_count_values($uploadedTypes); + + $checklist = []; + $completedCount = 0; + + foreach ($docTypes as $type) { + $isCompleted = isset($uploadedTypeCounts[$type['value']]) && $uploadedTypeCounts[$type['value']] > 0; + if ($isCompleted) $completedCount++; + + $checklist[] = [ + 'type' => $type['value'], + 'text' => $type['text'], + 'required' => $type['required'] ?? false, + 'completed' => $isCompleted, + 'count' => $uploadedTypeCounts[$type['value']] ?? 0, + ]; + } + + $technicalData = null; + if ($tenantConfigData && !empty($tenantConfigData['showTechnicalData'])) { + RimoWorkorder::autoParseForWorkorder($id); + $technicalData = WorkorderModel::getTechnicalData($id); + } + + self::returnJson([ + 'success' => true, + 'workorder' => $workorder, + 'tenantConfig' => $tenantConfigData, + 'docs' => $responseDocs, + 'journals' => $responseJournals, + 'checklist' => $checklist, + 'checklistProgress' => [ + 'completed' => $completedCount, + 'total' => count($docTypes), + 'allRequired' => $this->allRequiredCompleted($checklist) + ], + 'technicalData' => $technicalData, + ]); + } + + /** + * Get documentation and journals for a workorder + * GET /MobileApp/Workorder/Workorder/getDocumentation?workorderId=X + */ + public function getDocumentationAction() { + $workorderId = intval($this->request->workorderId ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + // Get documentation + $docs = WorkorderDocumentationModel::getAll( + ['workorderId' => $workorderId], + null, 0, + ['key' => 'create', 'order' => 'ASC'] + ); + + // Get journals + $journals = WorkorderJournalModel::getAll( + ['workorderId' => $workorderId], + null, 0, + ['key' => 'create', 'order' => 'DESC'] + ); + + // Get tenant config for type translations + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + $translationMap = []; + if ($tenantConfig && !empty($tenantConfig->documentationTypes)) { + $customTypes = json_decode($tenantConfig->documentationTypes, true); + $customMap = array_column($customTypes, 'text', 'value'); + $translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap); + } + + // Transform docs + $responseDocs = []; + $typeCounts = []; + + foreach ($docs as $doc) { + $file = new File($doc->fileId); + $documentTypeKey = $doc->documentType; + $typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1; + $originalFilename = $file->orig_filename ?? $file->filename; + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey; + $newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension); + + $responseDocs[] = [ + 'id' => $doc->id, + 'fileId' => $doc->fileId, + 'fileName' => $newFilename, + 'description' => $doc->description, + 'documentType' => $documentTypeKey, + 'documentTypeText' => $translationMap[$documentTypeKey] ?? $documentTypeKey, + 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt', + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc->create, + 'createFormatted' => date('d.m.Y H:i', $doc->create), + 'previewUrl' => "/File/Download/{$doc->fileId}", + ]; + } + + // Transform journals + $responseJournals = []; + foreach ($journals as $journal) { + $responseJournals[] = [ + 'id' => $journal->id, + 'text' => $journal->text, + 'statusChange' => $journal->statusChange ?? null, + 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt', + 'create' => $journal->create, + 'createFormatted' => date('d.m.Y H:i', $journal->create), + ]; + } + + self::returnJson([ + 'success' => true, + 'docs' => $responseDocs, + 'journals' => $responseJournals, + 'docCount' => count($responseDocs), + 'journalCount' => count($responseJournals) + ]); + } + + /** + * Get tenant configuration + * GET /MobileApp/Workorder/Workorder/getTenantConfig?workorderId=X + */ + public function getTenantConfigAction() { + $workorderId = intval($this->request->workorderId ?? 0); + + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + + if (!$tenantConfig) { + self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true) ?? [], + 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired, + 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [], + 'requireCableLength' => (bool)$tenantConfig->requireCableLength, + 'requireCableType' => (bool)$tenantConfig->requireCableType, + ]); + } + + /** + * Upload documentation files + * POST /MobileApp/Workorder/Workorder/uploadDocumentation + */ + public function uploadDocumentationAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + + if (empty($_FILES['files']) && empty($_FILES['file'])) { + self::returnJson(['success' => false, 'message' => 'Keine Datei hochgeladen']); + return; + } + + $workorderId = intval($_POST['workorderId'] ?? 0); + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + $documentType = $_POST['documentType'] ?? 'general'; + $description = $_POST['description'] ?? ''; + $clientTimestamp = intval($_POST['clientTimestamp'] ?? 0); + + // Handle both single file and multiple files + if (!empty($_FILES['files'])) { + foreach ($_FILES['files']['name'] as $index => $name) { + if ($_FILES['files']['error'][$index] === UPLOAD_ERR_OK) { + $_FILES['file'] = [ + 'name' => $name, + 'type' => $_FILES['files']['type'][$index], + 'tmp_name' => $_FILES['files']['tmp_name'][$index], + 'error' => $_FILES['files']['error'][$index], + 'size' => $_FILES['files']['size'][$index] + ]; + $this->saveDocumentation($workorderId, $documentType, $description); + } + } + } else if (!empty($_FILES['file'])) { + $this->saveDocumentation($workorderId, $documentType, $description); + } + + // Update workorder status if needed + $workorder = WorkorderModel::get($workorderId); + $oldStatus = $workorder->status; + $newStatus = null; + + if (in_array($oldStatus, ['assigned', 'scheduled'])) { + $newStatus = 'in_progress'; + } else if (in_array($oldStatus, ['correction_requested', 'problem_solved', 'civil_engineering_completed'])) { + $newStatus = 'assigned'; + } + + if ($newStatus) { + $workorder->status = $newStatus; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Status wurde nach Dokumenten-Upload automatisch geändert.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText($newStatus), + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + } + + $response = ['success' => true, 'message' => 'Datei(en) erfolgreich hochgeladen']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); + } + + /** + * Delete documentation + * POST /MobileApp/Workorder/Workorder/deleteDocumentation + */ + public function deleteDocumentationAction() { + $postData = $this->getPostData(); + $id = intval($postData['id'] ?? 0); + + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Dokumenten-ID fehlt']); + return; + } + + WorkorderDocumentationModel::delete($id); + self::returnJson(['success' => true, 'message' => 'Dokument gelöscht']); + } + + /** + * Add journal entry + * POST /MobileApp/Workorder/Workorder/addJournal + */ + public function addJournalAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $text = trim($postData['text'] ?? ''); + + if (!$workorderId || !$text) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID und Text sind erforderlich']); + return; + } + + // Use client timestamp if provided + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); + + WorkorderJournalModel::create([ + 'workorderId' => $workorderId, + 'text' => $text, + 'createBy' => $this->user->id, + 'create' => $clientTimestamp ?: time() + ]); + + // Return updated journals + $journals = WorkorderJournalModel::getAll( + ['workorderId' => $workorderId], + null, 0, + ['key' => 'create', 'order' => 'DESC'] + ); + + $responseJournals = []; + foreach ($journals as $journal) { + $responseJournals[] = [ + 'id' => $journal->id, + 'text' => $journal->text, + 'statusChange' => $journal->statusChange ?? null, + 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt', + 'create' => $journal->create, + 'createFormatted' => date('d.m.Y H:i', $journal->create), + ]; + } + + $response = [ + 'success' => true, + 'message' => 'Journaleintrag hinzugefügt', + 'journals' => $responseJournals + ]; + + // Cache for idempotency + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); + } + + /** + * Update additional info (notes) + * POST /MobileApp/Workorder/Workorder/updateAdditionalInfo + */ + public function updateAdditionalInfoAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + $oldInfo = $workorder->additionalInfo; + $newInfo = $postData['additionalInfo'] ?? null; + $workorder->additionalInfo = $newInfo; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'", + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + $response = [ + 'success' => true, + 'message' => 'Zusatzinfo aktualisiert', + 'newInfo' => $newInfo + ]; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); + } + + /** + * Schedule appointment + * POST /MobileApp/Workorder/Workorder/scheduleAppointment + */ + public function scheduleAppointmentAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $appointmentDate = intval($postData['appointmentDate'] ?? 0); + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); + + if (!$workorderId || !$appointmentDate) { + self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + // Validate time is set + $hour = (int)date('H', $appointmentDate); + if ($hour >= 23 || $hour < 1) { + self::returnJson(['success' => false, 'message' => 'Bitte geben Sie eine Uhrzeit an']); + return; + } + + $workorder->appointmentDate = $appointmentDate; + $workorder->status = 'scheduled'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $appointmentDate), + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + $response = ['success' => true, 'message' => 'Termin erfolgreich gespeichert']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); + } + + /** + * Request intervention (report problem) + * POST /MobileApp/Workorder/Workorder/requestIntervention + */ + public function requestInterventionAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $journalText = trim($postData['journalText'] ?? ''); + $interventionType = $postData['interventionType'] ?? ''; + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); + + if (!$workorderId || !$journalText) { + self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + $oldStatus = $workorder->status; + $workorder->status = 'intervention_required'; + WorkorderModel::update((array)$workorder); + + $fullText = $interventionType ? "{$interventionType}: {$journalText}" : "Eingriff erforderlich: {$journalText}"; + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $fullText, + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'), + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + $response = ['success' => true, 'message' => 'Eingriff wurde angefordert']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); + } + + /** + * Complete workorder + * POST /MobileApp/Workorder/Workorder/completeWorkorder + */ + public function completeWorkorderAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + // Validate cable data if required + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + if ($tenantConfig) { + if ($tenantConfig->requireCableLength && empty(trim($workorder->cableLength))) { + self::returnJson(['success' => false, 'message' => 'Bitte geben Sie die Kabellänge an']); + return; + } + if ($tenantConfig->requireCableType && empty(trim($workorder->cableType))) { + self::returnJson(['success' => false, 'message' => 'Bitte geben Sie den Kabeltyp an']); + return; + } + } + + // Validate checklist + $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : []; + $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]); + $uploadedTypes = array_column((array)$docs, 'documentType'); + $uploadedTypeCounts = array_count_values($uploadedTypes); + + foreach ($docTypes as $type) { + if (($type['required'] ?? false) && empty($uploadedTypeCounts[$type['value']])) { + self::returnJson([ + 'success' => false, + 'message' => 'Pflichtdokumentation fehlt: ' . $type['text'], + 'checklistIncomplete' => true + ]); + return; + } + } + + $oldStatus = $workorder->status; + $workorder->status = 'documented'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Arbeitsauftrag zur Prüfung eingereicht.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'), + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + $response = ['success' => true, 'message' => 'Arbeitsauftrag zur Prüfung eingereicht']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); + } + + /** + * Update workorder data (cable info) + * POST /MobileApp/Workorder/Workorder/updateWorkorderData + */ + public function updateWorkorderDataAction() { + // Check idempotency + $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null; + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + self::returnJson($cached); + return; + } + } + + $postData = $this->getPostData(); + $workorderId = intval($postData['workorderId'] ?? 0); + $clientTimestamp = intval($postData['clientTimestamp'] ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']); + return; + } + + $journalText = "Zusatzdaten aktualisiert:\n"; + $changed = false; + + if (isset($postData['cableLength'])) { + if ($workorder->cableLength != $postData['cableLength']) { + $journalText .= "Kabellänge: '{$workorder->cableLength}' -> '{$postData['cableLength']}'\n"; + $workorder->cableLength = $postData['cableLength']; + $changed = true; + } + } + + if (isset($postData['cableType'])) { + if ($workorder->cableType != $postData['cableType']) { + $journalText .= "Kabeltyp: '{$workorder->cableType}' -> '{$postData['cableType']}'\n"; + $workorder->cableType = $postData['cableType']; + $changed = true; + } + } + + if (!$changed) { + $response = ['success' => true, 'message' => 'Keine Änderungen vorgenommen']; + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + self::returnJson($response); + return; + } + + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $journalText, + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + $response = ['success' => true, 'message' => 'Daten gespeichert']; + + if ($idempotencyKey) { + $this->setIdempotencyCache($idempotencyKey, $response); + } + + self::returnJson($response); + } + + /** + * Get checklist status for a workorder + * GET /MobileApp/Workorder/Workorder/getChecklist?workorderId=X + */ + public function getChecklistAction() { + $workorderId = intval($this->request->workorderId ?? 0); + + if (!$workorderId) { + self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']); + return; + } + + // Get tenant config for required doc types + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : []; + + // Get existing documentation + $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]); + $uploadedTypes = array_column((array)$docs, 'documentType'); + $uploadedTypeCounts = array_count_values($uploadedTypes); + + // Build checklist + $checklist = []; + $completedCount = 0; + + foreach ($docTypes as $type) { + $isCompleted = isset($uploadedTypeCounts[$type['value']]) && $uploadedTypeCounts[$type['value']] > 0; + if ($isCompleted) $completedCount++; + + $checklist[] = [ + 'type' => $type['value'], + 'text' => $type['text'], + 'required' => $type['required'] ?? false, + 'completed' => $isCompleted, + 'count' => $uploadedTypeCounts[$type['value']] ?? 0, + ]; + } + + self::returnJson([ + 'success' => true, + 'checklist' => $checklist, + 'completed' => $completedCount, + 'total' => count($docTypes), + 'allRequired' => $this->allRequiredCompleted($checklist) + ]); + } + + // ===================== + // OFFLINE SYNC ENDPOINTS + // ===================== + + /** + * Get all workorders for offline mode initial sync + * Returns full workorder data with details for caching + * POST /MobileApp/Workorder/Workorder/getAllForOffline + */ + public function getAllForOfflineAction() { + $postData = $this->getPostData(); + $lastSyncTimestamp = intval($postData['lastSyncTimestamp'] ?? 0); + + // Get company for current user + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson([ + 'success' => true, + 'workorders' => [], + 'workorderDetails' => [], + 'tenantConfigs' => [], + 'serverTimestamp' => time(), + 'isFullSync' => true + ]); + return; + } + + // Get all workorders for this company (active statuses only) + $activeStatuses = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', + 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', + 'problem_solved', 'documented']; + + $workorders = WorkorderModel::getCompanyWorkorders( + ['status' => $activeStatuses], + 9999, // Large limit for offline sync + 0, + ['key' => 'deadlineDate', 'order' => 'ASC'], + $company->id + ); + + $result = []; + $workorderDetails = []; + $tenantConfigCache = []; + + foreach ($workorders as $wo) { + $woId = $wo['id']; + $result[] = $this->transformWorkorder($wo); + + // Get detailed data for each workorder + $detailData = $this->getWorkorderWithDetails($woId, $company->id); + if ($detailData) { + $detail = [ + 'workorderId' => $woId, + 'workorder' => $this->transformWorkorder($detailData, true), + 'lastFetched' => time() + ]; + + // Get tenant config (cached by addressId) + $tenantConfig = $this->getTenantConfigFromWorkorder($woId); + if ($tenantConfig) { + $configAddressId = $tenantConfig->addressId; + if (!isset($tenantConfigCache[$configAddressId])) { + $tenantConfigCache[$configAddressId] = [ + 'addressId' => $configAddressId, + 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true) ?? [], + 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired, + 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [], + 'requireCableLength' => (bool)$tenantConfig->requireCableLength, + 'requireCableType' => (bool)$tenantConfig->requireCableType, + 'showTechnicalData' => (bool)$tenantConfig->showTechnicalData, + 'lastFetched' => time() + ]; + } + $detail['tenantConfigAddressId'] = $configAddressId; + } + + // Get documentation metadata (without full file data) + $docs = WorkorderDocumentationModel::getAll( + ['workorderId' => $woId], + null, 0, + ['key' => 'create', 'order' => 'ASC'] + ); + + $docMeta = []; + foreach ($docs as $doc) { + $file = new File($doc->fileId); + $docMeta[] = [ + 'id' => $doc->id, + 'fileId' => $doc->fileId, + 'documentType' => $doc->documentType, + 'description' => $doc->description, + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc->create, + 'thumbnailUrl' => "/MobileApp/Workorder/Workorder/getThumbnail?fileId={$doc->fileId}", + 'previewUrl' => "/File/Download/{$doc->fileId}", + ]; + } + $detail['documentation'] = $docMeta; + + // Get journals + $journals = WorkorderJournalModel::getAll( + ['workorderId' => $woId], + null, 0, + ['key' => 'create', 'order' => 'DESC'] + ); + + $journalData = []; + foreach ($journals as $journal) { + $journalData[] = [ + 'id' => $journal->id, + 'text' => $journal->text, + 'statusChange' => $journal->statusChange ?? null, + 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt', + 'create' => $journal->create, + ]; + } + $detail['journals'] = $journalData; + + $workorderDetails[] = $detail; + } + } + + self::returnJson([ + 'success' => true, + 'workorders' => $result, + 'workorderDetails' => $workorderDetails, + 'tenantConfigs' => array_values($tenantConfigCache), + 'serverTimestamp' => time(), + 'isFullSync' => $lastSyncTimestamp === 0 + ]); + } + + /** + * Get list of workorder IDs assigned to this company + * Used for reassignment detection + * GET /MobileApp/Workorder/Workorder/getWorkorderIds + */ + public function getWorkorderIdsAction() { + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson(['success' => true, 'workorderIds' => [], 'serverTimestamp' => time()]); + return; + } + + $activeStatuses = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', + 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', + 'problem_solved', 'documented']; + + $db = $this->db(); + $fronkDbName = FRONKDB_DBNAME; + + $statusIn = "'" . implode("','", $activeStatuses) . "'"; + $sql = "SELECT id FROM `{$fronkDbName}`.`Workorder` + WHERE companyId = " . intval($company->id) . " + AND status IN ({$statusIn})"; + + $result = $db->query($sql); + $ids = []; + while ($row = $result->fetch_assoc()) { + $ids[] = intval($row['id']); + } + + self::returnJson([ + 'success' => true, + 'workorderIds' => $ids, + 'serverTimestamp' => time() + ]); + } + + /** + * Process batch of queued operations from offline sync + * POST /MobileApp/Workorder/Workorder/syncBatch + */ + public function syncBatchAction() { + $postData = $this->getPostData(); + $operations = $postData['operations'] ?? []; + + if (empty($operations)) { + self::returnJson(['success' => true, 'results' => [], 'processedCount' => 0]); + return; + } + + $results = []; + $processedCount = 0; + + foreach ($operations as $op) { + $idempotencyKey = $op['idempotencyKey'] ?? null; + $operationType = $op['type'] ?? ''; + $payload = $op['payload'] ?? []; + $clientTimestamp = intval($op['clientTimestamp'] ?? 0); + + // Check idempotency cache + if ($idempotencyKey) { + $cached = $this->getIdempotencyCache($idempotencyKey); + if ($cached !== null) { + $results[] = [ + 'idempotencyKey' => $idempotencyKey, + 'success' => true, + 'cached' => true, + 'result' => $cached + ]; + $processedCount++; + continue; + } + } + + // Process operation + $opResult = $this->processOfflineOperation($operationType, $payload, $clientTimestamp); + + // Store in idempotency cache + if ($idempotencyKey && $opResult['success']) { + $this->setIdempotencyCache($idempotencyKey, $opResult); + } + + $results[] = [ + 'idempotencyKey' => $idempotencyKey, + 'success' => $opResult['success'], + 'cached' => false, + 'result' => $opResult, + 'error' => $opResult['error'] ?? null + ]; + + if ($opResult['success']) { + $processedCount++; + } + } + + self::returnJson([ + 'success' => true, + 'results' => $results, + 'processedCount' => $processedCount, + 'totalOperations' => count($operations), + 'serverTimestamp' => time() + ]); + } + + /** + * Process a single offline operation + */ + private function processOfflineOperation($type, $payload, $clientTimestamp) { + try { + switch ($type) { + case 'ADD_JOURNAL': + return $this->processAddJournal($payload, $clientTimestamp); + + case 'UPDATE_NOTES': + return $this->processUpdateNotes($payload, $clientTimestamp); + + case 'SCHEDULE_APPOINTMENT': + return $this->processScheduleAppointment($payload, $clientTimestamp); + + case 'REQUEST_INTERVENTION': + return $this->processRequestIntervention($payload, $clientTimestamp); + + case 'UPDATE_CABLE_DATA': + return $this->processUpdateCableData($payload, $clientTimestamp); + + case 'COMPLETE_WORKORDER': + return $this->processCompleteWorkorder($payload, $clientTimestamp); + + default: + return ['success' => false, 'error' => 'Unknown operation type: ' . $type]; + } + } catch (Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + private function processAddJournal($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + $text = trim($payload['text'] ?? ''); + + if (!$workorderId || !$text) { + return ['success' => false, 'error' => 'Missing required fields']; + } + + $journalId = WorkorderJournalModel::create([ + 'workorderId' => $workorderId, + 'text' => $text, + 'createBy' => $this->user->id, + 'create' => $clientTimestamp ?: time() + ]); + + return ['success' => true, 'journalId' => $journalId]; + } + + private function processUpdateNotes($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + + if (!$workorderId) { + return ['success' => false, 'error' => 'Missing workorderId']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + $oldInfo = $workorder->additionalInfo; + $newInfo = $payload['additionalInfo'] ?? null; + + // Conflict resolution: concatenate if server has newer changes + // For now, we use last-write-wins but log the change + $workorder->additionalInfo = $newInfo; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => "Zusatzinfo geändert (Offline-Sync).\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'", + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + return ['success' => true, 'newInfo' => $newInfo]; + } + + private function processScheduleAppointment($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + $appointmentDate = intval($payload['appointmentDate'] ?? 0); + + if (!$workorderId || !$appointmentDate) { + return ['success' => false, 'error' => 'Missing required fields']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + $workorder->appointmentDate = $appointmentDate; + $workorder->status = 'scheduled'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $appointmentDate) . ' (Offline-Sync)', + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + return ['success' => true, 'appointmentDate' => $appointmentDate]; + } + + private function processRequestIntervention($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + $journalText = trim($payload['journalText'] ?? ''); + $interventionType = $payload['interventionType'] ?? ''; + + if (!$workorderId || !$journalText) { + return ['success' => false, 'error' => 'Missing required fields']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + $oldStatus = $workorder->status; + $workorder->status = 'intervention_required'; + WorkorderModel::update((array)$workorder); + + $fullText = $interventionType ? "{$interventionType}: {$journalText}" : "Eingriff erforderlich: {$journalText}"; + $fullText .= ' (Offline-Sync)'; + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $fullText, + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'), + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + return ['success' => true, 'newStatus' => 'intervention_required']; + } + + private function processUpdateCableData($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + + if (!$workorderId) { + return ['success' => false, 'error' => 'Missing workorderId']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + $journalText = "Zusatzdaten aktualisiert (Offline-Sync):\n"; + $changed = false; + + if (isset($payload['cableLength'])) { + if ($workorder->cableLength != $payload['cableLength']) { + $journalText .= "Kabellänge: '{$workorder->cableLength}' -> '{$payload['cableLength']}'\n"; + $workorder->cableLength = $payload['cableLength']; + $changed = true; + } + } + + if (isset($payload['cableType'])) { + if ($workorder->cableType != $payload['cableType']) { + $journalText .= "Kabeltyp: '{$workorder->cableType}' -> '{$payload['cableType']}'\n"; + $workorder->cableType = $payload['cableType']; + $changed = true; + } + } + + if ($changed) { + WorkorderModel::update((array)$workorder); + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $journalText, + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + } + + return ['success' => true, 'changed' => $changed]; + } + + private function processCompleteWorkorder($payload, $clientTimestamp) { + $workorderId = intval($payload['workorderId'] ?? 0); + + if (!$workorderId) { + return ['success' => false, 'error' => 'Missing workorderId']; + } + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) { + return ['success' => false, 'error' => 'Workorder not found']; + } + + // Validate cable data if required + $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId); + if ($tenantConfig) { + if ($tenantConfig->requireCableLength && empty(trim($workorder->cableLength))) { + return ['success' => false, 'error' => 'Cable length required', 'validationError' => true]; + } + if ($tenantConfig->requireCableType && empty(trim($workorder->cableType))) { + return ['success' => false, 'error' => 'Cable type required', 'validationError' => true]; + } + } + + // Validate checklist + $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : []; + $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]); + $uploadedTypes = array_column((array)$docs, 'documentType'); + $uploadedTypeCounts = array_count_values($uploadedTypes); + + foreach ($docTypes as $type) { + if (($type['required'] ?? false) && empty($uploadedTypeCounts[$type['value']])) { + return [ + 'success' => false, + 'error' => 'Required documentation missing: ' . $type['text'], + 'validationError' => true, + 'checklistIncomplete' => true + ]; + } + } + + $oldStatus = $workorder->status; + $workorder->status = 'documented'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Arbeitsauftrag zur Prüfung eingereicht (Offline-Sync).', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'), + 'create' => $clientTimestamp ?: time(), + 'createBy' => $this->user->id, + ]); + + return ['success' => true, 'newStatus' => 'documented']; + } + + /** + * Get thumbnail for documentation image + * GET /MobileApp/Workorder/Workorder/getThumbnail?fileId=X + */ + public function getThumbnailAction() { + $fileId = intval($this->request->fileId ?? 0); + + if (!$fileId) { + header('HTTP/1.1 400 Bad Request'); + echo 'File ID required'; + return; + } + + $file = new File($fileId); + if (!$file->id) { + header('HTTP/1.1 404 Not Found'); + echo 'File not found'; + return; + } + + // Check if it's an image + $mimetype = $file->mimetype ?? ''; + if (strpos($mimetype, 'image/') !== 0) { + // For non-images, redirect to download + header('Location: /File/Download/' . $fileId); + return; + } + + // Generate thumbnail path + $thumbDir = DATADIR . 'thumbnails/workorder/'; + if (!is_dir($thumbDir)) { + mkdir($thumbDir, 0755, true); + } + + $thumbPath = $thumbDir . $fileId . '_200x200.jpg'; + + // Generate thumbnail if it doesn't exist + if (!file_exists($thumbPath)) { + $sourcePath = $file->getFilepath(); + if (!$sourcePath || !file_exists($sourcePath)) { + header('HTTP/1.1 404 Not Found'); + echo 'Source file not found'; + return; + } + + // Create thumbnail + $this->createThumbnail($sourcePath, $thumbPath, 200, 200, $mimetype); + } + + // Serve the thumbnail + if (file_exists($thumbPath)) { + header('Content-Type: image/jpeg'); + header('Cache-Control: public, max-age=31536000'); // Cache for 1 year + header('Content-Length: ' . filesize($thumbPath)); + readfile($thumbPath); + } else { + // Fallback to original + header('Location: /File/Download/' . $fileId); + } + } + + /** + * Create a thumbnail from an image + */ + private function createThumbnail($sourcePath, $destPath, $maxWidth, $maxHeight, $mimetype) { + // Get source image info + $imageInfo = getimagesize($sourcePath); + if (!$imageInfo) { + return false; + } + + $srcWidth = $imageInfo[0]; + $srcHeight = $imageInfo[1]; + + // Calculate new dimensions + $ratio = min($maxWidth / $srcWidth, $maxHeight / $srcHeight); + $newWidth = round($srcWidth * $ratio); + $newHeight = round($srcHeight * $ratio); + + // Create source image resource + switch ($mimetype) { + case 'image/jpeg': + case 'image/jpg': + $srcImage = imagecreatefromjpeg($sourcePath); + break; + case 'image/png': + $srcImage = imagecreatefrompng($sourcePath); + break; + case 'image/gif': + $srcImage = imagecreatefromgif($sourcePath); + break; + case 'image/webp': + $srcImage = imagecreatefromwebp($sourcePath); + break; + default: + return false; + } + + if (!$srcImage) { + return false; + } + + // Create destination image + $destImage = imagecreatetruecolor($newWidth, $newHeight); + + // Preserve transparency for PNG + if ($mimetype === 'image/png') { + imagealphablending($destImage, false); + imagesavealpha($destImage, true); + $transparent = imagecolorallocatealpha($destImage, 255, 255, 255, 127); + imagefilledrectangle($destImage, 0, 0, $newWidth, $newHeight, $transparent); + } else { + // White background for other formats + $white = imagecolorallocate($destImage, 255, 255, 255); + imagefilledrectangle($destImage, 0, 0, $newWidth, $newHeight, $white); + } + + // Resize + imagecopyresampled( + $destImage, $srcImage, + 0, 0, 0, 0, + $newWidth, $newHeight, + $srcWidth, $srcHeight + ); + + // Save as JPEG + $result = imagejpeg($destImage, $destPath, 85); + + // Clean up + imagedestroy($srcImage); + imagedestroy($destImage); + + return $result; + } + + // ===================== + // IDEMPOTENCY HELPERS + // ===================== + + /** + * Get cached idempotency response + */ + private function getIdempotencyCache($key) { + $cacheKey = self::IDEMPOTENCY_CACHE_PREFIX . $key; + + // Try APC cache first (if available) + if (function_exists('apcu_fetch')) { + $success = false; + $data = apcu_fetch($cacheKey, $success); + if ($success) { + return $data; + } + } + + // Fall back to file cache + $cachePath = sys_get_temp_dir() . '/' . $cacheKey . '.json'; + if (file_exists($cachePath)) { + $data = json_decode(file_get_contents($cachePath), true); + if ($data && isset($data['expires']) && $data['expires'] > time()) { + return $data['response']; + } + // Expired, delete + unlink($cachePath); + } + + return null; + } + + /** + * Store idempotency response in cache + */ + private function setIdempotencyCache($key, $response) { + $cacheKey = self::IDEMPOTENCY_CACHE_PREFIX . $key; + + // Try APC cache first (if available) + if (function_exists('apcu_store')) { + apcu_store($cacheKey, $response, self::IDEMPOTENCY_TTL); + return; + } + + // Fall back to file cache + $cachePath = sys_get_temp_dir() . '/' . $cacheKey . '.json'; + $data = [ + 'response' => $response, + 'expires' => time() + self::IDEMPOTENCY_TTL + ]; + file_put_contents($cachePath, json_encode($data)); + } + + // ===================== + // HELPER METHODS + // ===================== + + /** + * Transform workorder for API response + * @param array|object $wo Workorder data (can be array or object from getCompanyWorkorders) + * @param bool $detailed Include full customer details + */ + private function transformWorkorder($wo, $detailed = false) { + // Handle both array and object formats + $isArray = is_array($wo); + $get = function($key, $default = null) use ($wo, $isArray) { + if ($isArray) { + return $wo[$key] ?? $default; + } + return $wo->$key ?? $default; + }; + + // Customer name: use company if available, else customerName (firstname lastname) + $customerCompany = $get('customerCompany', ''); + $customerName = $customerCompany ?: $get('customerName', ''); + + // Build address from the joined data + $street = $get('street', ''); + $hausnummer = $get('hausnummer', ''); + $plz = $get('plz', ''); + $city = $get('city', ''); + $customerAddress = trim("{$street} {$hausnummer}, {$plz} {$city}", ', '); + + $status = $get('status'); + $appointmentDate = $get('appointmentDate'); + $deadlineDate = $get('deadlineDate'); + $cableType = $get('cableType', ''); + $cableLength = $get('cableLength', ''); + + $result = [ + 'id' => intval($get('id', 0)), + 'fcpName' => $get('rimo_fcp_name', ''), + 'oaid' => $get('oaid', ''), + 'status' => $status, + 'statusText' => $this->statusOptions[$status]['text'] ?? $status, + 'statusColor' => $this->statusOptions[$status]['color'] ?? 'secondary', + 'customerName' => $customerName, + 'customerAddress' => $customerAddress, + 'additionalInfo' => $get('additionalInfo', ''), + 'appointmentDate' => $appointmentDate ? intval($appointmentDate) : null, + 'appointmentFormatted' => $appointmentDate ? date('d.m.Y H:i', $appointmentDate) : null, + 'deadlineDate' => $deadlineDate ? intval($deadlineDate) : null, + 'deadlineFormatted' => $deadlineDate ? date('d.m.Y', $deadlineDate) : null, + 'cableType' => $cableType, + 'cableLength' => $cableLength, + 'hasCableFlag' => !empty($cableType) || !empty($cableLength), + ]; + + // For detailed view (single workorder), include customer contact info + if ($detailed) { + $result['customer'] = [ + 'id' => intval($get('id', 0)), + 'name' => $customerName, + 'street' => trim("{$street} {$hausnummer}"), + 'zip' => $plz, + 'city' => $city, + 'phone' => $get('phone', ''), + 'email' => $get('email', ''), + 'gpsLat' => null, // Not available in this query + 'gpsLng' => null, + ]; + + $result['campaign'] = $get('networkOwnerName', ''); + } + + return $result; + } + + /** + * Get status display text + */ + private function getStatusText($statusKey) { + return $this->statusOptions[$statusKey]['text'] ?? ucfirst(str_replace('_', ' ', $statusKey)); + } + + /** + * Get tenant config from workorder + */ + private function getTenantConfigFromWorkorder($workorderId) { + if (!$workorderId) return null; + + $workorder = WorkorderModel::get($workorderId); + if (!$workorder) return null; + + $preorder = new Preorder($workorder->preorderId); + if (!$preorder->id) return null; + + $campaign = new Preordercampaign($preorder->preordercampaign_id); + if (!$campaign->id) return null; + + $network = NetworkModel::getOne($campaign->network_id); + if (!$network) return null; + + return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]); + } + + /** + * Save documentation file + */ + private function saveDocumentation($workorderId, $documentType, $description) { + try { + $uploaded = mfUpload::handleFormUpload("file", false, "/Workorder"); + WorkorderDocumentationModel::create([ + 'workorderId' => $workorderId, + 'fileId' => $uploaded->id, + 'description' => $description, + 'documentType' => $documentType, + 'create' => time(), + 'createBy' => $this->user->id + ]); + } catch (Exception $e) { + // Log error if necessary + } + } + + /** + * Check if all required checklist items are completed + */ + private function allRequiredCompleted($checklist) { + foreach ($checklist as $item) { + if ($item['required'] && !$item['completed']) { + return false; + } + } + return true; + } + + /** + * Get single workorder with full joined data (same structure as getCompanyWorkorders) + */ + private function getWorkorderWithDetails($workorderId, $companyId) { + $db = $this->db(); + $fronkDbName = FRONKDB_DBNAME; + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + + $sql = " + SELECT w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo, + w.cableType, w.cableLength, hn.rimo_fcp_name, + owner_addr.company as networkOwnerName, p.preordercampaign_id, + CONCAT_WS(' ', p.firstname, p.lastname) as customerName, p.company as customerCompany, p.oaid, + p.phone, p.email, str.name as street, hn.hausnummer, hn.stiege, we.bezeichner as apartment, + plz.plz, ort.name as city + FROM `{$fronkDbName}`.`Workorder` w + JOIN `{$fronkDbName}`.`Preorder` p ON w.preorderId = p.id + LEFT JOIN `{$fronkDbName}`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id + LEFT JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id + LEFT JOIN `{$fronkDbName}`.`Address` owner_addr ON n.owner_id = owner_addr.id + LEFT JOIN `{$addressDbName}`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id + LEFT JOIN `{$addressDbName}`.`Strasse` str ON hn.strasse_id = str.id + LEFT JOIN `{$addressDbName}`.`Plz` plz ON hn.plz_id = plz.id + LEFT JOIN `{$addressDbName}`.`Ortschaft` ort ON hn.ortschaft_id = ort.id + LEFT JOIN `{$addressDbName}`.`Wohneinheit` we ON p.adb_wohneinheit_id = we.id + WHERE w.id = " . intval($workorderId) . " + AND w.companyId = " . intval($companyId) . " + LIMIT 1 + "; + + $result = $db->query($sql); + return $result ? $result->fetch_assoc() : null; + } +} 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/OpenAccessId/OpenAccessId.php b/application/OpenAccessId/OpenAccessId.php index 04f70a191..35ae7fff3 100644 --- a/application/OpenAccessId/OpenAccessId.php +++ b/application/OpenAccessId/OpenAccessId.php @@ -173,7 +173,12 @@ class OpenAccessId extends mfBaseModel { $resp_data = Rimoapi::assignOaid($this->oaid, $ftu_data['id']); // update OAID export data - $exp_data_update = json_decode($this->export_data); + + //$exp_data_update = json_decode($this->export_data); + $exp_data_update = $this->getExportData(); + if(!property_exists($exp_data_update, "rimo")) { + $exp_data_update->rimo = new StdClass(); + } $exp_data_update->rimo->ftu_id = $ftu_data['id']; $exp_data_update->rimo->ftu_name = $ftu_data['name']; $exp_data_update->rimo->ftu_assigned_date = date("U"); @@ -306,11 +311,11 @@ class OpenAccessId extends mfBaseModel { public function getExportData($key = false) { if(!$this->export_data) { - return []; + return new StdClass(); } else { $exdata = json_decode($this->export_data); if(!is_object($exdata)) { - return []; + return new StdClass(); } if(!$key) { diff --git a/application/Order/Order.php b/application/Order/Order.php index 1774f35fb..00960d6a5 100644 --- a/application/Order/Order.php +++ b/application/Order/Order.php @@ -67,6 +67,27 @@ class Order extends mfBaseModel { //var_dump($this->terminations);exit; return $terminations; } + + public function getSnoppProduct() { + foreach($this->getProperty("products") as $product) { + if($product->snopp_order_id) return $product; + } + return null; + } + + public function getOaidProduct() { + foreach($this->getProperty("products") as $product) { + if($product->oaid) return $product; + } + return null; + } + + public function getPreorderProduct() { + foreach($this->getProperty("products") as $product) { + if($product->preorder_id) return $product; + } + return null; + } public function getShippingdate() { if(!$this->id) { diff --git a/application/Order/OrderController.php b/application/Order/OrderController.php index c8da669e9..09c88cd3c 100644 --- a/application/Order/OrderController.php +++ b/application/Order/OrderController.php @@ -359,7 +359,7 @@ class OrderController extends mfBaseController { return $new_filter; } - protected function addAction() { + public function addAction() { //var_dump($this->request->filter);exit; @@ -393,9 +393,9 @@ class OrderController extends mfBaseController { $products[$pn->product_id] = $pn->product; } } - + } - + $order = $this->layout()->get("order"); if($order) { foreach($order->products as $op) { @@ -404,7 +404,7 @@ class OrderController extends mfBaseController { } } } - + $this->layout()->set("products", $products); $countries = CountryModel::getAll(); @@ -969,6 +969,12 @@ class OrderController extends mfBaseController { } $product_data = []; + if(array_key_exists("preorder_id", $p) && $p["preorder_id"]) { + $product_data["preorder_id"] = $p["preorder_id"]; + } + if(array_key_exists("oaid", $p) && $p["oaid"]) { + $product_data["oaid"] = $p["oaid"]; + } $product_data["order_id"] = $new_id; $product_data["product_id"] = $p["product_id"]; $product_data['amount'] = (!empty($p['amount'])) ? $p['amount'] : 1; @@ -1338,7 +1344,7 @@ class OrderController extends mfBaseController { $this->layout()->setFlash("Keine Berechtigung", "error"); $this->redirect("Order"); } - + $r = $this->request; $order_id = $r->id; @@ -1366,7 +1372,146 @@ class OrderController extends mfBaseController { $this->returnJson(["status" => "OK", "order" => ['id' => $order_id]]); } - + + protected function createSnoppOrderAction() { + $order_id = $this->request->id; + + if(!$order_id || $order_id < 1) { + $this->layout()->setFlash("Bestellung nicht gefunden.", "error"); + $this->redirect("Order"); + } + $order = new Order($order_id); + if(!$order->id) { + $this->layout()->setFlash("Bestellung nicht gefunden.", "error"); + $this->redirect("Order"); + } + + $order_product = false; + + $product_snopp_id = false; + $products_noterm = false; + + // find product + foreach($order->products as $op) { + // check for valid internet access product + if(!in_array($op->product->producttech_id, TT_PRODUCTTECH_IDS_INTERNET_ACCESSS)) { + continue; + } + + if($op->oaid) { + $order_product = $op; + break; + } + if($op->preorder_id) { + $order_product = $op; + break; + } + // if product has a snopp product_id, then this must be it + if($op->product->getAttributeValue("oan_pid_snopp")) { + $order_product = $op; + break; + } + } + + if(!$order_product) { + $this->layout()->setFlash("Kein für SNOPP-Bestellungen geeignetes Produkt in dieser Bestellung gefunden.", "error"); + $this->redirect("Order", "Index", ["id" => $order->id]); + } + + // order in snopp + $snopp_prod_id = $op->product->getAttributeValue("oan_pid_snopp"); + if(!$snopp_prod_id) { + $this->layout()->setFlash("SNOPP Product ID fehlt im Produkt (".$order_product->product->name.").", "error"); + $this->redirect("Order", "Index", ["id" => $order->id]); + } + + // find snopp api credentials + $api_creds = $order_product->product->getOwnerSnoppApiCredentials(); + $this->log->debug(__METHOD__.": Snopp Api Creds: ".print_r($api_creds, true)); + if(!$api_creds) { + $this->layout()->setFlash("Produktbesitzer hat keinen SNOPP Api Key", "error"); + $this->redirect("Order"); + } + $baseurl = $api_creds["prod"]["url"]; + $apikey = $api_creds["prod"]["key"]; + $snopp = new Snoppapi($baseurl, $apikey); + + $ext_id = false; + + if($order_product->oaid) { + $ext_id = $order_product->oaid; + } elseif($order_product->preorder_id) { + $preorder = new Preorder($order_product->preorder_id); + if($preorder->id && $preorder->campaign->fulfillment == "citycom_oan") { + $ext_id = "SDIHome_xtc{$preorder->adb_wohneinheit_id}_1700000000"; + } elseif($preorder->id) { + if($preorder->adb_wohneinheit->oaid) { + $ext_id = $preorder->adb_wohneinheit->oaid; + } elseif($preorder->adb_wohneinheit->extref) { + $ext_id = $preorder->adb_wohneinheit->extref; + } + } + } else { + // search for address in snopp + $search_data = [ + "street" => $order->owner->street, + "zip" => $order->owner->zip, + "city" => $order->owner->city, + ]; + $homes = $snopp->searchAddress($search_data); + if(!$homes) { + $this->layout()->setFlash("Home in Snopp nicht gefunden", "error"); + $this->redirect("Order", "Index", ["id" => $order->id]); + } + + $home = reset($homes); + $ext_id = $home->oan_id; + } + + if(!$ext_id) { + $this->layout()->setFlash("Konnte keine OAID oder External ID zur Adresse finden.", "error"); + $this->redirect("Order", "Index", ["id" => $order->id]); + } + + $data = [ + "oan_id" => ($order_product->oaid) ?: $ext_id, + "product_id" => $snopp_prod_id, + "extref" => ($op->order->partner_number) ?: $op->order->owner->customer_number, + "execution_date" => (new DateTime("now"))->setTimezone(new DateTimeZone("Europe/Vienna"))->format("c"), + "name" => $order->owner->getCompanyOrName(), + "street" => $order->owner->street, + "zip" => $order->owner->zip, + "city" => $order->owner->city, + "phone" => $order->owner->phone, + "mobile" => $order->owner->mobile, + "email" => $order->owner->email, + ]; + + + $resp = $snopp->submitOrder($data); + + if(!$resp) { + $this->layout()->setFlash("Fehler beim Bestellen im Snopp.", "error"); + $this->redirect("Order", "Index", ["id" => $order->id]); + } + + if($resp->status != "Created") { + $this->layout()->setFlash("Konnte nicht bestellt werden: '{$resp->result->message}'", "error"); + $this->redirect("Order", "Index", ["id" => $order->id]); + } + + if($resp->result->order_id) { + $order_product->snopp_order_id = $resp->result->order_id; + } else { + $order_product->snopp_order_id = 1; + } + $order_product->save(); + + $this->layout()->setFlash("Bestellung erfolgreich in SNOPP erstellt.", "success"); + $this->redirect("Order", "Index", ["id" => $order->id]); + + } + protected function deleteAction() { if(!$this->me->is(["Admin","salespartner"])) { $this->layout()->setFlash("Keine Berechtigung", "error"); diff --git a/application/OrderProduct/OrderProduct.php b/application/OrderProduct/OrderProduct.php index ea1cc5ec1..b8ad6a22b 100644 --- a/application/OrderProduct/OrderProduct.php +++ b/application/OrderProduct/OrderProduct.php @@ -48,11 +48,7 @@ class OrderProduct extends mfBaseModel { public function getProperty($name) { if($this->$name == null) { - - if(!$this->id) { - return null; - } - + if($name == "cpeprovisioning") { $this->cpeprovisioning = CpeprovisioningModel::getFirst(["orderproduct_id" => $this->id]); return $this->cpeprovisioning; @@ -128,7 +124,7 @@ class OrderProduct extends mfBaseModel { } return $this->editor; } - + $classname = ucfirst($name); $idfield = $name."_id"; $this->$name = mfValuecache::singleton()->get("mfObjectmodel-$name-".$this->$idfield); diff --git a/application/OrderProduct/OrderProductModel.php b/application/OrderProduct/OrderProductModel.php index e9ab464d6..5b417b684 100644 --- a/application/OrderProduct/OrderProductModel.php +++ b/application/OrderProduct/OrderProductModel.php @@ -5,6 +5,8 @@ class OrderProductModel public $order_id; public $product_id; public $termination_id; + public $oaid; + public $preorder_id; public $voicenumber; public $voiceplan_id; public $domain; @@ -212,6 +214,13 @@ class OrderProductModel } } + if (array_key_exists("preorder_id", $filter)) { + $preorder_id = $filter['preorder_id']; + if (is_numeric($preorder_id)) { + $where .= " AND preorder_id=$preorder_id"; + } + } + if (array_key_exists("voicenumber", $filter)) { $voicenumber = FronkDB::singleton()->escape($filter['voicenumber']); if ($voicenumber) { diff --git a/application/Preorder/Preorder.php b/application/Preorder/Preorder.php index fffeb2fbe..b4d18c772 100644 --- a/application/Preorder/Preorder.php +++ b/application/Preorder/Preorder.php @@ -26,6 +26,7 @@ class Preorder extends mfBaseModel { private $statusjournals; private $cancel_request_status; private $cancel_request_creator; + private $orderproduct; protected function beforeUpdate($data) { if(!array_key_exists("edit_by", $data)) { @@ -736,19 +737,27 @@ class Preorder extends mfBaseModel { $first_ctag = $search_ctag - ($search_ctag % $ctags_per_home); $last_ctag = $first_ctag + $ctags_per_home - 1; + $mgmt_ctag_exists = false; + $mgmt_ctag = null; $ctag_range = []; for($i = $first_ctag; $i <= $last_ctag; $i++) { - if(!PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name])) { - if($i == $last_ctag) { + $ctag = PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name]); + if(!$ctag) { + if($i == $last_ctag && !$mgmt_ctag_exists) { // mgmt ctag should be the last in range $mgmt_ctag = $i; continue; } $ctag_range[] = $i; - + } else { + if($ctag->service_type == "mgmt") { + $this->log->debug(__METHOD__.": mgmt ctag ($i / stag $stag) exists already\n"); + $mgmt_ctag_exists = true; + } } } + return [$ctag_range, $mgmt_ctag]; } @@ -1682,6 +1691,13 @@ class Preorder extends mfBaseModel { return $this->editor; } + if($name == "orderproduct") { + $op = OrderProductModel::getFirst(["preorder_id" => $this->id]); + if(!$op) return null; + $this->orderproduct = $op; + return $this->orderproduct; + } + if($name == "creator") { $user = mfValuecache::singleton()->get("Worker-id-" . $this->create_by); if($user) { diff --git a/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php index 2379756eb..52623f44a 100644 --- a/application/Preorder/PreorderController.php +++ b/application/Preorder/PreorderController.php @@ -1048,6 +1048,171 @@ class PreorderController extends mfBaseController { $this->layout()->set("no_filename", false); } + protected function createOrderFromPreorderAction() { + $preorder_id = $this->request->preorder_id; + + if(!is_numeric($preorder_id) || $preorder_id < 1) { + $this->layout()->setFlash("Vorbestellung nicht gefunden!", "error"); + $this->redirect("Preorder", "Index"); + } + + $preorder = new Preorder($preorder_id); + if(!$preorder->id) { + $this->layout()->setFlash("Vorbestellung nicht gefunden!", "error"); + $this->redirect("Preorder", "Index"); + } + + $order_data = []; + $order_data["preorder_id"] = $preorder->id; + + $owner_data = []; + foreach(["company","uid","firstname","lastname","street","zip","city","phone","email"] as $field) { + if(!trim($preorder->$field)) { + $owner_data[$field] = ""; + } + $owner_data[$field] = trim($preorder->$field); + } + + // search owner in Address and add owner_id ... + $owner = false; + $owners = AddressModel::search($owner_data); + foreach($owners as $o) { + if(!$this->me->is("employee")) { + // external salespartners must not use addresses with customer_number + if($o->customer_number) continue; + // otherwise use with address + $owner = $o; + } else { + // every address can be used as fallback + $owner = $o; + + // if we are employees, customers with customer_number and fibu_primary_account have precedence + // but still use addresses with only customer_number as fallback + if($o->customer_number) { + $owner = $o; + if($o->fibu_primary_account) { + break; + } + } + } + } + + if($owner && $owner->id) { + $order_data["owner_id"] = $owner->id; + $order_data["owner"] = $owner; + } else { + foreach($owner_data as $field => $value) { + if(!$preorder->$field) continue; + $order_data["owner_".$field] = $value; + } + $order_data["new_owner"] = 1; + } + + if($preorder->order_date) { + $order_data["order_date"] = $preorder->order_date; + } else { + $order_data["order_date"] = $preorder->create; + } + + $operator = false; + $campaign = $preorder->campaign; + if(is_array($campaign->active_operators) && count($campaign->active_operators)) { + $campaign_operator = reset($campaign->active_operators); + $operator = $campaign_operator->operator; + } + + if(!$operator) { + $this->layout()->setFlash("Kampagne hat keinen Netzbetreiber!", "error"); + $this->redirect("Preorder", "Index", ["filter" => ["preordercampaign_id" => $campaign->id]], "preorder=$preorder_id"); + } + + // try product with correct network id + $product = ProductModel::getFirst([ + "external_id" => $operator->id, + "network_id" => $campaign->network_id, + "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI, + "name" => "%OAN%", + "active" => true + ]); + if(!$product) { + // else use any product from operator + $product = ProductModel::getFirst([ + "external_id" => $operator->id, + "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI, + "name" => "%OAN%", + "active" => true + ]); + } + if(!$product) { + // else use any product from operator + $product = ProductModel::getFirst([ + "external_id" => $operator->id, + "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI, + "active" => true + ]); + } + if($operator->id == 1) { + if(!$product) { + $product = ProductModel::getFirst([ + "external" => 0, + "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI, + "network_id" => $campaign->network_id, + "active" => true + ]); + } + if(!$product) { + $product = ProductModel::getFirst([ + "external" => 0, + "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI, + "name" => "%OAN%", + "active" => true + ]); + } + } + //var_dump($product);exit; + if(!$product) { + $this->layout()->setFlash("Keine Produkte für Netzbetreiber gefunden!", "error"); + $this->redirect("Preorder", "Index", ["filter" => ["preordercampaign_id" => $campaign->id]], "preorder=$preorder_id"); + } + + $product_data = []; + $product_data["preorder_id"] = $preorder->id; + $product_data["oaid"] = $preorder->oaid; + $product_data["product_id"] = $product->id; + $product_data['amount'] = 1; + $product_data["pos"] = 1; + $product_data["description"] = ""; + $product_data["price"] = trim($product->price) ? Layout::commaToDot(trim($product->price)) : 0; + $product_data["price_setup"] = trim($product->price_setup) ? Layout::commaToDot(trim($product->price_setup)) : 0; + + $product_data["billing_delay"] = ($product->billing_delay) ? $product->billing_delay : 0; + if($product_data["billing_delay"] > 6) { + $product_data["billing_delay"] = 6; + } + $product_data["billing_period"] = $product->billing_period; + $product_data["contract_term"] = $product->contract_term; + + if($this->me->is("Admin")) { + $product_data["price_nne"] = $product->price_nne; + $product_data["price_nbe"] = $product->price_nbe; + } + + $order_data["products"] = [1 => OrderProductModel::create($product_data)]; + + //var_dump($order_data["products"]);exit; + $order = new Order(); + $order->update($order_data); + + //var_dump($owner_data);exit; + + $oc = new OrderController(); + + $this->layout()->set("order", $order); + return $oc->addAction(); + + + } + protected function apiAction() { $do = $this->request->do; $data = []; @@ -1420,8 +1585,14 @@ class PreorderController extends mfBaseController { $new_remark = date("d.m.Y").": ".$new_remark; + $api_creds = $preorder->getNetownerRimoApiCredentials(); + $this->log->debug(__METHOD__.": Rimo Api Creds: ".print_r($api_creds, true)); + if(!$api_creds) return false; + + $apikey = $api_creds["prod"]["key"]; + // upload remark to Rimo - if(!Rimoapi::addRemark($workorder->rimo_id, $new_remark)) { + if(!Rimoapi::addRemark($apikey, $workorder->rimo_id, $new_remark)) { return false; } diff --git a/application/PreorderBillingInvoice/PreorderBillingInvoice.php b/application/PreorderBillingInvoice/PreorderBillingInvoice.php index 96b755201..0ca11cae6 100644 --- a/application/PreorderBillingInvoice/PreorderBillingInvoice.php +++ b/application/PreorderBillingInvoice/PreorderBillingInvoice.php @@ -6,6 +6,7 @@ use chillerlan\QRCode\Output\QROutputInterface; class PreorderBillingInvoice extends mfBaseModel { protected $forcestr = ["company", "zip", "email", "phone"]; + private $owner; private $netowner; private $positions; private $pdf; diff --git a/application/PreorderCtag/PreorderCtag.php b/application/PreorderCtag/PreorderCtag.php index db3f194f0..85f589cda 100644 --- a/application/PreorderCtag/PreorderCtag.php +++ b/application/PreorderCtag/PreorderCtag.php @@ -1,10 +1,8 @@ debug($sql); $res = $db->query($sql); @@ -230,7 +228,7 @@ class PreorderCtag extends mfBaseModel { $where = self::getSqlFilter($filter); $sql = "SELECT PreorderCtag.* FROM PreorderCtag WHERE $where - ORDER BY preorder_id DESC,stag DESC,ctag DESC LIMIT 1"; + ORDER BY ctag DESC LIMIT 1"; //var_dump($sql);exit; mfLoghandler::singleton()->debug($sql); $res = $db->query($sql); diff --git a/application/Preorderlogistics/PreorderlogisticsController.php b/application/Preorderlogistics/PreorderlogisticsController.php index 3254f53e1..b39cfe0fb 100644 --- a/application/Preorderlogistics/PreorderlogisticsController.php +++ b/application/Preorderlogistics/PreorderlogisticsController.php @@ -337,6 +337,16 @@ class PreorderlogisticsController extends mfBaseController { } } + // Date filter for sent date (Versanddatum range) + if (!empty($filter['sent_date']) && is_array($filter['sent_date'])) { + if (!empty($filter['sent_date']['from'])) { + $new_filter['add-where'] .= " AND Preorderlogistics.sent >= " . intval($filter['sent_date']['from']); + } + if (!empty($filter['sent_date']['to'])) { + $new_filter['add-where'] .= " AND Preorderlogistics.sent <= " . intval($filter['sent_date']['to']); + } + } + $new_filter["status_code"] = 140; $new_filter["deleted"] = 0; $new_filter["unit_count<="] = 2; diff --git a/application/Product/Product.php b/application/Product/Product.php index 9bbcd6d9d..eff806c3a 100644 --- a/application/Product/Product.php +++ b/application/Product/Product.php @@ -54,6 +54,28 @@ class Product extends mfBaseModel { } + public function getAttributeValue($name) { + $attributes = $this->getProperty("attributes"); + if(!array_key_exists($name, $attributes) || !$attributes[$name]->value) { + return null; + } + return $attributes[$name]->value; + } + + public function getOwnerSnoppApiCredentials() { + $owner = $this->getProperty("owner"); + if(!$owner) return false; + + foreach(TT_SNOPP_API_CREDS as $api_creds) { + if($api_creds["address_id"] == $owner->id) { + return $api_creds; + } + } + + return null; + } + + public function getProperty($name) { if($this->$name == null) { diff --git a/application/Product/ProductModel.php b/application/Product/ProductModel.php index eafe20bd4..5cd22046c 100644 --- a/application/Product/ProductModel.php +++ b/application/Product/ProductModel.php @@ -105,6 +105,7 @@ class ProductModel { LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id) LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id) LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id) + LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id) WHERE $where GROUP BY Product.id ORDER BY Productgroup.name,Producttech.name,Product.name LIMIT 1 @@ -135,6 +136,7 @@ class ProductModel { LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id) LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id) LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id) + LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id) WHERE $where GROUP BY Product.id ) as p @@ -160,6 +162,7 @@ class ProductModel { LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id) LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id) LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id) + LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id) WHERE $where GROUP BY Product.id ORDER BY Productgroup.name,Producttech.name,Product.name @@ -169,7 +172,7 @@ class ProductModel { if(is_array($limit) && count($limit)) { if(is_numeric($limit['start']) && is_numeric($limit['count'])) { $sql .= " LIMIT ".$limit['start'].", ".$limit['count']; - } elseif(is_numeric($count)) { + } elseif(is_numeric($limit['count'])) { $sql .= " LIMIT ".$limit['count']; } } @@ -232,6 +235,15 @@ class ProductModel { $where .= " AND Product.sla_id IN (". implode(",", $sla_id).")"; } } + + if(array_key_exists("network_id", $filter)) { + $network_id = $filter['network_id']; + if(is_numeric($network_id)) { + $where .= " AND ProductNetwork.network_id=$network_id"; + } elseif(is_array($network_id) && count($network_id)) { + $where .= " AND ProductNetwork.network_id IN (". implode(",", $network_id).")"; + } + } if(array_key_exists("name", $filter)) { $name = $db->escape($filter['name']); @@ -284,7 +296,7 @@ class ProductModel { if(array_key_exists("attributevalue", $filter)) { $attributevalue = $db->escape($filter['attributevalue']); - if($attributevalue) { + if(strlen($attributevalue)) { $where .= " AND ProductAttribute.value = '$attributevalue'"; } } diff --git a/application/Radius/RadiusController.php b/application/Radius/RadiusController.php index 5af86bb65..7c8e96be3 100644 --- a/application/Radius/RadiusController.php +++ b/application/Radius/RadiusController.php @@ -4,9 +4,45 @@ use PHPMailer\PHPMailer\Exception; class RadiusController extends mfBaseController { private User $me; + private bool $isApiCall = false; + + private array $apiAllowedActions = [ + 'ProxyUnsecureHTTPRequestToRadius', + 'GenieacsRunSpeedtest', + 'GenieacsGetSpeedtestResult', + 'GenieacsGetDeviceByIp', + 'GenieacsGetDeviceByMac', + 'GenieacsRefreshDevice', + 'GenieacsRebootDevice', + 'GenieacsGetDeviceInfo', + 'GenieacsPing', + 'GenieacsRemoteAccess', + 'GenieacsEventLog', + 'GenieacsNetworkStructure', + ]; protected function init(): void { - $this->needlogin=true; + $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null; + + if ($apiKey && in_array($this->action, $this->apiAllowedActions)) { + $me = new User(); + $me->loadByApikey($apiKey); + + if ($me->id) { + $this->me = $me; + $this->isApiCall = true; + $this->needlogin = false; + if (!defined('INTERNAL_USER_ID')) { + define('INTERNAL_USER_ID', $me->id); + } + header("Access-Control-Allow-Origin: *"); + header("Access-Control-Allow-Methods: GET, POST, OPTIONS"); + header("Access-Control-Allow-Headers: Content-Type, X-API-Key"); + return; + } + } + + $this->needlogin = true; $me = new User(); $me->loadMe(); $this->layout()->set("me", $me); @@ -51,20 +87,32 @@ class RadiusController extends mfBaseController { $acs = $this->getGenieACS(); - // Set speedtest parameters on the device - $acs->setParameterValues($deviceId, [ + $resolvedId = $this->resolveDeviceId($deviceId, $acs); + if (!$resolvedId) self::sendError("Device not found in GenieACS"); + + $acs->getParameterValues($resolvedId, [ + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start', + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect', + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess', + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result' + ]); + + sleep(2); + + $acs->setParameterValues($resolvedId, [ 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start' => 1, 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1, 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true ]); - // Get device and extract IP - $device = $acs->getDevice($deviceId); - $ip = GenieACS::getExternalIP($device); + sleep(3); + + $device = $acs->getDevice($resolvedId); + $managementIp = GenieACS::getManagementIP($device); + $externalIp = GenieACS::getExternalIP($device); + $ip = $externalIp ?: $managementIp; if (!$ip) self::sendError("Could not determine device IP"); - - // Trigger speedtest via external API $url = "http://acs.xinon.at:5000/run-speedtest"; $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; $data = json_encode(['ip' => $ip]); @@ -84,9 +132,8 @@ class RadiusController extends mfBaseController { if ($response === false) self::sendError("Failed to connect to speedtest server"); - self::returnJson(['success' => true, 'message' => 'Speedtest started']); + self::returnJson(['success' => true, 'message' => 'Speedtest started', 'ip' => $ip, 'serverResponse' => json_decode($response, true)]); } catch (Exception $e) { - $this->log->debug("Speedtest Error", ['error' => $e->getMessage()]); self::sendError("Error running speedtest: " . $e->getMessage()); } } @@ -101,11 +148,12 @@ class RadiusController extends mfBaseController { $acs = $this->getGenieACS(); - // Request parameter refresh - $acs->getParameterValues($deviceId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']); + $resolvedId = $this->resolveDeviceId($deviceId, $acs); + if (!$resolvedId) self::sendError("Device not found in GenieACS"); - // Get device info with full data - $device = $acs->getDevice($deviceId); + $acs->getParameterValues($resolvedId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']); + + $device = $acs->getDevice($resolvedId); if (!$device) self::sendError("Device not found"); @@ -178,6 +226,19 @@ class RadiusController extends mfBaseController { return new GenieACS($host, $username, $password); } + private function resolveDeviceId(string $deviceId, GenieACS $acs): ?string { + if (strpos($deviceId, ':') !== false) { + $device = $acs->getDeviceByMac($deviceId); + if ($device) { + $resolvedId = GenieACS::getDeviceId($device); + if ($resolvedId) return $resolvedId; + if (isset($device['_id'])) return $device['_id']; + } + return null; + } + return $deviceId; + } + protected function genieacsGetDeviceByIpAction() { try { $ip = $_GET['ip'] ?? null; @@ -218,6 +279,73 @@ class RadiusController extends mfBaseController { } } + protected function genieacsGetDeviceByMacAction() { + try { + $mac = $_GET['mac'] ?? null; + $this->log->debug("genieacsGetDeviceByMacAction", ['mac' => $mac]); + if (!$mac) self::sendError("MAC address is required"); + + $acs = $this->getGenieACS(); + $matchedDevice = $acs->getDeviceByMac($mac); + + if (!$matchedDevice) { + self::returnJson(['success' => false, 'message' => 'No device found with this MAC address']); + return; + } + + self::returnJson([ + 'success' => true, + 'deviceId' => GenieACS::getDeviceId($matchedDevice), + 'deviceInfo' => GenieACS::getDeviceInfo($matchedDevice), + 'mac' => $mac, + 'externalIp' => GenieACS::getExternalIP($matchedDevice), + 'managementIp' => GenieACS::getManagementIP($matchedDevice) + ]); + } catch (Exception $e) { + $this->log->debug("GetDeviceByMac Error", ['error' => $e->getMessage()]); + self::sendError("Error fetching device: " . $e->getMessage()); + } + } + + protected function genieacsRefreshDeviceAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRefreshDeviceAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + + $resolvedId = $this->resolveDeviceId($deviceId, $acs); + if (!$resolvedId) self::sendError("Device not found in GenieACS"); + + $acs->getParameterValues($resolvedId, [ + 'InternetGatewayDevice.DeviceInfo.HardwareVersion', + 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', + 'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.MACAddress', + 'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.ExternalIPAddress', + 'InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.SSID', + 'InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.KeyPassphrase', + 'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.HostName', + 'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.IPAddress', + 'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.MACAddress' + ]); + + $device = $acs->getDevice($resolvedId); + + self::returnJson([ + 'success' => true, + 'deviceInfo' => GenieACS::getDeviceInfo($device), + 'externalIp' => GenieACS::getExternalIP($device), + 'managementIp' => GenieACS::getManagementIP($device) + ]); + } catch (Exception $e) { + $this->log->debug("RefreshDevice Error", ['error' => $e->getMessage()]); + self::sendError("Error refreshing device: " . $e->getMessage()); + } + } + protected function genieacsRebootDeviceAction() { try { $input = json_decode(file_get_contents('php://input'), true); @@ -310,8 +438,11 @@ class RadiusController extends mfBaseController { if (!$deviceId) self::sendError("Device ID is required"); $acs = $this->getGenieACS(); - $creds = $acs->createRemoteUser($deviceId); + $resolvedId = $this->resolveDeviceId($deviceId, $acs); + if (!$resolvedId) self::sendError("Device not found in GenieACS"); + + $creds = $acs->createRemoteUser($resolvedId); if (!$creds) self::sendError("Could not obtain credentials for FritzBox"); $url = "http://acs.xinon.at:5000/read-fritz-eventlog"; @@ -362,8 +493,11 @@ class RadiusController extends mfBaseController { if (!$deviceId) self::sendError("Device ID is required"); $acs = $this->getGenieACS(); - $creds = $acs->createRemoteUser($deviceId); + $resolvedId = $this->resolveDeviceId($deviceId, $acs); + if (!$resolvedId) self::sendError("Device not found in GenieACS"); + + $creds = $acs->createRemoteUser($resolvedId); if (!$creds) self::sendError("Could not obtain credentials for FritzBox"); $url = "http://acs.xinon.at:5000/read-fritz"; @@ -523,47 +657,302 @@ class RadiusController extends mfBaseController { private function getVendor($mac) { - + $mac = strtoupper(str_replace([':', '-', '.'], '', $mac)); - + if (strlen($mac) < 6) return null; - - - + + + $path = TEMP_DIR . '/mac-vendors.csv'; - + if (!file_exists($path)) return null; - - - + + + // Format as XX:XX:XX - + $prefix = substr($mac, 0, 2) . ':' . substr($mac, 2, 2) . ':' . substr($mac, 4, 2); - - - - // Use grep for speed if available, else fallback to basic search? - + + + + // Use grep for speed if available, else fallback to basic search? + // Assuming Linux env as per docker context. - + $cmd = "grep -m 1 \"^" . $prefix . "\" " . escapeshellarg($path); - + $output = shell_exec($cmd); - - - + + + if ($output) { - + $parts = str_getcsv($output); - + if (isset($parts[1])) return $parts[1]; - + } - + return null; - + } - + + // ========== AVM Scanner Methods ========== + + private static $avmScannerStateFile = null; + + private static $avmPrefixes = [ + '00:04:0E', '00:15:0C', '00:1A:4F', '00:1C:4A', '00:1F:3F', '00:24:FE', + '04:B4:FE', '08:96:D7', '08:B6:57', '0C:72:74', + '1C:ED:6F', + '24:65:11', '2C:3A:FD', '2C:91:AB', + '34:31:C4', '34:81:C4', '34:E1:A9', '38:10:D5', '3C:37:12', '3C:A6:2F', + '44:4E:6D', '48:5D:35', + '50:E6:36', '5C:49:79', + '60:B5:8D', + '74:42:7F', '7C:FF:4D', + '80:23:95', + '98:9B:CB', '98:A9:65', '9C:C7:A6', + 'B0:F2:08', 'B4:FC:7D', 'BC:05:43', + 'C0:25:06', 'C8:0E:14', 'CC:CE:1E', + 'D0:12:CB', 'D4:24:DD', 'DC:15:C8', 'DC:39:6F', + 'E0:08:55', 'E0:28:6D', 'E8:DF:70', + 'F0:B0:14' + ]; + + private function getAvmScannerStatePath(): string { + return BASEDIR . '/files/avm_scanner.json'; + } + + private function loadAvmScannerState(): array { + $path = $this->getAvmScannerStatePath(); + if (file_exists($path)) { + $content = file_get_contents($path); + $state = json_decode($content, true); + if (is_array($state)) return $state; + } + return [ + 'scanning' => false, + 'stopRequested' => false, + 'progress' => ['current' => 0, 'total' => 0], + 'currentDevice' => null, + 'startedAt' => null, + 'startedBy' => null, + 'lastUpdated' => date('c'), + 'devices' => [] + ]; + } + + private function saveAvmScannerState(array $state): void { + $dir = dirname($this->getAvmScannerStatePath()); + if (!is_dir($dir)) @mkdir($dir, 0777, true); + $state['lastUpdated'] = date('c'); + file_put_contents($this->getAvmScannerStatePath(), json_encode($state, JSON_PRETTY_PRINT)); + } + + private function isAvmMac(string $mac): bool { + $mac = strtoupper(trim($mac)); + $prefix = substr($mac, 0, 8); + return in_array($prefix, self::$avmPrefixes); + } + + private function fetchRadiusUsersFromApi(): array { + $url = "http://radius.xinon.at/api.php"; + $opts = [ + "http" => [ + "method" => "GET", + "header" => "Authorization: Basic " . base64_encode("admin:saveman"), + "timeout" => 120 + ] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response === false) return []; + $data = json_decode($response, true); + return is_array($data) ? $data : []; + } + + private function fetchRadacctForUser(string $username): ?array { + $url = "http://radius.xinon.at/api.php?action2=fetchRadacct&username=" . urlencode($username); + $opts = [ + "http" => [ + "method" => "GET", + "header" => "Authorization: Basic " . base64_encode("admin:saveman"), + "timeout" => 10 + ] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response === false) return null; + $data = json_decode($response, true); + return is_array($data) ? $data : null; + } + + protected function avmScannerGetStateAction() { + $state = $this->loadAvmScannerState(); + header('Content-Type: application/json'); + echo json_encode($state); + die(); + } + + protected function avmScannerGetUsersAction() { + $users = $this->fetchRadiusUsersFromApi(); + $debug = $_GET['debug'] ?? false; + + $macUsers = 0; + $avmMacUsers = 0; + $notInAcs = 0; + $macPrefixCounts = []; + + $avmUsers = []; + foreach ($users as $user) { + $username = trim($user['username'] ?? ''); + if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue; + $macUsers++; + + $prefix = strtoupper(substr($username, 0, 8)); + $macPrefixCounts[$prefix] = ($macPrefixCounts[$prefix] ?? 0) + 1; + + if (!$this->isAvmMac($username)) continue; + $avmMacUsers++; + + $info = $user['info'] ?? ''; + if (stripos($info, 'ACS') !== false) continue; + $notInAcs++; + + $user['username'] = $username; + $avmUsers[] = $user; + } + + if ($debug) { + arsort($macPrefixCounts); + header('Content-Type: application/json'); + echo json_encode([ + 'totalFromApi' => count($users), + 'macAddressUsers' => $macUsers, + 'avmMacUsers' => $avmMacUsers, + 'notInAcs' => $notInAcs, + 'topMacPrefixes' => array_slice($macPrefixCounts, 0, 30, true), + 'avmPrefixes' => self::$avmPrefixes + ]); + die(); + } + + header('Content-Type: application/json'); + echo json_encode(['users' => $avmUsers, 'count' => count($avmUsers)]); + die(); + } + + protected function avmScannerStartAction() { + $state = $this->loadAvmScannerState(); + if ($state['scanning']) { + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'message' => 'Scan already running']); + die(); + } + + // Count how many users will be scanned (for immediate feedback) + $users = $this->fetchRadiusUsersFromApi(); + $count = 0; + foreach ($users as $user) { + $username = trim($user['username'] ?? ''); + if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue; + if (!$this->isAvmMac($username)) continue; + $info = $user['info'] ?? ''; + if (stripos($info, 'ACS') !== false) continue; + $count++; + } + + // Spawn background script using nohup to ensure it runs independently + $scriptPath = BASEDIR . '/scripts/avm_scanner.php'; + $logPath = BASEDIR . '/files/avm_scanner.log'; + $cmd = "nohup php " . escapeshellarg($scriptPath) . " >> " . escapeshellarg($logPath) . " 2>&1 &"; + shell_exec($cmd); + + header('Content-Type: application/json'); + echo json_encode(['success' => true, 'message' => 'Scan started', 'total' => $count]); + die(); + } + + protected function avmScannerStopAction() { + $state = $this->loadAvmScannerState(); + $state['stopRequested'] = true; + $this->saveAvmScannerState($state); + header('Content-Type: application/json'); + echo json_encode(['success' => true, 'message' => 'Stop requested']); + die(); + } + + protected function avmScannerToggleErledigtAction() { + $input = json_decode(file_get_contents('php://input'), true); + $mac = $input['mac'] ?? null; + if (!$mac) { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode(['error' => 'MAC address required']); + die(); + } + + $state = $this->loadAvmScannerState(); + foreach ($state['devices'] as &$device) { + if ($device['mac'] === $mac) { + $device['erledigt'] = !($device['erledigt'] ?? false); + break; + } + } + $this->saveAvmScannerState($state); + header('Content-Type: application/json'); + echo json_encode(['success' => true]); + die(); + } + + private function detectFritzPort(string $ip): ?int { + $url = "https://acs.xinon.at/detect-port"; + $data = json_encode(['fritz_ip' => $ip, 'timeout' => 3]); + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 15 + ], + "ssl" => ["verify_peer" => false, "verify_peer_name" => false] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response) { + $json = json_decode($response, true); + if ($json && $json['success'] && isset($json['port'])) { + return (int)$json['port']; + } + } + return null; + } + + private function detectFritzDevice(string $ip, int $port): ?array { + $url = "https://acs.xinon.at/detect-device"; + $data = json_encode(['fritz_ip' => $ip, 'fritz_port' => (string)$port]); + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 15 + ], + "ssl" => ["verify_peer" => false, "verify_peer_name" => false] + ]; + $context = stream_context_create($opts); + $response = @file_get_contents($url, false, $context); + if ($response) { + $json = json_decode($response, true); + if ($json && $json['success'] && isset($json['device'])) { + return $json['device']; + } + } + return null; + } + } \ No newline at end of file diff --git a/application/RimoWorkorder/RimoWorkorder.php b/application/RimoWorkorder/RimoWorkorder.php index 31dcb8994..0b6bccc89 100644 --- a/application/RimoWorkorder/RimoWorkorder.php +++ b/application/RimoWorkorder/RimoWorkorder.php @@ -1,5 +1,7 @@ id || !$this->adb_wohneinheit_id) return ['success' => false, 'message' => 'Missing ID']; + $preorder = PreorderModel::getFirstActive(["adb_wohneinheit_id" => $this->adb_wohneinheit_id]); + if (!$preorder?->id) return ['success' => false, 'message' => 'No active Preorder']; + $workorder = WorkorderModel::getFirst(['preorderId' => $preorder->id]); + if (!$workorder || !($pdf = $this->getAha())) return ['success' => false, 'message' => 'No Workorder or PDF']; + + try { + $dropkabel = $this->parseDropkabelFromPdf($pdf); + $map = $this->extractMapFromPdf($pdf); + $meta = json_decode($workorder->metadata ?: '{}', true) ?: []; + $mapFileId = null; + + if ($map) { + if ($oldId = ($meta['dropcable']['map_file_id'] ?? null)) { + $old = new File($oldId); if ($old->id) try { $old->delete(); } catch (Exception $e) {} + } + $fn = 'aha_lageplan_' . $this->id . '_' . time() . '.png'; + $file = FileModel::create(['name' => 'AHA Lageplan ' . $this->rimo_name, 'filename' => $fn, + 'store_filename' => $fn, 'orig_filename' => 'AHA_Lageplan_' . $this->rimo_name . '.png', + 'mimetype' => 'image/png', 'subfolder' => 'aha_maps', 'create_by' => 1]); + if ($file->save()) { + $dir = MFUPLOAD_FILE_SAVE_PATH . '/aha_maps'; + if (!is_dir($dir)) mkdir($dir, 0755, true); + if (file_put_contents("$dir/$fn", $map)) $mapFileId = $file->id; + } + } + $meta['dropcable'] = ['rimo_workorder_id' => $this->id, 'rimo_name' => $this->rimo_name, + 'parsed_at' => time(), 'entries' => $dropkabel, 'map_file_id' => $mapFileId]; + $workorder->metadata = json_encode($meta); + WorkorderModel::update((array)$workorder); + return ['success' => true, 'dropkabel_count' => count($dropkabel), 'has_map' => (bool)$mapFileId]; + } catch (Exception $e) { + $this->log->error(__METHOD__ . ": " . $e->getMessage()); + return ['success' => false, 'message' => $e->getMessage()]; + } + } + + public static function autoParseForWorkorder(int $workorderId, bool $force = false): void { + $wo = WorkorderModel::get($workorderId); + if (!$wo) return; + $meta = json_decode($wo->metadata ?? '{}', true); + if (($force || empty($meta['dropcable']['parsed_at'])) && $wo->preorderId) { + $pre = new Preorder($wo->preorderId); + $rimos = $pre->adb_wohneinheit_id ? RimoWorkorderModel::search(['adb_wohneinheit_id' => $pre->adb_wohneinheit_id]) : []; + if (!empty($rimos[0])) (new self($rimos[0]->id))->parseAha(); + } + } + + private function parseDropkabelFromPdf(string $pdf): array { + $result = []; + $text = (new Parser())->parseContent($pdf)->getPages()[0]?->getText() ?? ''; + + if (!preg_match('/Dropkabel:\s*\n(.+?)(?:Lage:|$)/s', $text, $m)) { + // Try alternative pattern without strict newline requirement + if (!preg_match('/Dropkabel[:\s]*(.+?)(?:Lage|Anschluss|$)/si', $text, $m)) { + return $result; + } + } + + $started = false; + foreach (explode("\n", $m[1]) as $line) { + $line = trim($line); + if (!$line) continue; + + // Check for header line (ID and Type columns) + if (stripos($line, 'ID') !== false && (stripos($line, 'Type') !== false || stripos($line, 'Typ') !== false)) { + $started = true; + continue; + } + + // Flexible cable ID pattern - matches F26-K009, F-ABC123-K01, F-XYZ(1)-K02, etc. + if ($started && preg_match('#^([A-Z][A-Z0-9()/_-]*-K\d+)\s+(.+)$#i', $line, $p)) { + $rest = $p[2]; $status = ''; + foreach (['Planfreigabe', 'Plan released', 'Grobplanung', 'Executed', 'Ausgeführt', 'Detailed planning', 'Detailplanung'] as $s) + if (preg_match('/\b' . preg_quote($s, '/') . '\s*$/i', $rest)) { + $status = $s; $rest = trim(preg_replace('/\b' . preg_quote($s, '/') . '\s*$/i', '', $rest)); break; + } + $lp = $li = ''; + if (preg_match_all('/(\d+)\s*m\b/', $rest, $lens, PREG_SET_ORDER)) { + $lp = ($lens[0][1] ?? '') . ' m'; $li = ($lens[1][1] ?? '') . ' m'; + $rest = preg_replace('/\d+\s*m\b/', '', $rest); + } + $result[] = ['cable_id' => trim($p[1]), 'type' => trim(preg_replace('/\s+/', ' ', $rest)), + 'laenge_plan' => $lp, 'laenge_ist' => $li, 'status' => $status]; + } + } + + return $result; + } + + private function extractMapFromPdf(string $pdf): ?string { + $tmp = tempnam(sys_get_temp_dir(), 'aha_'); file_put_contents($tmp, $pdf); + $out = tempnam(sys_get_temp_dir(), 'aha_img_'); unlink($out); + exec(sprintf('pdftoppm -png -f 1 -l 1 -r 150 %s %s 2>&1', escapeshellarg($tmp), escapeshellarg($out)), $_, $ret); + @unlink($tmp); + $outFile = file_exists("$out-1.png") ? "$out-1.png" : "$out.png"; + if ($ret !== 0 || !file_exists($outFile)) return null; + $img = @imagecreatefromstring(file_get_contents($outFile)); @unlink($outFile); + if (!$img) return null; + $h = imagesy($img); $cropY = (int)($h * 0.42); $cropH = (int)($h * 0.84) - $cropY; + $cropped = imagecrop($img, ['x' => 60, 'y' => $cropY, 'width' => imagesx($img) - 90, 'height' => $cropH]); + imagedestroy($img); if (!$cropped) return null; + ob_start(); imagepng($cropped, null, 6); $content = ob_get_clean(); imagedestroy($cropped); + return $content; + } public function getProperty($name) { if($this->$name == null) { diff --git a/application/RimoWorkorder/RimoWorkorderController.php b/application/RimoWorkorder/RimoWorkorderController.php index 1038575c0..5fd097a28 100644 --- a/application/RimoWorkorder/RimoWorkorderController.php +++ b/application/RimoWorkorder/RimoWorkorderController.php @@ -13,6 +13,7 @@ class RimoWorkorderController extends mfBaseController { protected function downloadAhaAction() { $workorder_id = $this->request->id; + $inline = !empty($this->request->inline); if(!$workorder_id || $workorder_id < 1) { header("HTTP/1.1 400 Bad Request"); @@ -34,10 +35,33 @@ class RimoWorkorderController extends mfBaseController { exit; } - header("Content-type: text/pdf"); - header('Content-disposition: attachment; filename="'.$workorder->rimo_name.'_AHA.pdf"'); + $filename = $workorder->rimo_name.'_AHA.pdf'; + $disposition = $inline ? 'inline' : 'attachment'; + + header("Content-type: application/pdf"); + header('Content-disposition: '.$disposition.'; filename="'.$filename.'"'); echo $return; exit; } + protected function parseAhaAction() { + header('Content-Type: application/json'); + $post = json_decode(file_get_contents('php://input'), true); + $id = $post['id'] ?? $this->request->id ?? null; + + if (!$id || $id < 1) { + echo json_encode(['success' => false, 'message' => 'Invalid workorder id.']); + exit; + } + + $wo = new RimoWorkorder($id); + if (!$wo->id) { + echo json_encode(['success' => false, 'message' => 'RimoWorkorder nicht gefunden.']); + exit; + } + + echo json_encode($wo->parseAha()); + exit; + } + } \ 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/Voicenumber/VoicenumberController.php b/application/Voicenumber/VoicenumberController.php index 1e192288c..22ec7d427 100644 --- a/application/Voicenumber/VoicenumberController.php +++ b/application/Voicenumber/VoicenumberController.php @@ -114,8 +114,11 @@ class VoicenumberController extends mfBaseController { $number_data['port_out_date'] = self::dateToTimestamp($r->port_out_date); } - if($r->disabled === "1") { - $number_data['disabled'] = 1; + if($r->disabled == "1") { + if(!$number->disabled) { + $number_data['disabled'] = date('U'); + $number_data['disabled_by'] = $this->me->id; + } switch($r->disabled_reason) { case "ported_out": $number_data['disabled_reason'] = "ported_out"; @@ -123,6 +126,9 @@ class VoicenumberController extends mfBaseController { case "ported_back": $number_data['disabled_reason'] = "ported_back"; break; + case "contract_cancelled": + $number_data['disabled_reason'] = "contract_cancelled"; + break; case "legacy": $number_data['disabled_reason'] = "legacy"; break; diff --git a/application/Voicenumber/VoicenumberModel.php b/application/Voicenumber/VoicenumberModel.php index 28da70fb8..33fc4d1f0 100644 --- a/application/Voicenumber/VoicenumberModel.php +++ b/application/Voicenumber/VoicenumberModel.php @@ -17,6 +17,7 @@ class VoicenumberModel { public $ported_out; public $disabled; public $disabled_reason; + public $disabled_by; public $enable_on_date; public $comment; diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php index c80214bfd..d7edbaded 100644 --- a/application/WarehouseArticle/WarehouseArticleController.php +++ b/application/WarehouseArticle/WarehouseArticleController.php @@ -13,7 +13,7 @@ class WarehouseArticleController extends TTCrud { ['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' => ['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' => 'vatgroup_id', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 2, 'text' => 'Dienstleistungen'], ['value' => 3, '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' => ' €']], ['key' => 'warningAmount', 'text' => 'Warnmenge', 'required' => true,'modal' => ['type' => 'number'], 'table' => false], @@ -111,7 +111,7 @@ class WarehouseArticleController extends TTCrud { if ($categoryId) { $category = WarehouseCategory::get($categoryId); if ($category && $category->articleNumberPrefix) { - $expectedPrefix = $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."); @@ -178,7 +178,7 @@ class WarehouseArticleController extends TTCrud { if (!$category) self::sendError("Kategorie nicht gefunden"); if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix"); - $prefix = $category->articleNumberPrefix; + $prefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT); $db = FronkDB::singleton(); // Get all existing article numbers with this prefix, sorted @@ -253,7 +253,7 @@ class WarehouseArticleController extends TTCrud { ]; $pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars); - $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + $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); @@ -262,4 +262,30 @@ class WarehouseArticleController extends TTCrud { 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/WarehouseArticle/WarehouseArticleModel.php b/application/WarehouseArticle/WarehouseArticleModel.php index 245e3bb64..a142c81fd 100644 --- a/application/WarehouseArticle/WarehouseArticleModel.php +++ b/application/WarehouseArticle/WarehouseArticleModel.php @@ -17,7 +17,7 @@ class WarehouseArticleModel extends TTCrudBaseModel { public ?int $isEndOfLife; public string $unit; public ?int $isSerialDocumentation; - public int $revenueAccount; + public int $vatgroup_id; public int $create; public int $createBy; } \ No newline at end of file diff --git a/application/WarehouseCategory/WarehouseCategory.php b/application/WarehouseCategory/WarehouseCategory.php index 129b19f18..46aeec39a 100644 --- a/application/WarehouseCategory/WarehouseCategory.php +++ b/application/WarehouseCategory/WarehouseCategory.php @@ -5,7 +5,7 @@ class WarehouseCategory extends TTCrudBaseModel { public string $description; public ?string $articleNumberPrefix; public int $create; - public int $create_by; + public ?int $create_by; public ?int $edit; public ?int $edit_by; -} \ No newline at end of file +} diff --git a/application/WarehouseCategory/WarehouseCategoryController.php b/application/WarehouseCategory/WarehouseCategoryController.php index 26d16ab83..40fe2a231 100644 --- a/application/WarehouseCategory/WarehouseCategoryController.php +++ b/application/WarehouseCategory/WarehouseCategoryController.php @@ -16,7 +16,39 @@ class WarehouseCategoryController extends TTCrud { ]; // @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(); diff --git a/application/WarehouseMovement/WarehouseMovementController.php b/application/WarehouseMovement/WarehouseMovementController.php new file mode 100644 index 000000000..f8e6711f2 --- /dev/null +++ b/application/WarehouseMovement/WarehouseMovementController.php @@ -0,0 +1,245 @@ + '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) { + $rawType = $row['movementType']; + + if (!empty($row['articleId'])) { + $article = WarehouseArticleModel::get($row['articleId']); + if ($article) { + $row['articleId'] = "{$article->articleNumber}
{$article->title}"; + } + } + + $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, ',', '.'); + + $allCategories = WarehouseMovementModel::getReasonCategories(); + $row['reasonCategory'] = $allCategories[$rawType][$row['reasonCategory']] ?? $row['reasonCategory']; + + 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..fa069a20e --- /dev/null +++ b/application/WarehouseMovement/WarehouseMovementModel.php @@ -0,0 +1,140 @@ +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' + ]; + } + + public function getArticle(): ?WarehouseArticleModel { + return WarehouseArticleModel::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 linked order if this movement was created from an order delivery + */ + public function getLinkedOrder(): ?WarehouseOrderModel { + if (!$this->linkedOrderId) return null; + return WarehouseOrderModel::get($this->linkedOrderId); + } + + /** + * 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/WarehouseOrder/WarehouseOrderController.php b/application/WarehouseOrder/WarehouseOrderController.php index 0e4d9d939..dc486fcfe 100644 --- a/application/WarehouseOrder/WarehouseOrderController.php +++ b/application/WarehouseOrder/WarehouseOrderController.php @@ -406,9 +406,72 @@ $appendToBody ]; try { + $createdMovementIds = []; + + // Create warehouse movements for delivery statuses + if (in_array($postData['status'], ['partiallyDelivered', 'fullyDelivered']) + && isset($postData['deliveryData']) && is_array($postData['deliveryData'])) { + + // Get location ID from request or use default (K1 Fladnitz 150) + $locationId = intval($postData['locationId'] ?? 0); + if ($locationId <= 0) { + // Default to K1 Fladnitz 150 + $allLocations = WarehouseLocationModel::getAll(); + $defaultLocation = null; + foreach ($allLocations as $loc) { + if ($loc->title === 'K1 Fladnitz 150') { + $defaultLocation = $loc; + break; + } + } + $locationId = $defaultLocation ? $defaultLocation->id : 1; + } + + // Prepare delivery data with articleId from order positions + $positions = json_decode($order->positions, true) ?: []; + $deliveryDataWithArticleIds = []; + + foreach ($postData['deliveryData'] as $index => $delivery) { + if (isset($positions[$index])) { + $delivery['articleId'] = $positions[$index]['article']; + } + $deliveryDataWithArticleIds[] = $delivery; + } + + $createdMovementIds = $this->createMovementsForDelivery( + intval($postData['orderId']), + $deliveryDataWithArticleIds, + $locationId + ); + + if (!empty($createdMovementIds)) { + // Update order with linked movement IDs + $existingMovementIds = $order->linkedMovementIds + ? json_decode($order->linkedMovementIds, true) : []; + $allMovementIds = array_merge($existingMovementIds, $createdMovementIds); + $orderAsArray['linkedMovementIds'] = json_encode($allMovementIds); + + // Add movement info to log message + $fullLogMessage .= ($fullLogMessage ? "\n\n" : "") . + count($createdMovementIds) . " Lagerbewegung(en) erstellt."; + $log['message'] = trim($fullLogMessage); + } + } + + // Store delivery note file IDs + if (!empty($postData['deliveryNoteFileIds'])) { + $existingFileIds = $order->deliveryNoteFileIds + ? json_decode($order->deliveryNoteFileIds, true) : []; + $allFileIds = array_merge($existingFileIds, $postData['deliveryNoteFileIds']); + $orderAsArray['deliveryNoteFileIds'] = json_encode($allFileIds); + } + if ($postData['status'] !== 'noChanges') { $orderAsArray['status'] = $postData['status']; WarehouseOrderModel::update($orderAsArray); + } elseif (!empty($orderAsArray['linkedMovementIds']) || !empty($orderAsArray['deliveryNoteFileIds'])) { + // Update even if status didn't change but we added linked data + WarehouseOrderModel::update($orderAsArray); } // Only create a log entry if there's actually something to log @@ -416,7 +479,11 @@ $appendToBody WarehouseLogModel::create($log); } - self::returnJson(['success' => true, 'message' => 'Log entry created']); + self::returnJson([ + 'success' => true, + 'message' => 'Log entry created', + 'createdMovementIds' => $createdMovementIds + ]); } catch (Exception $e) { self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]); } @@ -485,6 +552,107 @@ $appendToBody } } + protected function createMovementsForDelivery(int $orderId, array $deliveryData, int $locationId): array { + $order = WarehouseOrderModel::get($orderId); + $createdMovementIds = []; + foreach ($deliveryData as $delivery) { + $deliveredAmount = floatval($delivery['amount']); + $articleId = intval($delivery['articleId']); + + // Only create movements for items actually delivered + if ($deliveredAmount <= 0 || $articleId <= 0) { + continue; + } + + // Find or create WarehouseItem for article + location + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId + ]); + $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null; + + if (!$warehouseItem) { + // Create new warehouse item with zero quantity + $warehouseItemId = WarehouseItemModel::create([ + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId, + 'quantity' => 0 + ]); + $warehouseItem = WarehouseItemModel::get($warehouseItemId); + } + + $quantityBefore = $warehouseItem->quantity; + $quantityAfter = $quantityBefore + $deliveredAmount; + + // Create warehouse movement + $movementData = [ + 'movementNumber' => WarehouseMovementModel::generateMovementNumber(), + 'movementType' => 'IN', + 'articleId' => $articleId, + 'warehouseLocationId' => $locationId, + 'warehouseItemId' => $warehouseItem->id, + 'quantity' => $deliveredAmount, + 'quantityBefore' => $quantityBefore, + 'quantityAfter' => $quantityAfter, + 'reasonCategory' => 'Warenlieferung', + 'linkedOrderId' => $orderId, + 'note' => "Lagereingang aus Bestellung {$order->orderNumber}", + 'userId' => $this->user->id, + 'createBy' => $this->user->id, + 'create' => time() + ]; + + $movementId = WarehouseMovementModel::create($movementData); + $createdMovementIds[] = $movementId; + + // Update warehouse item quantity + $warehouseItem->quantity = $quantityAfter; + WarehouseItemModel::update((array)$warehouseItem); + } + + return $createdMovementIds; + } + + protected function getLinkedMovementsAction() { + $orderId = $this->request->orderId; + if (empty($orderId)) { + self::returnJson(['error' => 'Order ID is required']); + return; + } + + $order = WarehouseOrderModel::get($orderId); + $linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : []; + + $movements = []; + foreach ($linkedMovementIds as $movementId) { + $movement = WarehouseMovementModel::get($movementId); + if ($movement) { + $article = $movement->getArticle(); + $location = $movement->getLocation(); + $movements[] = [ + 'id' => $movement->id, + 'movementNumber' => $movement->movementNumber, + 'quantity' => $movement->quantity, + 'articleName' => $article ? $article->title : 'Unbekannt', + 'locationName' => $location ? $location->title : 'Unbekannt', + 'create' => $movement->create + ]; + } + } + + self::returnJson($movements); + } + + protected function getLocationsAction() { + $locations = WarehouseLocationModel::getAll(); + $result = array_map(function($loc) { + return [ + 'value' => $loc->id, + 'text' => $loc->title + ]; + }, $locations); + self::returnJson($result); + } } \ No newline at end of file diff --git a/application/WarehouseOrder/WarehouseOrderModel.php b/application/WarehouseOrder/WarehouseOrderModel.php index 4e14082c7..237f55ca2 100644 --- a/application/WarehouseOrder/WarehouseOrderModel.php +++ b/application/WarehouseOrder/WarehouseOrderModel.php @@ -32,6 +32,8 @@ class WarehouseOrderModel extends TTCrudBaseModel { public string $delAddrPLZ; public int $editor; public ?string $note; + public ?string $linkedMovementIds = null; + public ?string $deliveryNoteFileIds = null; public string $positions; public ?int $sendShippingNote; public int $create; diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteController.php b/application/WarehouseShippingNote/WarehouseShippingNoteController.php index 02b07ee85..fbf175801 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteController.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteController.php @@ -6,7 +6,7 @@ class WarehouseShippingNoteController extends TTCrud { //@formatter:off protected array $columns = [ - ['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']], + ['key' => 'shippingNoteNumber', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap', 'filter' => 'search']], ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true], ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], ['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [ @@ -56,6 +56,7 @@ class WarehouseShippingNoteController extends TTCrud { ]); $this->postData['positions'] = json_encode($this->postData['positions']); if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']); + $this->postData['shippingNoteNumber'] = WarehouseShippingNoteModel::generateShippingNoteNumber(); return true; } @@ -130,7 +131,10 @@ class WarehouseShippingNoteController extends TTCrud { // Get billing address info $billingAddress = null; if ($shippingNote->billingAddressId) { - $billingAddress = Address::getOne($shippingNote->billingAddressId); + $billingAddress = new Address($shippingNote->billingAddressId); + if (!$billingAddress->id) { + $billingAddress = null; + } } // Determine price type ONCE (not in loop for performance) @@ -486,10 +490,6 @@ class WarehouseShippingNoteController extends TTCrud { "bank_bank" => TT_INVOICE_BANK_BANK, "bank_owner" => TT_INVOICE_BANK_OWNER]; - // Replace placeholders in header - // create shipping note in this format LS2024-X0001 - // pad number on the left side with zeros - $shippingNoteNumber = "LS" . date("Y", $shippingNote->create) . "-" . str_pad($shippingNote->id, 4, "0", STR_PAD_LEFT); $headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html"); $headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml); $headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $headerHtml); @@ -504,7 +504,7 @@ class WarehouseShippingNoteController extends TTCrud { $headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml); $headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml); - $headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNoteNumber, $headerHtml); + $headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNote->shippingNoteNumber, $headerHtml); $headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml); $headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html"; diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php index 8cc4e5721..5354951b0 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php @@ -2,6 +2,7 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel { public int $id; + public ?string $shippingNoteNumber = null; public ?int $billingAddressId; public ?string $type; public ?string $metadata; @@ -21,4 +22,23 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel { public ?int $eShopOrderId; public ?int $create; public ?int $createBy; + + public static function generateShippingNoteNumber(): string { + $year = date('Y'); + $prefix = "LS{$year}-X"; + + $db = FronkDB::singleton(); + $result = $db->query("SELECT shippingNoteNumber FROM WarehouseShippingNote + WHERE shippingNoteNumber LIKE '{$prefix}%' + ORDER BY shippingNoteNumber DESC LIMIT 1"); + + if ($row = $result->fetch_assoc()) { + $lastNumber = intval(substr($row['shippingNoteNumber'], -4)); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad((string)$nextNumber, 4, '0', STR_PAD_LEFT); + } } \ No newline at end of file diff --git a/application/Workorder/WorkorderModel.php b/application/Workorder/WorkorderModel.php index b5762a147..680e51244 100644 --- a/application/Workorder/WorkorderModel.php +++ b/application/Workorder/WorkorderModel.php @@ -16,6 +16,7 @@ class WorkorderModel extends TTCrudBaseModel public ?string $additionalInfo; public ?string $cableLength; public ?string $cableType; + public ?string $metadata; public int $create; public int $createBy; @@ -199,4 +200,62 @@ class WorkorderModel extends TTCrudBaseModel $result = $db->query($sql); return $result ? $result->fetch_assoc()['count'] : 0; } + + public static function getTechnicalData(int $workorderId): ?array { + $workorder = self::get($workorderId); + if (!$workorder || !$workorder->preorderId) return null; + + $preorder = new Preorder($workorder->preorderId); + if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null; + + $wohneinheit = $preorder->adb_wohneinheit; + if (!$wohneinheit) return null; + + $defaultCluster = ''; + if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) { + $defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? ''; + } + + $patchposition = [ + 'equipmentName' => $wohneinheit->getPatchEqString(), + 'equipmentPort' => $wohneinheit->patch_port, + 'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster, + 'shelf' => $wohneinheit->patch_shelf, + 'module' => $wohneinheit->patch_module, + ]; + + // Get dropcable data from metadata + $dropkabelData = []; + $ahaParsed = null; + $mapFile = null; + if (!empty($workorder->metadata)) { + $metadata = json_decode($workorder->metadata, true); + if (!empty($metadata['dropcable'])) { + $ahaParsed = $metadata['dropcable']['parsed_at'] ?? null; + $dropkabelData = $metadata['dropcable']['entries'] ?? []; + if ($mapFileId = $metadata['dropcable']['map_file_id'] ?? null) { + $file = new File($mapFileId); + if ($file->id) { + $mapFile = ['id' => $file->id, 'name' => $file->name, 'download_url' => '/File/show?id=' . $file->id]; + } + } + } + } + + $rimoWorkorders = []; + if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) { + foreach ($wohneinheit->rimo_workorders as $wo) { + $rimoWorkorders[] = [ + 'id' => $wo->id, 'rimoName' => $wo->rimo_name, 'rimoId' => $wo->rimo_id, + 'rimoStatus' => $wo->rimo_status, 'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id, + ]; + } + } + + return [ + 'patchposition' => $patchposition, + 'rimoWorkorders' => $rimoWorkorders, + 'dropcable' => ['parsed_at' => $ahaParsed, 'entries' => $dropkabelData, 'map_file' => $mapFile], + ]; + } } \ No newline at end of file diff --git a/application/WorkorderAdmin/WorkorderAdminController.php b/application/WorkorderAdmin/WorkorderAdminController.php index c0771036f..40259cb0b 100644 --- a/application/WorkorderAdmin/WorkorderAdminController.php +++ b/application/WorkorderAdmin/WorkorderAdminController.php @@ -54,6 +54,7 @@ class WorkorderAdminController extends WorkorderBaseController public function indexAction() { $this->createWorkordersFromPreorders(); + $this->autoCompleteDocumentedWorkorders(); $this->archiveWorkorders(); parent::indexAction(); } diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php index fcfce3a08..8fac50f33 100644 --- a/application/WorkorderBase/WorkorderBaseController.php +++ b/application/WorkorderBase/WorkorderBaseController.php @@ -60,6 +60,10 @@ class WorkorderBaseController extends TTCrud $translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap); } + // Auto-parse AHA if enabled and not yet parsed + if ($tenantConfig?->showTechnicalData) { + RimoWorkorder::autoParseForWorkorder((int)$this->request->workorderId); + } $responseDocs = []; $typeCounts = []; @@ -141,6 +145,13 @@ class WorkorderBaseController extends TTCrud return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]) ?? null; } + /** + * Retrieves technical data (patchposition and AHA Blatt info) for a workorder. + */ + protected function getTechnicalData(int $workorderId): ?array { + return WorkorderModel::getTechnicalData($workorderId); + } + //region BACKGROUND TASKS /** * Creates new workorders from preorders based on tenant configurations. @@ -272,5 +283,50 @@ class WorkorderBaseController extends TTCrud } file_put_contents($lockFile, time()); } + + protected function autoCompleteDocumentedWorkorders() + { + $lockFile = TEMP_DIR . "/task_auto_complete_workorders.lock"; + if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) return; + + foreach (WorkorderTenantConfigModel::getAll() as $config) { + $filter = json_decode($config->autoCompleteFilter ?? '', true); + if (empty($filter)) continue; + + $networks = NetworkModel::search(['owner_id' => $config->addressId]); + if (empty($networks)) continue; + + $networkIds = array_map(fn($n) => $n->id, $networks); + $campaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds])); + if (empty($campaignIds)) continue; + + $filter['preordercampaign_id'] = $campaignIds; + $matchingPreorders = PreorderModel::searchActive($filter); + if (empty($matchingPreorders)) continue; + + $preorderIds = array_map(fn($p) => $p->id, $matchingPreorders); + $preorderIdSet = array_flip($preorderIds); + + $workorders = WorkorderModel::getAll([ + 'status' => ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved', 'documented', 'archived'], + 'preorderId' => $preorderIds + ]); + + foreach ($workorders as $workorder) { + if (!isset($preorderIdSet[$workorder->preorderId])) continue; + $oldStatus = $workorder->status; + $workorder->status = 'completed'; + WorkorderModel::update((array)$workorder); + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Dokumentation automatisch akzeptiert (Auto-Complete Filter).', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'), + 'create' => time(), + 'createBy' => 1, + ]); + } + } + file_put_contents($lockFile, time()); + } //endregion } diff --git a/application/WorkorderCompany/WorkorderCompanyController.php b/application/WorkorderCompany/WorkorderCompanyController.php index 6125b6e81..fd6f93d53 100644 --- a/application/WorkorderCompany/WorkorderCompanyController.php +++ b/application/WorkorderCompany/WorkorderCompanyController.php @@ -121,6 +121,23 @@ class WorkorderCompanyController extends WorkorderBaseController { self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']); } + protected function clearAppointmentAction() { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A'; + $workorder->appointmentDate = null; + $workorder->status = 'assigned'; + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, 'text' => "Termin gelöscht (war: {$oldDateFormatted}).", + 'create' => time(), 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gelöscht.']); + } + protected function requestInterventionAction() { if (empty($this->postData['workorderId']) || empty($this->postData['journalText'])) self::sendError("Erforderliche Felder fehlen."); $workorder = WorkorderModel::get($this->postData['workorderId']); @@ -167,14 +184,23 @@ class WorkorderCompanyController extends WorkorderBaseController { self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']); return; } - self::returnJson([ + + $response = [ 'success' => true, 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true), 'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired, 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true), 'requireCableLength' => $tenantConfig->requireCableLength, - 'requireCableType' => $tenantConfig->requireCableType - ]); + 'requireCableType' => $tenantConfig->requireCableType, + 'showTechnicalData' => (bool)$tenantConfig->showTechnicalData, + 'tiefbauSeesNormalDocs' => (bool)$tenantConfig->tiefbauSeesNormalDocs, + ]; + + if ($tenantConfig->showTechnicalData) { + $response['technicalData'] = $this->getTechnicalData((int)$this->request->workorderId); + } + + self::returnJson($response); } protected function uploadDocumentationAction() { diff --git a/application/WorkorderDashboard/WorkorderDashboardController.php b/application/WorkorderDashboard/WorkorderDashboardController.php new file mode 100644 index 000000000..204609ee8 --- /dev/null +++ b/application/WorkorderDashboard/WorkorderDashboardController.php @@ -0,0 +1,304 @@ +", + "", + "", + "" + ]; + + protected array $statusLabels = [ + 'new' => 'Neu', 'assigned' => 'Zugewiesen', 'scheduled' => 'Geplant', 'in_progress' => 'In Bearbeitung', + 'correction_requested' => 'Korrektur angefordert', 'intervention_required' => 'Eingriff erforderlich', + 'civil_engineering_required' => 'Tiefbau benötigt', 'civil_engineering_completed' => 'Tiefbau abgeschlossen', + 'problem_solved' => 'Problem gelöst', 'documented' => 'Dokumentiert', 'completed' => 'Abgeschlossen', + 'charged' => 'Verrechnet', 'cancelled' => 'Abgebrochen', 'archived' => 'Archiviert', + ]; + + protected array $statusColors = [ + 'new' => '#3b82f6', 'assigned' => '#06b6d4', 'scheduled' => '#8b5cf6', 'in_progress' => '#f59e0b', + 'correction_requested' => '#ef4444', 'intervention_required' => '#dc2626', 'civil_engineering_required' => '#ea580c', + 'civil_engineering_completed' => '#65a30d', 'problem_solved' => '#22c55e', 'documented' => '#14b8a6', + 'completed' => '#10b981', 'charged' => '#8b5cf6', 'cancelled' => '#6b7280', 'archived' => '#9ca3af', + ]; + + protected function indexAction() + { + $this->layout()->set('additionalHead', $this->additionalHead); + Helper::renderVue($this, 'WorkorderDashboard', $this->headerTitle, []); + } + + protected function getFilterOptionsAction() + { + if ($this->user->isAdmin()) { + $tenants = WorkorderTenantConfigModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']); + } else { + $tenants = WorkorderTenantConfigModel::getAll(['addressId' => $this->user->address_id], null, 0, ['key' => 'name', 'order' => 'ASC']); + } + $companies = WorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']); + + self::returnJson([ + 'tenants' => array_map(fn($t) => ['value' => $t->id, 'text' => $t->name], $tenants), + 'companies' => array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies), + 'statuses' => array_map(fn($key, $label) => ['value' => $key, 'text' => $label], array_keys($this->statusLabels), $this->statusLabels), + 'campaigns' => [] + ]); + } + + protected function getCampaignsForTenantAction() + { + $tenantId = $this->postData['tenantId'] ?? null; + if (!$tenantId || !($config = WorkorderTenantConfigModel::get($tenantId))) { + self::returnJson([]); + return; + } + if (!$this->user->isAdmin() && $config->addressId != $this->user->address_id) { + self::returnJson([]); + return; + } + + $networks = NetworkModel::search(['owner_id' => $config->addressId]); + if (empty($networks)) { + self::returnJson([]); + return; + } + + $campaigns = PreordercampaignModel::search(['network_id' => array_map(fn($n) => $n->id, $networks)]); + $options = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $campaigns); + usort($options, fn($a, $b) => strcmp($a['text'], $b['text'])); + self::returnJson($options); + } + + protected function getDashboardDataAction() + { + $tenantId = $this->postData['tenantId'] ?? null; + $dateFrom = $this->postData['dateFrom'] ?? null; + $dateTo = $this->postData['dateTo'] ?? null; + $companyIds = $this->postData['companyIds'] ?? []; + $statuses = $this->postData['statuses'] ?? []; + $campaignIds = $this->postData['campaignIds'] ?? []; + + if (!$tenantId) self::sendError('Mandant muss ausgewählt werden.'); + $config = WorkorderTenantConfigModel::get($tenantId); + if (!$config) self::sendError('Mandant nicht gefunden.'); + if (!$this->user->isAdmin() && $config->addressId != $this->user->address_id) self::sendError('Keine Berechtigung für diesen Mandanten.'); + + $networks = NetworkModel::search(['owner_id' => $config->addressId]); + $tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => array_map(fn($n) => $n->id, $networks)])); + + if (empty($tenantCampaignIds)) { + self::returnJson($this->getEmptyDashboardData()); + return; + } + + if (!empty($campaignIds)) $tenantCampaignIds = array_intersect($tenantCampaignIds, $campaignIds); + + $db = FronkDB::singleton(); + $whereConditions = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")"]; + if ($dateFrom) $whereConditions[] = "w.`create` >= " . intval($dateFrom); + if ($dateTo) $whereConditions[] = "w.`create` <= " . intval($dateTo); + if (!empty($companyIds)) $whereConditions[] = "w.companyId IN (" . implode(',', array_map('intval', $companyIds)) . ")"; + if (!empty($statuses)) $whereConditions[] = "w.status IN (" . implode(',', array_map(fn($s) => "'" . $db->escape($s) . "'", $statuses)) . ")"; + $whereClause = implode(' AND ', $whereConditions); + + self::returnJson([ + 'kpis' => $this->getKPIs($db, $whereClause, $tenantCampaignIds, $dateFrom, $dateTo), + 'statusDistribution' => $this->getStatusDistribution($db, $whereClause), + 'companyPerformance' => $this->getCompanyPerformance($db, $whereClause), + 'timeTrends' => $this->getTimeTrends($db, $tenantCampaignIds, $dateFrom, $dateTo, $companyIds), + 'companyStatusCampaign' => $this->getCompanyStatusCampaign($db, $whereClause), + 'interventionRates' => $this->getInterventionRates($db, $whereClause), + 'statusTransitions' => $this->getStatusTransitions($db, $tenantCampaignIds, $dateFrom, $dateTo), + ]); + } + + private function getKPIs($db, $whereClause, $tenantCampaignIds, $dateFrom, $dateTo): array + { + $total = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause")->fetch_assoc()['c'] ?? 0; + $offen = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status NOT IN ('completed', 'charged', 'archived')")->fetch_assoc()['c'] ?? 0; + $terminisiert = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status NOT IN ('completed', 'charged', 'archived') AND w.appointmentDate IS NOT NULL")->fetch_assoc()['c'] ?? 0; + $issues = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status IN ('intervention_required', 'correction_requested')")->fetch_assoc()['c'] ?? 0; + + return [ + 'total' => (int)$total, + 'offen' => (int)$offen, + 'terminisiert' => (int)$terminisiert, + 'issues' => (int)$issues, + 'interventionRate' => $total > 0 ? round(($issues / $total) * 100, 1) : 0, + 'avgCompletionDays' => $this->calculateAvgCompletionTime($db, $tenantCampaignIds), + ]; + } + + private function calculateAvgCompletionTime($db, $tenantCampaignIds): ?float + { + $sql = "SELECT w.id, + MIN(CASE WHEN wj.statusChange LIKE '%Zugewiesen%' OR wj.statusChange LIKE '%-> Zugewiesen' THEN wj.`create` END) as assigned_time, + MIN(CASE WHEN wj.statusChange LIKE '%-> Abgeschlossen%' THEN wj.`create` END) as completed_time + FROM thetool.Workorder w + JOIN thetool.Preorder p ON w.preorderId = p.id + JOIN thetool.WorkorderJournal wj ON w.id = wj.workorderId + WHERE p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ") AND w.status IN ('completed', 'charged') + GROUP BY w.id HAVING assigned_time IS NOT NULL AND completed_time IS NOT NULL"; + + $result = $db->query($sql); + $totalDays = $count = 0; + while ($row = $result->fetch_assoc()) { + if ($row['completed_time'] > $row['assigned_time']) { + $totalDays += ($row['completed_time'] - $row['assigned_time']) / 86400; + $count++; + } + } + return $count > 0 ? round($totalDays / $count, 1) : null; + } + + private function getStatusDistribution($db, $whereClause): array + { + $result = $db->query("SELECT w.status, COUNT(*) as count FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause GROUP BY w.status ORDER BY count DESC"); + $distribution = []; + while ($row = $result->fetch_assoc()) { + $distribution[] = [ + 'status' => $row['status'], + 'label' => $this->statusLabels[$row['status']] ?? $row['status'], + 'count' => (int)$row['count'], + 'color' => $this->statusColors[$row['status']] ?? '#6b7280', + ]; + } + return $distribution; + } + + private function getCompanyPerformance($db, $whereClause): array + { + $sql = "SELECT wc.name as company, wc.id as companyId, + SUM(CASE WHEN w.status IN ('completed', 'charged') THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN w.status IN ('new', 'assigned', 'scheduled', 'in_progress', 'documented') THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN w.status IN ('intervention_required', 'correction_requested') THEN 1 ELSE 0 END) as issues, + COUNT(*) as total + FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id + LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id + WHERE $whereClause AND w.companyId IS NOT NULL GROUP BY wc.id, wc.name ORDER BY total DESC"; + + $result = $db->query($sql); + $performance = []; + while ($row = $result->fetch_assoc()) { + $performance[] = [ + 'company' => $row['company'] ?? 'Nicht zugewiesen', + 'companyId' => (int)$row['companyId'], + 'completed' => (int)$row['completed'], + 'pending' => (int)$row['pending'], + 'issues' => (int)$row['issues'], + 'total' => (int)$row['total'], + ]; + } + return $performance; + } + + private function getTimeTrends($db, $tenantCampaignIds, $dateFrom, $dateTo, $companyIds): array + { + $where = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")"]; + if ($dateFrom) $where[] = "w.`create` >= " . intval($dateFrom); + if ($dateTo) $where[] = "w.`create` <= " . intval($dateTo); + if (!empty($companyIds)) $where[] = "w.companyId IN (" . implode(',', array_map('intval', $companyIds)) . ")"; + + $sql = "SELECT DATE(FROM_UNIXTIME(w.`create`)) as date, COUNT(*) as created, + SUM(CASE WHEN w.status IN ('completed', 'charged') THEN 1 ELSE 0 END) as completed + FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id + WHERE " . implode(' AND ', $where) . " GROUP BY DATE(FROM_UNIXTIME(w.`create`)) ORDER BY date ASC"; + + $result = $db->query($sql); + $trends = []; + while ($row = $result->fetch_assoc()) { + $trends[] = ['date' => $row['date'], 'created' => (int)$row['created'], 'completed' => (int)$row['completed']]; + } + return $trends; + } + + private function getCompanyStatusCampaign($db, $whereClause): array + { + $sql = "SELECT wc.name as company, w.status, pc.name as campaign, COUNT(*) as count + FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id + JOIN thetool.Preordercampaign pc ON p.preordercampaign_id = pc.id + LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id + WHERE $whereClause AND w.companyId IS NOT NULL + GROUP BY wc.name, w.status, pc.name ORDER BY wc.name, count DESC"; + + $result = $db->query($sql); + $data = []; + while ($row = $result->fetch_assoc()) { + $key = ($row['company'] ?? 'Nicht zugewiesen') . '|' . $row['status']; + if (!isset($data[$key])) { + $data[$key] = [ + 'company' => $row['company'] ?? 'Nicht zugewiesen', + 'status' => $row['status'], + 'statusLabel' => $this->statusLabels[$row['status']] ?? $row['status'], + 'count' => 0, + 'campaigns' => [], + ]; + } + $data[$key]['count'] += (int)$row['count']; + $data[$key]['campaigns'][] = ['name' => $row['campaign'], 'count' => (int)$row['count']]; + } + + $result = array_values($data); + usort($result, fn($a, $b) => $b['count'] - $a['count']); + return $result; + } + + private function getInterventionRates($db, $whereClause): array + { + $sql = "SELECT wc.name as company, COUNT(*) as total, + SUM(CASE WHEN w.status = 'intervention_required' THEN 1 ELSE 0 END) as interventions, + SUM(CASE WHEN w.status = 'correction_requested' THEN 1 ELSE 0 END) as corrections + FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id + LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id + WHERE $whereClause AND w.companyId IS NOT NULL GROUP BY wc.name + HAVING COUNT(*) >= 5 ORDER BY (SUM(CASE WHEN w.status IN ('intervention_required', 'correction_requested') THEN 1 ELSE 0 END) / COUNT(*)) DESC"; + + $result = $db->query($sql); + $rates = []; + while ($row = $result->fetch_assoc()) { + $total = (int)$row['total']; + $issueCount = (int)$row['interventions'] + (int)$row['corrections']; + $rates[] = [ + 'company' => $row['company'] ?? 'Nicht zugewiesen', + 'total' => $total, + 'interventions' => (int)$row['interventions'], + 'corrections' => (int)$row['corrections'], + 'rate' => $total > 0 ? round(($issueCount / $total) * 100, 1) : 0, + ]; + } + return $rates; + } + + private function getStatusTransitions($db, $tenantCampaignIds, $dateFrom, $dateTo): array + { + $where = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")", "wj.statusChange IS NOT NULL"]; + if ($dateFrom) $where[] = "wj.`create` >= " . intval($dateFrom); + if ($dateTo) $where[] = "wj.`create` <= " . intval($dateTo); + + $sql = "SELECT wj.statusChange, COUNT(*) as count FROM thetool.WorkorderJournal wj + JOIN thetool.Workorder w ON wj.workorderId = w.id JOIN thetool.Preorder p ON w.preorderId = p.id + WHERE " . implode(' AND ', $where) . " GROUP BY wj.statusChange ORDER BY count DESC LIMIT 15"; + + $result = $db->query($sql); + $transitions = []; + while ($row = $result->fetch_assoc()) { + $transitions[] = ['transition' => $row['statusChange'], 'count' => (int)$row['count']]; + } + return $transitions; + } + + private function getEmptyDashboardData(): array + { + return [ + 'kpis' => ['total' => 0, 'offen' => 0, 'terminisiert' => 0, 'issues' => 0, 'interventionRate' => 0, 'avgCompletionDays' => null], + 'statusDistribution' => [], 'companyPerformance' => [], 'timeTrends' => [], + 'companyStatusCampaign' => [], 'interventionRates' => [], 'statusTransitions' => [], + ]; + } +} diff --git a/application/WorkorderMphAdmin/WorkorderMphAdminController.php b/application/WorkorderMphAdmin/WorkorderMphAdminController.php index 416d01d6d..efe5aec14 100644 --- a/application/WorkorderMphAdmin/WorkorderMphAdminController.php +++ b/application/WorkorderMphAdmin/WorkorderMphAdminController.php @@ -375,4 +375,38 @@ class WorkorderMphAdminController extends WorkorderMphBaseController self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']); } + + protected function unassignWorkorderAction() + { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + if ($workorder->status === 'new') self::sendError("Arbeitsauftrag ist nicht zugewiesen."); + if (in_array($workorder->status, ['completed', 'cancelled'])) self::sendError("Arbeitsauftrag kann nicht mehr geändert werden."); + + $oldStatus = $workorder->status; + $oldCompany = $workorder->companyId ? WorkorderCompanyModel::get($workorder->companyId) : null; + $oldCompanyName = $oldCompany ? $oldCompany->name : 'Unbekannt'; + + $workorder->status = 'new'; + $workorder->companyId = null; + $workorder->assignmentDate = null; + $workorder->deadlineDate = null; + $workorder->appointmentDate = null; + WorkorderMphModel::update((array)$workorder); + + $reason = !empty($this->postData['reason']) ? " Grund: " . $this->postData['reason'] : ''; + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Zuweisung aufgehoben (vorher: $oldCompanyName).$reason", + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Zuweisung wurde aufgehoben.']); + } } diff --git a/application/WorkorderTenantConfig/WorkorderTenantConfigController.php b/application/WorkorderTenantConfig/WorkorderTenantConfigController.php index 8a7dc0099..064fd2787 100644 --- a/application/WorkorderTenantConfig/WorkorderTenantConfigController.php +++ b/application/WorkorderTenantConfig/WorkorderTenantConfigController.php @@ -20,6 +20,7 @@ class WorkorderTenantConfigController extends TTCrud { $data['interventionTypes'] = json_encode($data['interventionTypes'] ?? []); $data['workorderCreationFilters'] ??= '{}'; $data['workorderActiveFilters'] ??= '{}'; + $data['autoCompleteFilter'] ??= null; if (empty($data['id'])) { $data['create'] = time(); diff --git a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php index bc917678b..723830a83 100644 --- a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php +++ b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php @@ -8,10 +8,13 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel { public string $documentationTypes; // JSON public string $workorderCreationFilters; // JSON public ?string $workorderActiveFilters; // JSON + public ?string $autoCompleteFilter; // JSON public ?string $interventionTypes; // JSON public int $civilEngineeringDocsRequired; public int $requireCableLength; public int $requireCableType; + public int $showTechnicalData = 0; + public int $tiefbauSeesNormalDocs = 0; public int $enableWorkorder; public int $enableWorkorderMph; public int $create; diff --git a/composer.json b/composer.json index 151670a4a..f4603b66e 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "stomp-php/stomp-php": "^5", "phpmailer/phpmailer": "^6.9", "pear2/net_routeros": "dev-develop@dev", - "matthiasmullie/minify": "^1.3" + "matthiasmullie/minify": "^1.3", + "smalot/pdfparser": "^2.0" } } 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/db/migrations/20260115121818_preorder_ctag_add_ext_name.php b/db/migrations/20260115121818_preorder_ctag_add_ext_name.php new file mode 100644 index 000000000..d225a7036 --- /dev/null +++ b/db/migrations/20260115121818_preorder_ctag_add_ext_name.php @@ -0,0 +1,35 @@ +getEnvironment() == "thetool") { + $table = $this->table("PreorderCtag"); + $table->renameColumn("ext_id", "ext_name"); + $table->addColumn("ext_id", "string", ["null" => true, "default" => null, "length" => 255, "after" => "service_type"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $table = $this->table("PreorderCtag"); + $table->removeColumn("ext_id"); + $table->renameColumn("ext_name", "ext_id"); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20260115165426_voicenumber_add_disable_reason_canceled.php b/db/migrations/20260115165426_voicenumber_add_disable_reason_canceled.php new file mode 100644 index 000000000..f6e7381d7 --- /dev/null +++ b/db/migrations/20260115165426_voicenumber_add_disable_reason_canceled.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + $table = $this->table("Voicenumber"); + $table->changeColumn("disabled", "integer", ["null" => false, "default" => 0]); + $table->changeColumn("disabled_reason", "enum", ["values" => "ported_out,ported_back,reserved,legacy,damaged,contract_cancelled", "null" => true, "default" => null]); + $table->addColumn("disabled_by", "integer", ["null" => true, "default" => null, "after" => "disabled_reason"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20260115184942_address_add_manual_invoice_sepa_limit.php b/db/migrations/20260115184942_address_add_manual_invoice_sepa_limit.php new file mode 100644 index 000000000..6ef69f1ae --- /dev/null +++ b/db/migrations/20260115184942_address_add_manual_invoice_sepa_limit.php @@ -0,0 +1,31 @@ +getEnvironment() == "thetool") { + $table = $this->table('Address'); + $table->addColumn("manual_invoice_sepa_limit", "decimal", ["null" => true, "default" => 500, "precision" => 9, "scale" => 2, "after" => "fibu_payment_skonto_rate"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table('Address')->removeColumn("manual_invoice_sepa_limit")->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20260116152525_order_product_add_preorder_data.php b/db/migrations/20260116152525_order_product_add_preorder_data.php new file mode 100644 index 000000000..6afa39742 --- /dev/null +++ b/db/migrations/20260116152525_order_product_add_preorder_data.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + $table = $this->table("OrderProduct"); + $table->addColumn("oaid", "string", ["null" => true, "default" => null, "limit" => 255, "after" => "termination_id"]); + $table->addColumn("preorder_id", "integer", ["null" => true, "default" => null, "after" => "oaid"]); + $table->addColumn("snopp_order_id", "integer", ["null" => true, "default" => null, "after" => "preorder_id"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20260117120000_add_shipping_note_number.php b/db/migrations/20260117120000_add_shipping_note_number.php new file mode 100644 index 000000000..ba76c9224 --- /dev/null +++ b/db/migrations/20260117120000_add_shipping_note_number.php @@ -0,0 +1,50 @@ +getEnvironment() == "thetool") { + $table = $this->table('WarehouseShippingNote'); + $table + ->addColumn('shippingNoteNumber', 'string', ['limit' => 20, 'null' => true, 'after' => 'id']) + ->addIndex(['shippingNoteNumber'], ['unique' => true]) + ->update(); + + // Get all shipping notes ordered by create timestamp to assign numbers in chronological order + $rows = $this->fetchAll( + "SELECT id, YEAR(FROM_UNIXTIME(`create`)) as year + FROM WarehouseShippingNote + ORDER BY `create` ASC, id ASC" + ); + + // Group by year and assign sequential numbers + $yearCounters = []; + foreach ($rows as $row) { + $year = $row['year']; + if (!isset($yearCounters[$year])) { + $yearCounters[$year] = 0; + } + $yearCounters[$year]++; + + $shippingNoteNumber = 'LS' . $year . '-X' . str_pad((string)$yearCounters[$year], 4, '0', STR_PAD_LEFT); + + $this->execute( + "UPDATE WarehouseShippingNote SET shippingNoteNumber = '{$shippingNoteNumber}' WHERE id = {$row['id']}" + ); + } + + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WarehouseShippingNote'); + $table->removeColumn('shippingNoteNumber')->update(); + } + } +} diff --git a/db/migrations/20260117150000_add_order_movement_linking.php b/db/migrations/20260117150000_add_order_movement_linking.php new file mode 100644 index 000000000..6a6d9a31b --- /dev/null +++ b/db/migrations/20260117150000_add_order_movement_linking.php @@ -0,0 +1,43 @@ +getEnvironment() == "thetool") { + // Add columns to WarehouseOrder for linking to movements and delivery note files + $orderTable = $this->table('WarehouseOrder'); + $orderTable + ->addColumn('linkedMovementIds', 'text', ['null' => true, 'after' => 'note']) + ->addColumn('deliveryNoteFileIds', 'text', ['null' => true, 'after' => 'linkedMovementIds']) + ->update(); + + // Add column to WarehouseMovement for linking back to orders + $movementTable = $this->table('WarehouseMovement'); + $movementTable + ->addColumn('linkedOrderId', 'integer', ['null' => true, 'signed' => false, 'after' => 'note']) + ->addIndex(['linkedOrderId'], ['name' => 'idx_linkedOrderId']) + ->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $orderTable = $this->table('WarehouseOrder'); + $orderTable + ->removeColumn('linkedMovementIds') + ->removeColumn('deliveryNoteFileIds') + ->update(); + + $movementTable = $this->table('WarehouseMovement'); + $movementTable + ->removeIndex(['linkedOrderId']) + ->removeColumn('linkedOrderId') + ->update(); + } + } +} diff --git a/db/migrations/20260118120000_add_show_technical_data_to_tenant_config.php b/db/migrations/20260118120000_add_show_technical_data_to_tenant_config.php new file mode 100644 index 000000000..4bd3e16c9 --- /dev/null +++ b/db/migrations/20260118120000_add_show_technical_data_to_tenant_config.php @@ -0,0 +1,26 @@ +getEnvironment() !== "thetool") return; + + $table = $this->table('WorkorderTenantConfig'); + if (!$table->hasColumn('showTechnicalData')) { + $table->addColumn('showTechnicalData', 'boolean', [ + 'default' => false, + 'after' => 'requireCableType' + ])->update(); + } + } + + public function down() { + if ($this->getEnvironment() !== "thetool") return; + + $table = $this->table('WorkorderTenantConfig'); + if ($table->hasColumn('showTechnicalData')) { + $table->removeColumn('showTechnicalData')->update(); + } + } +} diff --git a/db/migrations/20260119140000_asset_management_add_category.php b/db/migrations/20260119140000_asset_management_add_category.php new file mode 100644 index 000000000..47dac3c34 --- /dev/null +++ b/db/migrations/20260119140000_asset_management_add_category.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + $table = $this->table("AssetManagement"); + $table->addColumn('category', 'string', [ + 'limit' => 255, + 'null' => true, + 'default' => null, + 'after' => 'description', + 'comment' => 'Free text category for the asset with autocomplete support', + ]); + $table->addIndex(['category'], ['name' => 'idx_category']); + $table->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table("AssetManagement"); + $table->removeIndex(['category']); + $table->removeColumn('category'); + $table->update(); + } + } +} diff --git a/db/migrations/20260119170000_add_metadata_to_workorder.php b/db/migrations/20260119170000_add_metadata_to_workorder.php new file mode 100644 index 000000000..a24126bef --- /dev/null +++ b/db/migrations/20260119170000_add_metadata_to_workorder.php @@ -0,0 +1,30 @@ +getEnvironment() == "thetool") { + $table = $this->table("Workorder"); + $table + ->addColumn("metadata", "json", [ + 'null' => true, + 'default' => null, + 'after' => 'cableType' + ]) + ->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table("Workorder") + ->removeColumn("metadata") + ->save(); + } + } +} diff --git a/db/migrations/20260126120000_warehousearticle_rename_revenueaccount_to_vatgroupid.php b/db/migrations/20260126120000_warehousearticle_rename_revenueaccount_to_vatgroupid.php new file mode 100644 index 000000000..0b1933697 --- /dev/null +++ b/db/migrations/20260126120000_warehousearticle_rename_revenueaccount_to_vatgroupid.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + $this->execute("ALTER TABLE WarehouseArticle CHANGE revenueAccount vatgroup_id INT(11) NOT NULL DEFAULT 2"); + + $this->execute("UPDATE WarehouseArticle SET vatgroup_id = CASE + WHEN vatgroup_id = 0 THEN 2 + WHEN vatgroup_id = 1 THEN 3 + ELSE vatgroup_id + END"); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->execute("UPDATE WarehouseArticle SET vatgroup_id = CASE + WHEN vatgroup_id = 2 THEN 0 + WHEN vatgroup_id = 3 THEN 1 + ELSE vatgroup_id + END"); + + $this->execute("ALTER TABLE WarehouseArticle CHANGE vatgroup_id revenueAccount INT(11) NOT NULL DEFAULT 0"); + } + } +} diff --git a/db/migrations/20260126130000_manualinvoice_column_cleanup.php b/db/migrations/20260126130000_manualinvoice_column_cleanup.php new file mode 100644 index 000000000..4feef0ec7 --- /dev/null +++ b/db/migrations/20260126130000_manualinvoice_column_cleanup.php @@ -0,0 +1,58 @@ +getEnvironment() == "thetool") { + $position = $this->table("ManualInvoiceposition"); + if ($position->hasColumn("billing_id")) { + $position->removeColumn("billing_id")->save(); + } + if ($position->hasColumn("contract_id")) { + $position->removeColumn("contract_id")->save(); + } + // ManualInvoiceposition: Rename columns + $this->execute("ALTER TABLE ManualInvoiceposition CHANGE product_id warehousearticle_id INT(11) NOT NULL"); + $this->execute("ALTER TABLE ManualInvoiceposition CHANGE product_name warehousearticle_name VARCHAR(255) NOT NULL"); + + $invoice = $this->table("ManualInvoice"); + if ($invoice->hasColumn("billing_delivery")) { + $invoice->removeColumn("billing_delivery")->save(); + } + + $this->execute("ALTER TABLE ManualInvoice CHANGE leistungszeitraum performance_period VARCHAR(255) NULL"); + $this->execute("ALTER TABLE ManualInvoice CHANGE einleitender_text introductory_text TEXT NULL"); + $this->execute("ALTER TABLE ManualInvoice CHANGE externe_referenz external_reference VARCHAR(255) NULL"); + $this->execute("ALTER TABLE ManualInvoice CHANGE gesamtrabatt total_discount DECIMAL(6,2) NOT NULL DEFAULT 0.00"); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->execute("ALTER TABLE ManualInvoice CHANGE performance_period leistungszeitraum VARCHAR(255) NULL"); + $this->execute("ALTER TABLE ManualInvoice CHANGE introductory_text einleitender_text TEXT NULL"); + $this->execute("ALTER TABLE ManualInvoice CHANGE external_reference externe_referenz VARCHAR(255) NULL"); + $this->execute("ALTER TABLE ManualInvoice CHANGE total_discount gesamtrabatt DECIMAL(6,2) NOT NULL DEFAULT 0.00"); + + $invoice = $this->table("ManualInvoice"); + $invoice->addColumn("billing_delivery", "enum", [ + "null" => false, + "values" => ["email", "paper"], + "after" => "billing_type" + ])->save(); + + $this->execute("ALTER TABLE ManualInvoiceposition CHANGE warehousearticle_id product_id INT(11) NOT NULL"); + $this->execute("ALTER TABLE ManualInvoiceposition CHANGE warehousearticle_name product_name VARCHAR(255) NOT NULL"); + + $position = $this->table("ManualInvoiceposition"); + $position->addColumn("billing_id", "integer", ["null" => true, "after" => "position_group"]); + $position->addColumn("contract_id", "integer", ["null" => false, "after" => "billing_id"]); + $position->save(); + } + } +} diff --git a/db/migrations/20260127120000_add_tiefbau_sees_normal_docs.php b/db/migrations/20260127120000_add_tiefbau_sees_normal_docs.php new file mode 100644 index 000000000..c896f981a --- /dev/null +++ b/db/migrations/20260127120000_add_tiefbau_sees_normal_docs.php @@ -0,0 +1,32 @@ +getEnvironment() == "thetool") { + $table = $this->table('WorkorderTenantConfig'); + + $table->addColumn('tiefbauSeesNormalDocs', 'boolean', [ + 'default' => false, + 'null' => false, + 'after' => 'showTechnicalData', + 'comment' => 'Allow civil engineering status to see and use normal documentation steps' + ]); + + $table->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WorkorderTenantConfig') + ->removeColumn('tiefbauSeesNormalDocs') + ->save(); + } + } +} diff --git a/db/migrations/20260128074700_add_auto_complete_filter.php b/db/migrations/20260128074700_add_auto_complete_filter.php new file mode 100644 index 000000000..cb95ef5d5 --- /dev/null +++ b/db/migrations/20260128074700_add_auto_complete_filter.php @@ -0,0 +1,36 @@ +getEnvironment() !== 'thetool') { + return; + } + + $workorderTenantConfigTable = $this->table('WorkorderTenantConfig'); + if (!$workorderTenantConfigTable->hasColumn('autoCompleteFilter')) + $workorderTenantConfigTable->addColumn('autoCompleteFilter', 'text', [ + 'null' => true, + 'default' => null, + 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG, + 'after' => 'workorderActiveFilters', + ]) + ->save(); + } + + public function down(): void + { + if ($this->getEnvironment() !== 'thetool') { + return; + } + + $workorderTenantConfigTable = $this->table('WorkorderTenantConfig'); + if ($workorderTenantConfigTable->hasColumn('autoCompleteFilter')) + $workorderTenantConfigTable->removeColumn('autoCompleteFilter')->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/Citycom/OanApiClient.php b/lib/Citycom/OanApiClient.php index c5f61c546..7d61e91f7 100644 --- a/lib/Citycom/OanApiClient.php +++ b/lib/Citycom/OanApiClient.php @@ -219,6 +219,21 @@ class Citycom_OanApiClient { $url = str_replace("{service_id}", $service_id, $this->baseurl.CITYCOM_OAN_API_EP_UPDATE_SERVICES); + $ctx_options = [ + "http" => [ + "ignore_errors" => true, + "method" => "PUT", + "content" => json_encode($data), + "header" => [ + "Accept: application/json", + "Content-type: application/json", + "Authorization: Bearer ".$this->token, + ], + ] + ]; + + $result = $this->runApiRequest($url, $ctx_options); + return $result; } public function cancelService($service_id, $data) { @@ -449,7 +464,7 @@ class Citycom_OanApiClient { $output = file_get_contents($final_url, false, $ctx); $resp = json_decode($output); - //var_dump($resp); + //var_dump($resp);exit; if(!is_object($resp) || (property_exists($resp, "success") && !$resp->success)) { $this->lastError = $output; return false; @@ -459,6 +474,8 @@ class Citycom_OanApiClient { if(is_array($response)) { $return_data = $response; + } elseif(is_object($response) && (!property_exists($response, "data") || !is_array($response->data))) { + $return_data = $response; } elseif(is_object($response) && property_exists($response, "data") && is_array($response->data)) { $return_data = $response->data; diff --git a/lib/Citycom/OanApiHelper.php b/lib/Citycom/OanApiHelper.php index ccd3bacc7..969b8a27c 100644 --- a/lib/Citycom/OanApiHelper.php +++ b/lib/Citycom/OanApiHelper.php @@ -94,7 +94,7 @@ class Citycom_OanApiHelper { $execution_date = date("Y-m-d"); } - + $ctag_range_search = false; if(array_key_exists("ctag_range_search", $data) && $data["ctag_range_search"]) { $ctag_range_search = $data["ctag_range_search"]; @@ -116,21 +116,21 @@ class Citycom_OanApiHelper { // order all services and save ctags $cc_service_types = $this->api->getServiceTypes(); - $this->log->debug(print_r($want_services, true)); + $this->log->debug(__METHOD__.": Want services: ".print_r($want_services, true)); $allowed_service_types = array_merge(CITYCOM_OAN_API_SERVICES_FOR_ORDER, CITYCOM_OAN_API_SERVICES_FOR_RESERVATION); // check if we have these services already foreach($cc_service_types as $stype) { if(!in_array($stype->name, $allowed_service_types)) continue; - $ctag_service_type = array_flip($allowed_service_types)[$stype->name]; + $ctag_service_type = (array_flip($allowed_service_types))[$stype->name]; if(PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => $ctag_service_type])) { // service was ordered already, remove from want_services unset($want_services[$ctag_service_type]); } } - $this->log->debug(print_r($want_services, true)); + $this->log->debug(__METHOD__.": Want services after filtering: ".print_r($want_services, true)); $new_services = []; @@ -140,26 +140,32 @@ class Citycom_OanApiHelper { list($ctags, $mgmt_ctag) = $preorder->getNextFreeCtags(); } - $this->log->debug(print_r($ctags, true)); + //var_dump($ctags); + //var_dump($mgmt_ctag); + + $this->log->debug(__METHOD__.": ctags: ".print_r($ctags, true)); + $this->log->debug(__METHOD__.": mgmt ctag: ".print_r($mgmt_ctag, true)); if(!is_array($ctags)) { $this->log->error(__METHOD__.": No Free Ctags (Preorder ".$preorder->id.")"); return false; } - if(count($ctags) < count($want_services)) { + $ctag_count = count($ctags); + if($mgmt_ctag) $ctag_count++; + if($ctag_count < count($want_services)) { $this->log->error(__METHOD__.": Not enough New Free CTags for Preorder ".$preorder->id); return false; } - $preorder_ctag_data = [ "preorder_id" => $preorder->id, "network" => "citycom-oan", "stag" => $preorder->adb_hausnummer->vlan_stag, ]; + $service_count = 0; foreach($cc_service_types as $stype) { // was this service type requested @@ -169,7 +175,7 @@ class Citycom_OanApiHelper { if($mgmt_ctag && $stype->name == $allowed_service_types["mgmt"]) { $ctag = $mgmt_ctag; } else { - $ctag = $ctags[$service_count]; + $ctag = array_shift($ctags); } $ctag_service_type = array_flip($allowed_service_types)[$stype->name]; if(!$ctag_service_type) { @@ -185,6 +191,7 @@ class Citycom_OanApiHelper { "ctag" => $ctag, ]; + //echo "Creating Service ".$stype->name." on sublocation $sublocation_id with product_id $product_id and ctag $ctag\n"; $this->log->info(__METHOD__.": Creating Service ".$stype->name." on sublocation $sublocation_id with product_id $product_id and ctag $ctag"); //continue; @@ -247,11 +254,61 @@ class Citycom_OanApiHelper { } - - return true; } + /** + * Updates service values if nesseccary + * + * @param $service_ext_num + * @param $data + * @return bool + */ + public function updateService($service_ext_id, $data) { + // get service and compare data + $services = $this->api->getServices(); + if(!$services) { + $this->log->error(__METHOD__.": Error getting services."); + return false; + } + + $service = false; + foreach($services as $cc_service) { + if($cc_service->id == $service_ext_id) { + $service = $cc_service; + break; + } + } + + if(!$service) { + $this->log->error(__METHOD__.": Service not available."); + return false; + } + + $service_data = []; + + // update service if nesseccary + if(array_key_exists("product_name", $data) && $data["product_name"] && $service->product->name != $data["product_name"]) { + $product_data["up"] = $data["up"]; + $product_data["down"] = $data["down"]; + $product_data["name"] = $data["product_name"]; + $product_id = $this->findOrCreateProduct($product_data); + if(!$product_id) { + $this->log->error(__METHOD__.": Cannot find or create product ".$product_data["name"]); + return false; + } + + $service_data["product"] = $product_id; + } + + if(!count($service_data)) return true; + $result = $this->api->updateService($service->id, $service_data); + + if($result) return true; + return false; + + } + public static function citycomIdToHausnummerExtref($id) { diff --git a/lib/GenieACS/GenieACS.php b/lib/GenieACS/GenieACS.php index 540026e90..6905065b9 100644 --- a/lib/GenieACS/GenieACS.php +++ b/lib/GenieACS/GenieACS.php @@ -106,6 +106,24 @@ class GenieACS { return $this->_request('GET', '/api/devices'); } + public function getDeviceByMac($mac) { + $mac = strtolower(preg_replace('/[^A-Fa-f0-9]/', '', $mac)); + $mac = implode(':', str_split($mac, 2)); + + $paths = [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.MACAddress', + ]; + + foreach ($paths as $path) { + $filter = urlencode($path . ' = "' . $mac . '"'); + $result = $this->_request('GET', '/api/devices/?filter=' . $filter . '&limit=1'); + if ($result && is_array($result) && count($result) > 0) return $result[0]; + } + + return null; + } + public function getDevice($deviceId) { return $this->_request('GET', '/api/devices/' . rawurlencode($deviceId)); } diff --git a/lib/Rimoapi/Rimoapi.php b/lib/Rimoapi/Rimoapi.php index f50c1fe2c..a371a9ffd 100644 --- a/lib/Rimoapi/Rimoapi.php +++ b/lib/Rimoapi/Rimoapi.php @@ -395,12 +395,18 @@ class Rimoapi { return false; } + $filename = false; + $filetype = false; + foreach($items->item as $item) { if(!$item->name) continue; if(!preg_match('/_AHA.pdf$/i', $item->name)) continue; $filename = $item->name; + if(property_exists($item, "fileType") && is_object($item->fileType) && property_exists($item->fileType, "name")) { + $filetype = $item->fileType->name; + } } if(!$filename) { @@ -413,6 +419,9 @@ class Rimoapi { $params['apiKey'] = $apikey; $params['objectId'] = $rimo_id; $params['fileNames'] = $filename; + if($filetype) { + $params["fileTypeNames"] = $filetype; + } $ctx_opts = [ 'http' => [ @@ -553,13 +562,13 @@ class Rimoapi { return $resp_data; } - public static function addRemark($object_id, $text) { + public static function addRemark($apikey, $object_id, $text) { if(!$object_id || !$text) return false; $log = mfLoghandler::singleton(); $params = []; - $params['apiKey'] = RIMO_API_JSON_APIKEY; + $params['apiKey'] = $apikey; $params['objectId'] = $object_id; $params['text'] = $text; diff --git a/lib/SNOPP/SNOPP.php b/lib/SNOPP/SNOPP.php deleted file mode 100644 index d995ea3e5..000000000 --- a/lib/SNOPP/SNOPP.php +++ /dev/null @@ -1,36 +0,0 @@ - [ - 'method' => 'GET', - 'header' => "X-Api-Key: $SNOPP_API_KEY\r\n", - ] - ]; - - $snopp_output = file_get_contents("$SNOPP_API_URL/ticket/find?provider_id=all&status=open", false, stream_context_create($ctx_opts)); - $ticket_obj = json_decode($snopp_output); - $tickets = $ticket_obj->result->tickets; - - return $tickets; - } - -} -?> diff --git a/lib/Snoppapi/Snoppapi.php b/lib/Snoppapi/Snoppapi.php new file mode 100644 index 000000000..2b7aedaca --- /dev/null +++ b/lib/Snoppapi/Snoppapi.php @@ -0,0 +1,111 @@ +baseurl = $baseurl; + $this->apikey = $apikey; + + $this->log = mfLoghandler::singleton(); + } + + + public function searchAddress(Array $address_data) { + $street = trim($address_data['street']); + $zip = trim($address_data['zip']); + $city = trim($address_data['city']); + + $ctx_opts = [ + 'http' => [ + 'method' => 'GET', + 'ignore_errors' => true, + 'header' => [ + "Accept: application/json", + "X-Api-Key: {$this->apikey}", + ] + ] + ]; + + $getHomesEp = $this->baseurl.SNOPP_API_EP_GET_HOMES; + //$url = $getHomesEp."?".$qs; + $url = $getHomesEp; + + $ctx = stream_context_create($ctx_opts); + $this->log->debug(__METHOD__.": Getting SNOPP homes: $url"); + $response = file_get_contents($url, false, $ctx); + //var_dump($response);exit; + if($response === false) { + $this->log->error("Fehler beim Homes abfragen in SNOPP"); + return false; + } + + $resp_data = json_decode($response); + $homes = $resp_data->result->homes; + + $results = []; + + foreach($homes as $home) { + if(trim($home->street) == $street + && trim($home->zip) == $zip + && trim($home->city) == $city + ) { + $results[] = $home; + } + } + + return $results; + } + + public function submitOrder(Array $order_data) { + $data = []; + foreach(["oan_id", "execution_date", "product_id", "name", "street", "zip", "city"] as $field) { + if(!array_key_exists($field, $order_data) || !$order_data[$field]) { + $this->log->error(__METHOD__.": Mandatory field '$field' missing"); + return false; + } + $data[$field] = $order_data[$field]; + } + + foreach(["phone", "mobile", "email"] as $field) { + if(array_key_exists($field, $order_data) && $order_data[$field]) { + $data[$field] = $order_data[$field]; + } + } + + $ctx_opts = [ + 'http' => [ + 'method' => 'POST', + 'content' => json_encode($data), + 'ignore_errors' => true, + 'header' => [ + "Accept: application/json", + "Content-type: application/json", + "X-Api-Key: {$this->apikey}", + ] + ] + ]; + + $url = $this->baseurl.SNOPP_API_EP_SUBMIT_ORDER; + + $ctx = stream_context_create($ctx_opts); + $this->log->debug(__METHOD__.": Ordering Snopp product: $url\n".print_r($data, true)); + $response = file_get_contents($url, false, $ctx); + + $this->log->debug(__METHOD__.": ".print_r($response, true)); + + if($response === false) { + $this->log->error("Fehler beim Bestellen in SNOPP ".print_r($response, true)); + return false; + } + + $resp_data = json_decode($response); + return $resp_data; + } + +} 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/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/AssetManagement/AssetManagement.js b/public/js/pages/AssetManagement/AssetManagement.js index a26bd81d1..64ab588d0 100644 --- a/public/js/pages/AssetManagement/AssetManagement.js +++ b/public/js/pages/AssetManagement/AssetManagement.js @@ -454,6 +454,14 @@ Vue.component('asset-management-modal', { + @@ -488,9 +496,11 @@ Vue.component('asset-management-modal', { `, data(){ return { + categoryAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/AssetManagement/getCategories', asset: { name: '', description: '', + category: '', assetNumber: '', location: 'Liftkammer', serviceDueDate: null, diff --git a/public/js/pages/ManualInvoice/ManualInvoice.js b/public/js/pages/ManualInvoice/ManualInvoice.js index 4ce7a72cd..2967d5712 100644 --- a/public/js/pages/ManualInvoice/ManualInvoice.js +++ b/public/js/pages/ManualInvoice/ManualInvoice.js @@ -52,27 +52,21 @@ Vue.component('manual-invoice', { }, async handleSave(invoiceData) { try { - const positions = invoiceData.positions.map(p => { - const amount = parseFloat(p.amount) || 0; - const price = parseFloat(p.price) || 0; - const discount = parseFloat(p.discount) || 0; - const vatrate = parseFloat(p.vatrate) || 0; - const priceAfterDiscount = amount * price * (1 - discount / 100); - return { - ...p, amount, price, discount, vatrate, - unit: p.unit || 'Stk.', - price_total: priceAfterDiscount, - price_gross: priceAfterDiscount * (1 + vatrate / 100), - product_id: p.product_id || 0, - contract_id: p.contract_id || 0, - billing_id: p.billing_id || null, - matchcode: p.matchcode || null, - fibu_cost_account: p.fibu_cost_account || null, - fibu_cost_account_legacy: p.fibu_cost_account_legacy || null, - fibu_taxcode: p.fibu_taxcode || null, - options: p.options || null - }; - }); + const positions = invoiceData.positions.map(p => ({ + ...p, + amount: parseFloat(p.amount) || 0, + price: parseFloat(p.price) || 0, + discount: parseFloat(p.discount) || 0, + vatrate: parseFloat(p.vatrate) || 0, + unit: p.unit || 'Stk.', + warehousearticle_id: p.warehousearticle_id || p.product_id || 0, + warehousearticle_name: p.warehousearticle_name || p.product_name || '', + matchcode: p.matchcode || null, + fibu_cost_account: p.fibu_cost_account || null, + fibu_cost_account_legacy: p.fibu_cost_account_legacy || null, + fibu_taxcode: p.fibu_taxcode || null, + options: p.options || null + })); const payload = { ...invoiceData, @@ -82,11 +76,13 @@ Vue.component('manual-invoice', { customer_number: invoiceData.customer_number || 0, country: invoiceData.country || 'Österreich', billing_type: invoiceData.billing_type || 'invoice', - billing_delivery: 'email', fibu_payment_due: 14, fibu_account_number: invoiceData.fibu_account_number || 0, - vatgroup_id: 1, - gesamtrabatt: parseFloat(invoiceData.gesamtrabatt) || 0 + vatgroup_id: invoiceData.vatgroup_id || 1, + performance_period: invoiceData.performance_period || invoiceData.leistungszeitraum || '', + introductory_text: invoiceData.introductory_text || invoiceData.einleitender_text || '', + external_reference: invoiceData.external_reference || invoiceData.externe_referenz || '', + total_discount: parseFloat(invoiceData.total_discount || invoiceData.gesamtrabatt) || 0 }; const url = invoiceData.id ? window.TT_CONFIG.UPDATE_URL : window.TT_CONFIG.CREATE_URL; @@ -161,20 +157,28 @@ Vue.component('manual-invoice-modal', { -
- - +
+
+ +
+ + {{ effectiveBillingType === 'sepa' ? 'SEPA' : 'Rechnung' }} + + + Brutto überschreitet SEPA-Limit ({{ formatPrice(customerBillingInfo.manual_invoice_sepa_limit) }}) + +
+
- - - + + + - + - - - + +
@@ -197,41 +201,48 @@ Vue.component('manual-invoice-modal', { pdfLoading: false, pdfPreviewUrl: '', previewDebounceTimer: null, + customerBillingInfo: { + billing_type: 'invoice', + manual_invoice_sepa_limit: null, + vatarea: 'domestic', + tax_text: '' + }, invoiceData: { id: null, invoice_number: null, invoice_date: moment().format('YYYY-MM-DD'), billingaddress_id: null, owner_id: null, customer_number: 0, fibu_account_number: 0, company: '', firstname: '', lastname: '', street: '', zip: '', city: '', country: 'Österreich', - uid: '', email: '', billing_type: 'invoice', tax_text: '', - leistungszeitraum: '', einleitender_text: '', externe_referenz: '', gesamtrabatt: 0, + uid: '', email: '', billing_type: 'invoice', tax_text: '', vatgroup_id: 1, + performance_period: '', introductory_text: '', external_reference: '', total_discount: 0, positions: [], total: 0, total_gross: 0 }, - billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}], positionsConfig: { fields: { - product_name: { type: 'input', label: 'Bezeichnung' }, + article_id: { + type: 'input-article', + label: 'Artikel', + apiUrl: '/WarehouseArticle/autocomplete', + customFieldReference: 'WarehouseArticle', + emitDisplayValue: true + }, + warehousearticle_name: { type: 'input', label: 'Bezeichnung' }, product_info: { type: 'input', label: 'Zusatzinfo' }, amount: { type: 'input', label: 'Menge', inputType: 'number' }, - unit: { + price_type: { type: 'select', - label: 'Einheit', - options: [ - { value: 'Pau.', text: 'Pau.' }, - { value: 'Stk.', text: 'Stk.' }, - { value: 'h', text: 'h' }, - { value: 'm', text: 'm' } - ] + label: 'Preistyp', + options: [] }, price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' }, discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number' }, - vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' }, }, validateForm: (d) => { - if (!d.product_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; } - if (!d.amount) { window.notify('error', 'Menge ist erforderlich.'); return false; } + if (!d.warehousearticle_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; } + if (d.amount == null || d.amount === '') { window.notify('error', 'Menge ist erforderlich.'); return false; } if (d.price == null) { window.notify('error', 'Preis ist erforderlich.'); return false; } return true; } - } + }, + articlePrices: [] }; }, computed: { @@ -246,34 +257,46 @@ Vue.component('manual-invoice-modal', { subtotal += lineTotal; }); - // Apply gesamtrabatt - const gesamtrabatt = parseFloat(this.invoiceData.gesamtrabatt) || 0; - const net = subtotal * (1 - gesamtrabatt / 100); - - // Calculate VAT + const totalDiscount = parseFloat(this.invoiceData.total_discount) || 0; + const net = subtotal * (1 - totalDiscount / 100); let vat = {}, gross = 0; (this.invoiceData.positions || []).forEach(p => { const amount = parseFloat(p.amount) || 0; const price = parseFloat(p.price) || 0; const discount = parseFloat(p.discount) || 0; const r = parseInt(p.vatrate) || 0; - const lineNet = amount * price * (1 - discount / 100) * (1 - gesamtrabatt / 100); + const lineNet = amount * price * (1 - discount / 100) * (1 - totalDiscount / 100); const lineVat = lineNet * (r / 100); vat[r] = (vat[r] || 0) + lineVat; gross += lineNet + lineVat; }); return { subtotal, net, vat, gross }; + }, + effectiveBillingType() { + if (this.customerBillingInfo.billing_type !== 'sepa') return 'invoice'; + if (this.customerBillingInfo.manual_invoice_sepa_limit === null) return 'sepa'; + return this.totals.gross <= this.customerBillingInfo.manual_invoice_sepa_limit ? 'sepa' : 'invoice'; } }, watch: { 'invoiceData': { handler() { this.debouncedPreviewUpdate(); }, deep: true }, + effectiveBillingType: { + handler(newType) { + this.invoiceData.billing_type = newType; + }, + immediate: true + }, 'invoiceData.billingaddress_id': { async handler(newId) { - if (!newId) return Object.assign(this.invoiceData, { - company: '', firstname: '', lastname: '', street: '', zip: '', city: '', - country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0 - }); + if (!newId) { + Object.assign(this.invoiceData, { + company: '', firstname: '', lastname: '', street: '', zip: '', city: '', + country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0 + }); + this.customerBillingInfo = { billing_type: 'invoice', manual_invoice_sepa_limit: null, vatarea: 'domestic', tax_text: '' }; + return; + } const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`); if (data.status === 'OK' && data.result.address) { @@ -285,6 +308,8 @@ Vue.component('manual-invoice-modal', { fibu_account_number: a.fibu_account_number || 0, owner_id: newId }); } + + await this.fetchCustomerBillingInfo(newId); } } }, @@ -321,15 +346,100 @@ Vue.component('manual-invoice-modal', { methods: { close() { this.$emit('close'); }, saveInvoice() { + this.invoiceData.invoice_date = moment().format('YYYY-MM-DD'); + this.invoiceData.billing_type = this.effectiveBillingType; + this.invoiceData.tax_text = this.customerBillingInfo.tax_text; if (!this.invoiceData.billingaddress_id) return window.notify('error', 'Bitte wählen Sie einen Kunden aus.'); if (!this.invoiceData.positions?.length) return window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.'); this.$emit('save', this.invoiceData); }, + formatPrice(value) { + if (value === null || value === undefined) return '-'; + return new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value); + }, + async fetchCustomerBillingInfo(addressId) { + if (!addressId) return; + try { + const vatgroupId = this.invoiceData.vatgroup_id || 2; + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getCustomerBillingInfo?address_id=${addressId}&vatgroup_id=${vatgroupId}`); + if (data.success) { + this.customerBillingInfo = { + billing_type: data.billing_type || 'invoice', + manual_invoice_sepa_limit: data.manual_invoice_sepa_limit, + vatarea: data.vatarea || 'domestic', + tax_text: data.tax_text || '' + }; + this.invoiceData.tax_text = data.tax_text || ''; + } + } catch (e) { + console.error('Error fetching customer billing info:', e); + } + }, handleResize() { this.isLargeScreen = window.innerWidth >= 1920; }, handleGlobalKeydown(e) { if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); } }, togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; }, + async onArticleSelected(articleId) { + if (!articleId) { + // Reset price type options when no article selected + this.articlePrices = []; + this.positionsConfig.fields.price_type.options = []; + return; + } + try { + const vatarea = this.customerBillingInfo.vatarea || 'domestic'; + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getArticleVatInfo?article_id=${articleId}&vatarea=${vatarea}`); + if (data.success && this.$refs.positionsManager) { + const pm = this.$refs.positionsManager; + if (data.article) { + pm.$set(pm.formData, 'warehousearticle_name', data.article.articleNumber + ' | ' + data.article.title); + pm.$set(pm.formData, 'product_info', data.article.description || ''); + pm.$set(pm.formData, 'unit', data.article.unit || 'Stk.'); + } + 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); + this.invoiceData.vatgroup_id = data.vatgroup_id; + await this.updateTaxText(data.vatgroup_id); + + // Handle prices and price type selection + this.articlePrices = data.prices || []; + if (this.articlePrices.length > 0) { + const priceOptions = this.articlePrices.map(p => ({ value: p.title, text: `${p.title} (${this.formatPrice(p.price)})` })); + this.positionsConfig.fields.price_type.options = priceOptions; + // Set first price as default + pm.$set(pm.formData, 'price_type', this.articlePrices[0].title); + pm.$set(pm.formData, 'price', this.articlePrices[0].price); + } else { + this.positionsConfig.fields.price_type.options = []; + } + } + } catch (e) { + console.error('Error fetching article VAT info:', e); + } + }, + onPriceTypeChanged(priceType) { + if (!priceType || !this.articlePrices.length) return; + const selectedPrice = this.articlePrices.find(p => p.title === priceType); + if (selectedPrice && this.$refs.positionsManager) { + this.$refs.positionsManager.$set(this.$refs.positionsManager.formData, 'price', selectedPrice.price); + } + }, + async updateTaxText(vatgroupId) { + if (!vatgroupId) return; + try { + const vatarea = this.customerBillingInfo.vatarea || 'domestic'; + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getTaxText?vatgroup_id=${vatgroupId}&vatarea=${vatarea}`); + if (data.success) { + this.customerBillingInfo.tax_text = data.tax_text || ''; + this.invoiceData.tax_text = data.tax_text || ''; + } + } catch (e) { + console.error('Error fetching tax text:', e); + } + }, debouncedPreviewUpdate() { clearTimeout(this.previewDebounceTimer); this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000); @@ -338,7 +448,7 @@ Vue.component('manual-invoice-modal', { this.pdfLoading = true; try { const positions = this.invoiceData.positions - .filter(p => p.product_name && (parseFloat(p.amount) || 0) > 0) // Filter out empty positions + .filter(p => p.warehousearticle_name && (parseFloat(p.amount) || 0) !== 0) .map(p => { const amount = parseFloat(p.amount) || 0; const price = parseFloat(p.price) || 0; @@ -407,17 +517,17 @@ Vue.component('manual-invoice-modal', { } // Pre-fill external reference with shipping note reference - this.invoiceData.externe_referenz = `Lieferschein #${shippingNoteData.shippingNoteId}`; + this.invoiceData.external_reference = `Lieferschein #${shippingNoteData.shippingNoteId}`; // Add introductory text if shipping note has notes if (shippingNoteData.note) { - this.invoiceData.einleitender_text = shippingNoteData.note; + this.invoiceData.introductory_text = shippingNoteData.note; } // Add all positions (batch operation to avoid triggering watcher for each item) if (shippingNoteData.positions && Array.isArray(shippingNoteData.positions)) { const newPositions = shippingNoteData.positions.map(position => ({ - product_name: position.product_name || '', + warehousearticle_name: position.warehousearticle_name || position.product_name || '', product_info: position.product_info || '', amount: parseFloat(position.amount) || 0, unit: position.unit || 'Stk.', @@ -450,7 +560,7 @@ Vue.component('manual-invoice-modal', { Vue.component('gutschrift-modal', { props: ['invoiceId'], template: ` - +

Lade Rechnungsdaten...

Originalrechnung: {{ invoice.invoice_number }} - {{ invoice.customer_name }}
@@ -466,7 +576,7 @@ Vue.component('gutschrift-modal', {
- + @@ -507,7 +617,7 @@ Vue.component('gutschrift-modal', { .map((p, i) => ({ p, i })).filter(({ i }) => this.selectedPositions[i]) .map(({ p, i }) => { const amt = this.creditAmounts[i]; - if (amt > p.available_amount) throw new Error(`Menge zu hoch: ${p.product_name}`); + if (amt > p.available_amount) throw new Error(`Menge zu hoch: ${p.warehousearticle_name}`); return amt > 0 ? { ...p, amount: amt } : null; }).filter(Boolean); @@ -527,7 +637,7 @@ Vue.component('gutschrift-modal', { Vue.component('send-invoice-modal', { props: ['invoiceId'], template: ` - + @@ -543,11 +653,11 @@ Vue.component('send-invoice-modal', {
- +
@@ -557,8 +667,8 @@ Vue.component('send-invoice-modal', {
-
- +
+
@@ -582,7 +692,6 @@ Vue.component('send-invoice-modal', { if (data.success) { this.invoice = data.invoice; this.emailAddress = data.invoice.email || ''; - this.selectedAction = data.invoice.email ? 'email' : 'download'; } else { window.notify('error', data.message || 'Fehler beim Laden der Rechnung'); this.close(); diff --git a/public/js/pages/PreorderLogistics/PreorderLogistics.css b/public/js/pages/PreorderLogistics/PreorderLogistics.css index 6678ffa59..5513bac12 100644 --- a/public/js/pages/PreorderLogistics/PreorderLogistics.css +++ b/public/js/pages/PreorderLogistics/PreorderLogistics.css @@ -10,4 +10,33 @@ .col-form-label { padding-top: 0 !important +} + +/* Filter panel styles */ +.filter-panel { + padding: 1rem !important; +} + +.filter-panel .header-title { + font-size: 1.1rem; + margin-bottom: 0.75rem !important; +} + +.filter-panel .row.g-2 > [class*="col-"] { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.filter-panel .form-group { + margin-bottom: 0; +} + +.filter-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.filter-actions .btn { + margin-right: 0; } \ No newline at end of file diff --git a/public/js/pages/PreorderLogistics/PreorderLogistics.js b/public/js/pages/PreorderLogistics/PreorderLogistics.js index 7bda1d003..41f82d9c6 100644 --- a/public/js/pages/PreorderLogistics/PreorderLogistics.js +++ b/public/js/pages/PreorderLogistics/PreorderLogistics.js @@ -2,35 +2,38 @@ Vue.component('preorder-logistics', { template: `
-
-

Filter

-
-
+
+

Filter

+
+
-
+
-
+
-
+
-
+
+
+ +
-
-
+
+
-
+
-
-
+
+
i { font-size: 28px; color: var(--accent); flex-shrink: 0; } .tt-scope .router-header-text { flex-grow: 1; min-height: 39px; } +.tt-scope .refresh-btn { padding: 8px; border-radius: 6px; flex-shrink: 0; } +.tt-scope .refresh-btn i { font-size: 16px; margin: 0; } .tt-scope .router-title { font-size: 16px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; line-height: 1.375; } .tt-scope .router-subtitle { font-size: 12px; color: var(--muted); font-family: var(--mono); margin-top: 2px; line-height: 1.25; } .tt-scope .router-info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 16px; margin-right: -8px; } diff --git a/public/js/pages/Radius/Radius.js b/public/js/pages/Radius/Radius.js index f7f97cd2a..14bbb860f 100644 --- a/public/js/pages/Radius/Radius.js +++ b/public/js/pages/Radius/Radius.js @@ -44,6 +44,9 @@ const Radius = {
+
+ +
`, @@ -59,7 +62,8 @@ const Radius = { const options = [ { id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' }, { id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' }, - { id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' } + { id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' }, + { id: 'avmscanner', name: 'AVM Scanner', icon: 'fa-duotone fa-router' } ]; if (window.TT_CONFIG.CAN_BILLING === '1') { @@ -84,6 +88,7 @@ const Radius = { let refName = ''; if (v === 'free') refName = 'freeView'; else if (v === 'unused') refName = 'unusedView'; + else if (v === 'avmscanner') refName = 'avmScannerView'; if (refName) { this.$nextTick(() => { diff --git a/public/js/pages/Radius/RadiusAVMScanner.js b/public/js/pages/Radius/RadiusAVMScanner.js new file mode 100644 index 000000000..e8f1e944a --- /dev/null +++ b/public/js/pages/Radius/RadiusAVMScanner.js @@ -0,0 +1,268 @@ +const RadiusAVMScanner = { + name: 'RadiusAVMScanner', + template: ` +
+
+
+
+ + +
+
+
+ +
+
+ + + +
+
+ +
+
+
+ Scan läuft... + {{ state.progress?.current || 0 }} / {{ state.progress?.total || 0 }} +
+
+
+
+
+ Aktuell: {{ state.currentDevice.mac }} + ({{ state.currentDevice.ip }}) +
+
+
+ +
+ + + + + + +
+ + {{ filteredDevices.length }} von {{ state.devices.length }} Geräten + + + Keine Geräte +
+
+
+ `, + data: () => ({ + window: window, + state: null, + isLoading: false, + polling: null, + deviceTypeFilter: '', + showErledigt: true + }), + computed: { + progressPercent() { + if (!this.state?.progress?.total) return 0; + return Math.round((this.state.progress.current / this.state.progress.total) * 100); + }, + deviceTypes() { + if (!this.state?.devices) return []; + const types = new Set(); + this.state.devices.forEach(d => { + if (d.deviceType) types.add(d.deviceType); + }); + return Array.from(types).sort(); + }, + filteredDevices() { + if (!this.state?.devices) return []; + return this.state.devices.filter(d => { + if (this.deviceTypeFilter && d.deviceType !== this.deviceTypeFilter) return false; + if (!this.showErledigt && d.erledigt) return false; + return true; + }); + } + }, + methods: { + initIfNeeded() { + this.refreshState(); + }, + startPolling() { + if (this.polling) return; + this.polling = setInterval(() => { + this.refreshState(true); + }, 3000); // Poll every 3 seconds + }, + stopPolling() { + if (this.polling) { + clearInterval(this.polling); + this.polling = null; + } + }, + async refreshState(silent = false) { + if (!silent) this.isLoading = true; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerGetState`); + this.state = data; + + // Start/stop polling based on scanning state + if (data.scanning && !this.polling) { + this.startPolling(); + } else if (!data.scanning && this.polling) { + this.stopPolling(); + } + } catch (e) { + console.error('Failed to fetch AVM scanner state:', e); + } + if (!silent) this.isLoading = false; + }, + async startScan() { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerStart`); + if (data.success) { + window.notify('success', `Scan gestartet für ${data.total} Geräte`); + this.startPolling(); // Start polling immediately + } else { + window.notify('warning', data.message || 'Scan konnte nicht gestartet werden'); + } + this.refreshState(); + } catch (e) { + console.error('Failed to start scan:', e); + window.notify('error', 'Fehler beim Starten des Scans'); + } + }, + async stopScan() { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerStop`); + if (data.success) { + window.notify('info', 'Scan wird gestoppt...'); + } + } catch (e) { + console.error('Failed to stop scan:', e); + window.notify('error', 'Fehler beim Stoppen des Scans'); + } + }, + async toggleErledigt(mac) { + try { + await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerToggleErledigt`, { mac }); + this.refreshState(true); + } catch (e) { + console.error('Failed to toggle erledigt:', e); + window.notify('error', 'Fehler beim Aktualisieren'); + } + }, + formatDate(dateStr) { + if (!dateStr) return '—'; + const d = new Date(dateStr); + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + } + }, + beforeUnmount() { + this.stopPolling(); + } +}; + +if (window.VueApp) { + window.VueApp.component('radius-avm-scanner', RadiusAVMScanner); +} diff --git a/public/js/pages/Radius/RadiusRouterManager.js b/public/js/pages/Radius/RadiusRouterManager.js index 47289255a..95c92c04d 100644 --- a/public/js/pages/Radius/RadiusRouterManager.js +++ b/public/js/pages/Radius/RadiusRouterManager.js @@ -32,6 +32,9 @@ const RadiusRouterManager = { {{ routerDevice.username || userItem.username }}
+
@@ -40,7 +43,7 @@ const RadiusRouterManager = { - +
@@ -239,7 +242,8 @@ const RadiusRouterManager = { showEventLogModal: false, eventLogLoading: false, - eventLogData: null + eventLogData: null, + refreshLoading: false }), watch: { show: { @@ -265,18 +269,12 @@ const RadiusRouterManager = { this.speedtestLoading = false; try { - const { data: radacct } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { - params: { action2: 'fetchRadacct', username: this.userItem.username } + const { data: deviceData } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceByMac`, { + params: { mac: this.userItem.username } }); - if (radacct?.ip) { - const { data: deviceData } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceByIp`, { - params: { ip: radacct.ip } - }); - - if (deviceData?.success) { - this.routerDevice = deviceData; - } + if (deviceData?.success) { + this.routerDevice = deviceData; } } catch (error) { console.error('Error fetching router:', error); @@ -284,6 +282,25 @@ const RadiusRouterManager = { } this.routerLoading = false; }, + async refreshDevice() { + if (!this.routerDevice?.deviceId) return; + this.refreshLoading = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRefreshDevice`, { + deviceId: this.routerDevice.deviceId + }); + if (data?.success) { + this.routerDevice.deviceInfo = data.deviceInfo; + this.routerDevice.externalIp = data.externalIp; + this.routerDevice.managementIp = data.managementIp; + window.notify('success', 'Daten aktualisiert'); + } + } catch (error) { + console.error('Error refreshing device:', error); + window.notify('error', 'Fehler beim Aktualisieren'); + } + this.refreshLoading = false; + }, async rebootRouter() { if (!this.routerDevice || !this.routerDevice.deviceId) return; if (!confirm('Möchten Sie den Router wirklich neu starten?')) return; diff --git a/public/js/pages/User/User.js b/public/js/pages/User/User.js index b39a9b265..fe5cce0ab 100644 --- a/public/js/pages/User/User.js +++ b/public/js/pages/User/User.js @@ -3,14 +3,14 @@ Vue.component("User", {
-
- QR-Code - diff --git a/Layout/default/ManualInvoice/PDF_MAIN.php b/Layout/default/ManualInvoice/PDF_MAIN.php index 49ef1d468..3e6523035 100644 --- a/Layout/default/ManualInvoice/PDF_MAIN.php +++ b/Layout/default/ManualInvoice/PDF_MAIN.php @@ -17,7 +17,7 @@ foreach($invoice->positions as $p) { } } -$gesamtrabatt = $invoice->gesamtrabatt ?? 0; +$total_discount = $invoice->total_discount ?? 0; $subtotal = 0; foreach($invoice->positions as $p) { $subtotal += $p->price_total ?? 0; @@ -127,8 +127,8 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);

Ihre Xinon vom invoice_date)?>

- einleitender_text ?? ''): ?> -

einleitender_text))?>

+ introductory_text ?? ''): ?> +

introductory_text))?>

@@ -166,7 +166,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]); "> - - + + - + diff --git a/Layout/default/MobileApp/App.php b/Layout/default/MobileApp/App.php new file mode 100644 index 000000000..a7f33fb95 --- /dev/null +++ b/Layout/default/MobileApp/App.php @@ -0,0 +1,9 @@ + 'Xinon Mobile', + 'appName' => 'Xinon', + 'manifestPath' => '/mobile/manifest.json', + 'appJsPath' => '/mobile/app.js', + 'swPath' => '/mobile/sw.js', +]; +require __DIR__ . '/Base.php'; diff --git a/Layout/default/MobileApp/Base.php b/Layout/default/MobileApp/Base.php new file mode 100644 index 000000000..63f7f6500 --- /dev/null +++ b/Layout/default/MobileApp/Base.php @@ -0,0 +1,71 @@ + 'Xinon Mobile', + 'appName' => 'Xinon', + 'manifestPath' => '/mobile/manifest.json', + 'appJsPath' => '/mobile/app.js', + 'swPath' => '/mobile/sw.js', + 'additionalStylesheets' => [], +], $appConfig ?? []); +?> + + + + + + <?= htmlspecialchars($config['title']) ?> + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
Lädt...
+
+
+
+ + + + diff --git a/Layout/default/MobileApp/WarehouseStocktake.php b/Layout/default/MobileApp/WarehouseStocktake.php new file mode 100644 index 000000000..738bab8ed --- /dev/null +++ b/Layout/default/MobileApp/WarehouseStocktake.php @@ -0,0 +1,10 @@ + 'Lager Inventur', + 'appName' => 'Inventur', + 'manifestPath' => '/mobile/warehouse-stocktake/manifest.json', + 'appJsPath' => '/mobile/warehouse-stocktake/app.js', + 'swPath' => '/mobile/warehouse-stocktake/sw.js', + 'additionalStylesheets' => ['/mobile/warehouse-stocktake/app.css'], +]; +require __DIR__ . '/Base.php'; diff --git a/Layout/default/Order/Form.php b/Layout/default/Order/Form.php index a513c694e..5ff2e19c1 100644 --- a/Layout/default/Order/Form.php +++ b/Layout/default/Order/Form.php @@ -437,7 +437,7 @@
- order_date) : ""?>" /> + order_date) : $order->order_date ?>" />
@@ -553,7 +553,6 @@
-

Produkte

@@ -585,6 +584,9 @@
+ preorder_id): ?> + +
@@ -596,9 +598,11 @@
-
- -
+ preorder_id): ?> +
+ +
+
@@ -641,7 +645,17 @@
- + + product->getAttributeValue("oaid_enabled")): ?> +
+ + product->attributes) && count($product->product->attributes)) && (array_key_exists(TT_ATTRIB_TERMINATION_REQUIRED_NAME, $product->product->attributes) @@ -828,7 +842,7 @@
- +
@@ -869,7 +883,14 @@
- + + + \ @@ -1973,7 +2001,14 @@ \ \ \ - \ +\ + \ +\ " id="order-dates-id?>"> @@ -543,7 +547,10 @@
- product_name ?? '')?> + warehousearticle_name ?? '')?> product_info) && $p->product_info): ?>
product_info)?>
@@ -186,17 +186,17 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]); endforeach; endforeach; ?> - 0): ?> + 0): ?>
Zwischensumme:
Gesamtrabatt %:-Gesamtrabatt %:-
Gesamt Netto:
pos?> formatAmount()?> - product->name?> + snopp_order_id): ?> + + + product->name?> oaid ? "{$product->oaid}" : ""?> product->attributes) && count($product->product->attributes)) diff --git a/Layout/default/Preorder/Index.php b/Layout/default/Preorder/Index.php index 6a1a44cbb..2f8219386 100644 --- a/Layout/default/Preorder/Index.php +++ b/Layout/default/Preorder/Index.php @@ -888,9 +888,10 @@ $pagination_entity_name = "Vorbestellungen"; Filter-Vorlagen
+ + +
@@ -697,6 +711,7 @@
ctag Typ External IDExternal Name External State
ctag?> service_type?> ext_id)?>ext_name)?> ext_status)?>
active): ?> - - (seit id) ? date("d.m.Y H:i:s", $num->activated_date) : ""?>) + + (seit id) ? date("d.m.Y H:i:s", $num->activated_date) : ""?>) + disabled): ?> + - + $num->contract_id])?>">contract_id?> disabled_reason?>disabled_reason?>disabled > 1) ? " (".date("d.m.Y H:i", $num->disabled).")" : ""?> id && $num->enable_on_date) ? date("d.m.Y", $num->enable_on_date) : ""?> $block->id, "number" => $number])?>"> diff --git a/Layout/default/VueViews/WarehouseStocktakePWA.php b/Layout/default/VueViews/WarehouseStocktakePWA.php index 9c65502bb..8da06d02c 100644 --- a/Layout/default/VueViews/WarehouseStocktakePWA.php +++ b/Layout/default/VueViews/WarehouseStocktakePWA.php @@ -1,4 +1,12 @@ id) { + $openreplayUserId = (string) $user->id; + } +} ?> @@ -32,6 +40,34 @@ } + + + - +
-
- + + - -
-
+ +
+
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/WarehouseShippingNote/PDF_HEADER.html b/Layout/default/WarehouseShippingNote/PDF_HEADER.html index 6c6bd5781..40c2a07ff 100644 --- a/Layout/default/WarehouseShippingNote/PDF_HEADER.html +++ b/Layout/default/WarehouseShippingNote/PDF_HEADER.html @@ -30,6 +30,7 @@ .invoice-details td { text-align: left; + white-space: nowrap; } .invoice-details td:first-child { diff --git a/Layout/default/header.php b/Layout/default/header.php index 96ffbbad7..e27ba457b 100644 --- a/Layout/default/header.php +++ b/Layout/default/header.php @@ -99,6 +99,7 @@ + diff --git a/Layout/default/includes/openreplay.php b/Layout/default/includes/openreplay.php new file mode 100644 index 000000000..b82a4148f --- /dev/null +++ b/Layout/default/includes/openreplay.php @@ -0,0 +1,79 @@ + + * + * Variables that can be set before including: + * - $openreplayUserType: 'internal' (default) or 'external' + * - $openreplayDisabled: set to true to disable tracking + */ + +if (!empty($openreplayDisabled)) return; + +$openreplayUserId = ''; +$openreplayUserName = ''; +$openreplayUserType = $openreplayUserType ?? 'internal'; +$openreplayMetadata = []; + +// Get user info for internal users +if (class_exists('mfUser') && class_exists('mfLoginController') && mfLoginController::isLoggedIn()) { + $user = mfUser::singleton(); + if ($user && $user->id) { + $openreplayUserId = (string) $user->id; + $openreplayUserName = $user->username ?? ''; + $openreplayMetadata['userType'] = $openreplayUserType; + $openreplayMetadata['username'] = $openreplayUserName; + } +} + +// Allow override from JSGlobals (for PWA contexts) +if (isset($JSGlobals['OPENREPLAY_USER_ID'])) { + $openreplayUserId = (string) $JSGlobals['OPENREPLAY_USER_ID']; +} +if (isset($JSGlobals['OPENREPLAY_USER_TYPE'])) { + $openreplayUserType = $JSGlobals['OPENREPLAY_USER_TYPE']; + $openreplayMetadata['userType'] = $openreplayUserType; +} +if (isset($JSGlobals['OPENREPLAY_COMPANY_ID'])) { + $openreplayMetadata['companyId'] = $JSGlobals['OPENREPLAY_COMPANY_ID']; +} + +// Disable on dev environment if needed +$openreplayEnabled = true; +if (defined('MFAPPNAME') && MFAPPNAME === 'devthetool') { + // Optionally disable on dev - comment out to enable on dev too + // $openreplayEnabled = false; +} + +if ($openreplayEnabled): +?> + + diff --git a/Layout/default/menu.php b/Layout/default/menu.php index baf61ddc7..7061bab1a 100644 --- a/Layout/default/menu.php +++ b/Layout/default/menu.php @@ -141,9 +141,9 @@ is(["Admin","netowner","pipeplanner","pipeplanner"]) && $me->is("employee")): ?>
  • "> Verteiler und Schächte
  • is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?>
  • "> Rohrverzeichnis
  • is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?>
  • "> Kabelverzeichnis
  • - can("RMLCompany")): ?>
  • "> Arbeitsaufträge
  • can("RMLAdmin")): ?>
  • "> Arbeitsaufträge-Management
  • + can("RMLAdmin")): ?>
  • "> Arbeitsaufträge-Dashboard
  • can("WorkorderMph")): ?>
  • "> MPH Arbeitsaufträge
  • can("WorkorderMphAdmin")): ?>
  • "> MPH Arbeitsaufträge Verwaltung
  • @@ -185,6 +185,7 @@ 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/Layout/default/vueHeader3.php b/Layout/default/vueHeader3.php index f4a126f50..fe4ede8ca 100644 --- a/Layout/default/vueHeader3.php +++ b/Layout/default/vueHeader3.php @@ -69,6 +69,8 @@ + + diff --git a/application/ADBHausnummer/ADBHausnummerModel.php b/application/ADBHausnummer/ADBHausnummerModel.php index d589558f7..8703b8cd8 100644 --- a/application/ADBHausnummer/ADBHausnummerModel.php +++ b/application/ADBHausnummer/ADBHausnummerModel.php @@ -147,7 +147,7 @@ class ADBHausnummerModel { $sql .= " WHERE $where"; - if (!empty($filter['home_oaid_rimo_id'])) { + if (!empty($filter['home_oaid_rimo_id']) && !$join_tables) { $sql .= " GROUP BY Hausnummer.id"; } diff --git a/application/ADBNetzgebiet/ADBNetzgebiet.php b/application/ADBNetzgebiet/ADBNetzgebiet.php index 54d60385e..b560bddd6 100644 --- a/application/ADBNetzgebiet/ADBNetzgebiet.php +++ b/application/ADBNetzgebiet/ADBNetzgebiet.php @@ -41,6 +41,10 @@ class ADBNetzgebiet extends mfBaseModelV2 { public ?string $source_id = null; public ?string $borderpoly = null; public ?string $freigabe = '["interest", "provision", "order", "reorder"]'; + public int $unit_count = 0; + public int $unit_count_sd = 0; + public int $unit_count_md = 0; + public int $unit_create_oaid = 0; public ?string $options = '{"create_address_parts": 0, "update_freigabe": 1, "update_address": 1, "hausnummer_dont_overwrite_netzgebiet": 0, "create_preorder": 0, "preorder_only_oaid": 0, "wo_ignore_status": 0, "delete_units": 0, "mph_min_homes_tool_automatic_count": 3, "unit_create_oaid": 0}'; public int $create; public int $edit; 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 d514a785d..129f420a5 100644 --- a/application/Address/AddressController.php +++ b/application/Address/AddressController.php @@ -333,6 +333,8 @@ class AddressController extends mfBaseController { $data['fibu_supplier_due'] = ($r->fibu_supplier_due) ? trim($r->fibu_supplier_due) : null; $data['fibu_supplier_skonto'] = ($r->fibu_supplier_skonto) ? trim($r->fibu_supplier_skonto) : null; $data['fibu_supplier_skonto_rate'] = ($r->fibu_supplier_skonto_rate) ? trim($r->fibu_supplier_skonto_rate) : null; + + $data["manual_invoice_sepa_limit"] = ($r->manual_invoice_sepa_limit) ? str_replace(",", ".", trim($r->manual_invoice_sepa_limit)) : null; } diff --git a/application/Api/v1/AddressdbApicontroller.php b/application/Api/v1/AddressdbApicontroller.php index 06d579716..ad59a805e 100644 --- a/application/Api/v1/AddressdbApicontroller.php +++ b/application/Api/v1/AddressdbApicontroller.php @@ -131,7 +131,7 @@ class AddressdbApicontroller extends mfBaseApicontroller { protected function getClusters() { $cluster_search = []; if(count($this->filter_salescluster_ids)) { - $cluster_search['netzgebiet_id'] = $this->filter_salescluster_ids; + $cluster_search['id'] = $this->filter_salescluster_ids; } $clusters = []; foreach(ADBNetzgebietModel::search($cluster_search) as $cluster) { diff --git a/application/Api/v1/InvestigatorApicontroller.php b/application/Api/v1/InvestigatorApicontroller.php new file mode 100644 index 000000000..396db8eeb --- /dev/null +++ b/application/Api/v1/InvestigatorApicontroller.php @@ -0,0 +1,176 @@ +addRoute("/investigator/customer/:id", "getCustomer", "GET"); + $this->addRoute("/investigator/customer/:id/contract", "getCustomerContract", "GET"); + $this->addRoute("/investigator/customer/:id/cpe", "getCustomerCpe", "GET"); + $this->addRoute("/investigator/customer/:id/address", "getCustomerAddress", "GET"); + $this->addRoute("/investigator/search/customer", "searchCustomer", "GET"); + } + + protected function authenticated() + { + $this->registerRoutes(); + } + + public function getCustomer($customerId) + { + if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']); + + $addresses = AddressModel::search(['customer_number' => $customerId]); + if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']); + + $address = $addresses[0]; + return mfResponse::Ok([ + 'customer' => [ + 'id' => $address->id, + 'customerNumber' => $address->customer_number, + 'company' => $address->company, + 'firstName' => $address->firstname, + 'lastName' => $address->lastname, + 'email' => $address->email, + 'phone' => $address->phone, + 'street' => $address->street, + 'zip' => $address->zip, + 'city' => $address->city, + 'country' => $address->country, + ] + ]); + } + + public function getCustomerContract($customerId) + { + if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']); + + $addresses = AddressModel::search(['customer_number' => $customerId]); + if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']); + + $contracts = ContractModel::search(['owner_id' => $addresses[0]->id]); + $contractData = []; + + foreach ($contracts as $contract) { + $contractData[] = [ + 'contractId' => $contract->contract_id, + 'productName' => $contract->product_name, + 'productExternal' => $contract->product_external, + 'price' => $contract->price, + 'billingPeriod' => $contract->billing_period, + 'orderDate' => $contract->order_date, + 'finishDate' => $contract->finish_date, + 'cancelDate' => $contract->cancel_date, + 'slaId' => $contract->sla_id, + 'status' => $contract->cancel_date ? 'Cancelled' : ($contract->finish_date ? 'Active' : 'Pending'), + ]; + } + + return mfResponse::Ok([ + 'customerId' => $customerId, + 'contractCount' => count($contractData), + 'contracts' => $contractData, + ]); + } + + public function getCustomerCpe($customerId) + { + if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']); + + $addresses = AddressModel::search(['customer_number' => $customerId]); + if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']); + + $db = $this->db(); + $sql = "SELECT cp.* FROM Cpeprovisioning cp + INNER JOIN `Order` o ON cp.order_id = o.id + WHERE o.owner_id = " . intval($addresses[0]->id) . " + ORDER BY cp.id DESC LIMIT 10"; + + $res = $db->query($sql); + $cpeData = []; + + while ($row = $db->fetch_object($res)) { + $cpeData[] = [ + 'orderId' => $row->order_id ?? null, + 'routerType' => $row->routertype ?? null, + 'routerConfigFinished' => (bool)($row->routerconfig_finished ?? false), + 'mac' => $row->mac ?? null, + 'ontSerial' => $row->ont_sn ?? null, + 'wifiSsid' => $row->wifi_ssid ?? null, + 'wifiPasswordSet' => !empty($row->wifi_pass), + 'vlanPublic' => $row->vlan_public ?? null, + 'vlanNat' => $row->vlan_nat ?? null, + 'vlanIpv6' => $row->vlan_ipv6 ?? null, + 'shipping' => (bool)($row->shipping ?? false), + ]; + } + + return mfResponse::Ok([ + 'customerId' => $customerId, + 'cpeCount' => count($cpeData), + 'cpeProvisioning' => $cpeData, + ]); + } + + public function getCustomerAddress($customerId) + { + if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']); + + $addresses = AddressModel::search(['customer_number' => $customerId]); + if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']); + + $address = $addresses[0]; + return mfResponse::Ok([ + 'customerId' => $customerId, + 'address' => [ + 'company' => $address->company, + 'name' => trim(($address->firstname ?? '') . ' ' . ($address->lastname ?? '')), + 'street' => $address->street, + 'zip' => $address->zip, + 'city' => $address->city, + 'country' => $address->country, + 'email' => $address->email, + 'phone' => $address->phone, + ] + ]); + } + + public function searchCustomer() + { + $searchTerm = $this->get['q'] ?? ''; + $limit = intval($this->get['limit'] ?? 10); + + if (empty($searchTerm)) return mfResponse::BadRequest(['message' => 'Search term required']); + + $db = $this->db(); + $searchTerm = $db->escape($searchTerm); + + $sql = "SELECT * FROM Address + WHERE customer_number LIKE '%$searchTerm%' + OR CONCAT(firstname, ' ', lastname) LIKE '%$searchTerm%' + OR company LIKE '%$searchTerm%' + OR email LIKE '%$searchTerm%' + OR street LIKE '%$searchTerm%' + LIMIT $limit"; + + $results = $db->queryRows($sql); + $customers = []; + + foreach ($results as $row) { + $customers[] = [ + 'customerId' => $row['customer_number'], + 'name' => trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')) ?: $row['company'], + 'email' => $row['email'], + 'city' => $row['city'], + ]; + } + + return mfResponse::Ok([ + 'searchTerm' => $searchTerm, + 'count' => count($customers), + 'customers' => $customers, + ]); + } +} diff --git a/application/Api/v1/Modules/Operationaldata/SnoppCitycom.php b/application/Api/v1/Modules/Operationaldata/SnoppCitycom.php index 75bdbade4..d8434a062 100644 --- a/application/Api/v1/Modules/Operationaldata/SnoppCitycom.php +++ b/application/Api/v1/Modules/Operationaldata/SnoppCitycom.php @@ -85,13 +85,13 @@ class SnoppCitycom extends Modules\ApiControllerModule $bb_down = $this->post["bb_down"]; $execution_date = false; - if($this->post["execution_date"]) { + /*if($this->post["execution_date"]) { try { $execution_date = new \DateTime($this->post["execution_date"]); } catch(\Exception $e) { return \mfResponse::BadRequest(["message" => "Invalid Timestamp format"]); } - } + }*/ if(!is_numeric($bb_down) || !$bb_down || !is_numeric($bb_up) || !$bb_up || !$bb_down > 10000 || $bb_up > 10000) { @@ -118,6 +118,10 @@ class SnoppCitycom extends Modules\ApiControllerModule // if all services are ordered and active, finish order and return active $ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]); + if(!$ctag) { + return \mfResponse::NotFound(["message" => "Home not found"]); + } + if($ctag->ext_id && $ctag->ext_status == "finished") { if($preorder->status->code < 500) { $preorder->setNewStatusCode(500); @@ -128,11 +132,11 @@ class SnoppCitycom extends Modules\ApiControllerModule // Home must have Status 300, else return deferred - if($wohneinheit->status->code < 300) { + /*if($wohneinheit->status->code < 300) { return \mfResponse::Ok(["message" => "ONT not yet installed. Deferred", "activation_status" => "deferred"]); - } - + }*/ + /* $cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref); $data["up"] = $bb_up; $data["down"] = $bb_down; @@ -159,10 +163,30 @@ class SnoppCitycom extends Modules\ApiControllerModule // order Service at Citycom and set Preorder to 500 Finished if(!$cc_api->orderServices($preorder, $cc_home_id, $data)) { return \mfResponse::InternalServerError(["message" => "Error activating service"]); + }*/ + + // update product at citycom + //$cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref); + $data["up"] = $bb_up; + $data["down"] = $bb_down; + $data["product_name"] = false; + + if($preorder->campaign->fulfillment == "citycom_oan") { + $data["product_name"] = "Estmk Greenstream OAN $bb_down/$bb_up"; } - // live check if service is active, if not return deferred - $ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]); + $cc_api_client = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS); + $cc_api = new \Citycom_OanApiHelper($cc_api_client); + + // try to update product with bandwidth provided by snopp. + // updateService() only updates if values are changed. + if(!$cc_api->updateService($ctag->ext_id, $data)) { + $this->log->error(__METHOD__.": Error updating service {$ctag->ext_id} for preorder {$preorder->id}"); + //return \mfResponse::InternalServerError(["message" => "Error activating service"]); + } + + // check if service is active, if not return deferred + //$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]); if($ctag->ext_id && $ctag->ext_status == "finished") { if($preorder->status->code < 500) { $preorder->setNewStatusCode(500); diff --git a/application/Api/v1/Modules/Preorder/Cif.php b/application/Api/v1/Modules/Preorder/Cif.php index 8e60be040..6a34e818f 100644 --- a/application/Api/v1/Modules/Preorder/Cif.php +++ b/application/Api/v1/Modules/Preorder/Cif.php @@ -43,22 +43,8 @@ class Cif extends Modules\ApiControllerModule { return \mfResponse::NotFound(["message" => "Preorder not found"]); } - // set status to 200 - if($preorder->status->code < 200) { - $new_status = \PreorderstatusModel::getFirst(["code" => 200]); - if(!$new_status) { - return \mfResponse::InternalServerError(); - } - $preorder->status_id = $new_status->id; - $preorder->save(); - } - - $sflag = \PreorderStatusflagModel::getFirst(["code" => 200]); - $sflag->preorder_id = $preorder->id; - if(!$sflag->value->value) { - $sflag->value->value = 1; - $sflag->value->save(); - } + // set status flag 200 + $preorder->setStatusFlag(200, 1); return \mfResponse::Ok(["message" => "Status successfully updated."]); @@ -134,22 +120,8 @@ class Cif extends Modules\ApiControllerModule { return \mfResponse::NotFound(["message" => "Invalid ciftoken"]); } - // set status to 200 - if($preorder->status->code < 200) { - $new_status = \PreorderstatusModel::getFirst(["code" => 200]); - if(!$new_status) { - return \mfResponse::InternalServerError(); - } - $preorder->status_id = $new_status->id; - $preorder->save(); - } - - $sflag = \PreorderStatusflagModel::getFirst(["code" => 200]); - $sflag->preorder_id = $preorder->id; - if(!$sflag->value->value) { - $sflag->value->value = 1; - $sflag->value->save(); - } + // set status flag 200 + $preorder->setStatusFlag(200, 1); return \mfResponse::Ok(["message" => "Status successfully updated."]); diff --git a/application/AssetManagement/AssetManagementController.php b/application/AssetManagement/AssetManagementController.php index 9db10b068..ff7d00cc6 100644 --- a/application/AssetManagement/AssetManagementController.php +++ b/application/AssetManagement/AssetManagementController.php @@ -7,7 +7,8 @@ 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' => 'category', 'text' => 'Kategorie', 'modal' => false, 'table' => ['filter' => 'select', 'filterOptions' => []]], ['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']], @@ -22,6 +23,15 @@ class AssetManagementController extends TTCrud $this->additionalJSVariables['ASSET_ADMIN'] = '0'; $this->columns = array_filter($this->columns, fn($col) => $col['key'] !== 'actions'); } + + $categories = AssetManagementModel::getDistinctCategories(); + $categoryOptions = array_map(fn($cat) => ['value' => $cat, 'text' => $cat], $categories); + foreach ($this->columns as &$column) { + if ($column['key'] === 'category') { + $column['table']['filterOptions'] = $categoryOptions; + break; + } + } } /** @@ -42,7 +52,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); @@ -277,6 +292,18 @@ class AssetManagementController extends TTCrud self::returnJson(['success' => true, 'message' => 'Reservierung gelöscht.']); } + protected function getCategoriesAction() + { + $searchTerm = $this->request->q ?? ''; + $categories = AssetManagementModel::getDistinctCategories($searchTerm); + + $result = array_map(function($category) { + return ['value' => $category, 'text' => $category]; + }, $categories); + + self::returnJson($result); + } + protected function printLabelAction() { if (!$this->user->can('AssetAdmin')) { self::sendError("Permission denied", 403); diff --git a/application/AssetManagement/AssetManagementModel.php b/application/AssetManagement/AssetManagementModel.php index 9cef32f94..793e54f10 100644 --- a/application/AssetManagement/AssetManagementModel.php +++ b/application/AssetManagement/AssetManagementModel.php @@ -4,6 +4,7 @@ class AssetManagementModel extends TTCrudBaseModel { public int $id; public string $name; public ?string $description; + public ?string $category; public ?int $mainImageId; // Renamed from imageId public ?string $imageIds; // Changed to JSON (will be a string in PHP) public string $assetNumber; @@ -35,8 +36,7 @@ class AssetManagementModel extends TTCrudBaseModel { foreach ($searchTerms as $term) { if (empty(trim($term))) continue; $escapedTerm = $db->real_escape_string($term); - // For each term, search in name, assetNumber, and description. - $searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')"; + $searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%' OR `category` LIKE '%$escapedTerm%')"; } if (!empty($searchConditions)) { @@ -99,8 +99,8 @@ class AssetManagementModel extends TTCrudBaseModel { foreach ($searchTerms as $term) { if (empty(trim($term))) continue; $escapedTerm = $db->real_escape_string($term); - // For each term, search in name, assetNumber, and description. - $searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')"; + // For each term, search in name, assetNumber, description, and category. + $searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%' OR `category` LIKE '%$escapedTerm%')"; } if (!empty($searchConditions)) { @@ -128,4 +128,26 @@ class AssetManagementModel extends TTCrudBaseModel { return $result->fetch_assoc()['count']; } + + public static function getDistinctCategories(string $searchTerm = ''): array { + $db = self::getDB(); + $table = self::getFullyQualifiedTable(); + + $sql = "SELECT DISTINCT `category` FROM $table WHERE `category` IS NOT NULL AND `category` != ''"; + + if (!empty($searchTerm)) { + $escapedTerm = $db->real_escape_string($searchTerm); + $sql .= " AND `category` LIKE '%$escapedTerm%'"; + } + + $sql .= " ORDER BY `category` ASC LIMIT 20"; + + $result = $db->query($sql); + $categories = []; + while ($row = $result->fetch_assoc()) { + $categories[] = $row['category']; + } + + return $categories; + } } \ No newline at end of file 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/Emailnotification/Emailnotification.php b/application/Emailnotification/Emailnotification.php index a686a9067..4906b0ca6 100644 --- a/application/Emailnotification/Emailnotification.php +++ b/application/Emailnotification/Emailnotification.php @@ -32,7 +32,7 @@ class Emailnotification { if($object_data !== false) $this->object_data = $object_data; } - public function addAttachment($filepath = null, $content = null, $name = false, $c_type = "application/octet-stream", $disposition = "attachment", $encoding = "base64" , $charset = "utf-8") { + public function addAttachment($filepath = null, $content = null, $name = false, $c_type = "application/octet-stream", $disposition = "attachment", $encoding = "base64", $charset = "utf-8") { $attachment = [ "file" => $filepath, "content" => $content, diff --git a/application/Invoice/InvoiceController.php b/application/Invoice/InvoiceController.php index 6d44febed..3cbc18800 100644 --- a/application/Invoice/InvoiceController.php +++ b/application/Invoice/InvoiceController.php @@ -681,6 +681,8 @@ class InvoiceController extends mfBaseController { } // save Invoiceposition + // first round price + $position->price_gross = round($position->price_gross, 4); if (!$position->save()) { $invoice->rollbackTransaction(); die("Error saving Invoiceposition"); @@ -703,7 +705,7 @@ class InvoiceController extends mfBaseController { } $invoice->total = $total_net; - $invoice->total_gross = $total_gross; + $invoice->total_gross = round($total_gross, 4); //$invoice->total_vat = $total_vat; if (!$invoice->save()) { @@ -778,6 +780,207 @@ class InvoiceController extends mfBaseController { } + protected function manualExportBmd() { + if(!$this->me->can("Billing")) { + $this->redirect("Dashboard"); + } + //var_dump($this->request->get()); + + $csv_header = "\u{FEFF}satzart;konto;gkonto;belegnr;belegdatum;zziel;skontopz;skontotage;buchsymbol;buchcode;prozent;steuercode;betrag;steuer;text;"; + $csv_header .= "bank-iban-nr;bank-swiftcode;bank-mandatsid;bank-mandatsdatum;bank-mandatskz;bank-letztereinzug;zvsperre;bankeinzug;"; + $csv_header .= "kost;kobetrag"; + + $csv_out = ""; + + //var_dump($filter);exit; + $filter = [ + "lock" => 1, + "exported" => 0, + ]; + + if($this->request->manual_invoice_date_from) { + $date_from = Layout::dateToInt($this->request->manual_invoice_date_from); + if($date_from) { + $filter["invoice_date"] = ["from" => $date_from]; + } + } + if($this->request->manual_invoice_date_to) { + $date_to = Layout::dateToInt($this->request->manual_invoice_date_to); + if($date_to) { + $filter["invoice_date"] = ["to" => $date_to]; + } + } + + //var_dump($filter);exit; + + if(!ManualInvoiceModel::count($filter)) { + $this->layout()->setFlash("Keine Rechnungen zum Exportieren gefunden."); + $this->redirect("Invoice"); + } + foreach(ManualInvoiceModel::getAll($filter) as $invoice) { + if($invoice->exported) { + die("wtf"); + } + + $billingaddress = new Address($invoice->billingaddress_id); + if(!$billingaddress->id) { + die("Billingaddresse für Rechnung {$invoice->invoice_number} not found"); + } + + $kostentraeger = []; + //var_dump($invoice->getProperty("positions")); + //$vat_total_gross = 0; + foreach($invoice->getProperty("positions") as $position) { + if(!array_key_exists($position->position_group, $kostentraeger)) { + $kostentraeger[$position->position_group] = 0; + } + //$kostentraeger[$position->position_group] += $position->price_gross; + //$vat_total_gross += $position->price_gross - $position->price_total; + + $price = $position->price_total; + /*if($position->discount) { + $price -= ($price / 100) * $position->discount; + }*/ + if($invoice->gesamtrabatt) { + $price -= ($price / 100) * $invoice->gesamtrabatt; + } + + $kostentraeger[$position->position_group] += $price; + } + + $total_gross = $invoice->total_gross; + /*if($invoice->gesamtrabatt) { + $total_gross -= round(($total_gross / 100) * $invoice->gesamtrabatt, 4); + }*/ + + $total = $invoice->total; + /*if($invoice->gesamtrabatt) { + $total -= round(($total / 100) * $invoice->gesamtrabatt, 4); + }*/ + + if($invoice->total_gross) { + $vatrate = 20; + } + if($invoice->total == $invoice->total_gross && $invoice->fibu_cost_area != "domestic") { + $vatrate = "0"; + } else { + $vatrate = "20"; + } + $vat = $total_gross - $total; + $vat *= -1; + //$vat_total_gross *= -1; + + if($invoice->total < 0) { + $buchsymbol = "GU"; + } else { + $buchsymbol = "AR"; + } + + $fibu_account = $invoice->fibu_account_number; + + $buchungstext = "[".$invoice->customer_number."]"; + if($invoice->company) { + $buchungstext .= " ".$invoice->company; + } elseif($invoice->firstname || $invoice->lastname) { + $buchungstext .= " ".$invoice->firstname." ".$invoice->lastname; + } + + $buchungstext = str_replace(["\n","\r", ";"], "", $buchungstext); + $buchcode = "1"; + $is_sepa = ($invoice->billing_type == "sepa"); + + $iban = ""; + $bic = ""; + $sepa_id = ""; + $sepa_date = false; + $last_invoice_date = false; + $mandatskz = ""; + + if($is_sepa) { + $iban = $invoice->bank_account_iban; + $bic = $invoice->bank_account_bic; + $sepa_id = "R".$fibu_account; + if($billingaddress->sepa_date) { + $sepa_date = new DateTime("@".$billingaddress->sepa_date); + $sepa_date->setTimezone(new DateTimeZone("Europe/Vienna")); + + if($billingaddress->last_invoice_date) { + $sepa_last_date = new DateTime("@".$billingaddress->last_invoice_date); + + $data["sepa_last_date"] = $sepa_last_date->format("Y-m-d"); + + $last_invoice_date = new DateTime("@".$billingaddress->last_invoice_date); + $last_invoice_date->setTimezone(new DateTimeZone("Europe/Vienna")); + if($last_invoice_date->format("Y-m-d") < $sepa_date->format("Y-m-d")) { + $last_invoice_date = false; + } + } + } + + $mandatskz = ($last_invoice_date ? "1" : "0"); + + $three_years_ago = new DateTime("now"); + $three_years_ago->modify("-3 years"); + + if($mandatskz == "0") { + while($sepa_date->format("Y-m-d") < $three_years_ago->format("Y-m-d")) { + $sepa_date->modify("+1 year"); + } + } + + } + + $kost = $invoice->fibu_cost_account; + + $csv_out .= "0;"; + $csv_out .= $fibu_account.";"; + $csv_out .= $invoice->fibu_cost_account.";"; + $csv_out .= $invoice->invoice_number.";"; + $csv_out .= date("d.m.Y", $invoice->invoice_date).";"; + $csv_out .= ($invoice->fibu_payment_due === null) ? ";" : $invoice->fibu_payment_due.";"; + $csv_out .= ($invoice->fibu_payment_skonto) ? $invoice->fibu_payment_skonto.";" : ";"; + $csv_out .= ($invoice->fibu_payment_skonto_rate) ? $invoice->fibu_payment_skonto_rate.";" : ";"; + $csv_out .= $buchsymbol.";"; + $csv_out .= $buchcode.";"; + $csv_out .= $vatrate.";"; + $csv_out .= $invoice->fibu_taxcode.";"; + $csv_out .= number_format($total_gross, 2, ",", "").";"; + $csv_out .= number_format($vat, 2, ",", "").";"; + $csv_out .= $buchungstext.";"; + + $csv_out .= $iban.";"; + $csv_out .= $bic.";"; + $csv_out .= $sepa_id.";"; + $csv_out .= ($sepa_date ? $sepa_date->format("d.m.Y") : "").";"; + $csv_out .= $mandatskz.";"; + $csv_out .= ($last_invoice_date ? $last_invoice_date->format("d.m.Y") : "").";"; + $csv_out .= ($is_sepa ? 0 : 10).";"; + $csv_out .= ($is_sepa ? 1 : 0); + + + if(count($kostentraeger) >= 2) { + foreach($kostentraeger as $kostelle => $kobetrag) { + $kobetrag_text = number_format($kobetrag, 2, ",", ""); + $csv_out .= "\n1;;;;;;;;;;;;;;;;;;;;;;;$kostelle;$kobetrag_text;"; + } + } + + ///var_dump($kostentraeger); + $csv_out .= "\n"; + + + } + //exit; + /*$this->layout()->setFlash("Export erfolgreich abgeschlossen", "success"); + $this->redirect("Invoice");*/ + + header("Content-type: text/csv; charset=utf-8"); + header('Content-disposition: attachment; filename="tt-mrech-export-bmd-'.date('Y-m-d_H-i-s').'.csv"'); + + echo $csv_header."\n".$csv_out; + exit; + } + protected function exportBmdAction() { if(!$this->me->can("Billing")) { $this->redirect("Dashboard"); diff --git a/application/ManualInvoice/ManualInvoiceController.php b/application/ManualInvoice/ManualInvoiceController.php index caa91d659..b29cca986 100644 --- a/application/ManualInvoice/ManualInvoiceController.php +++ b/application/ManualInvoice/ManualInvoiceController.php @@ -105,10 +105,12 @@ class ManualInvoiceController extends TTCrud "{{ billingAccount }}" => $invoice->fibu_account_number ?? '', "{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU", "{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()), - "{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "
    Leistungszeitraum:" . htmlspecialchars($invoice->leistungszeitraum) . "
    Externe Referenz:" . htmlspecialchars($invoice->externe_referenz) . "
    Leistungszeitraum:" . htmlspecialchars($invoice->performance_period) . "
    Externe Referenz:" . htmlspecialchars($invoice->external_reference) . "
    Ihre UID:" . $invoice->uid . "
    QR-Codetotal_gross ?? 0, 2)) . '" style="display: block; height: 100%; max-height: 3.5cm; width: auto;"> + +
    {{ pos.product_name }}
    {{ pos.product_info }}
    {{ pos.warehousearticle_name }}
    {{ pos.product_info }}
    {{ pos.original_amount }}{{ pos.credited_amount }} {{ pos.available_amount }}