diff --git a/Layout/default/Address/Index.php b/Layout/default/Address/Index.php index 6f4a397a7..66a8bec06 100644 --- a/Layout/default/Address/Index.php +++ b/Layout/default/Address/Index.php @@ -13,7 +13,7 @@
+ + + + + + -
@@ -344,6 +336,12 @@ $pagination_entity_name = "Zustimmungserklärungen";
+ + +
+
+ +
diff --git a/Layout/default/ConstructionConsentProject/Form.php b/Layout/default/ConstructionConsentProject/Form.php index 2dc324781..2acce5f25 100644 --- a/Layout/default/ConstructionConsentProject/Form.php +++ b/Layout/default/ConstructionConsentProject/Form.php @@ -1,5 +1,6 @@ - + +
@@ -27,7 +28,7 @@
"> - "/> + "/>
@@ -36,21 +37,21 @@
- + " />
- + " />
- + " />
@@ -58,8 +59,9 @@
@@ -70,21 +72,21 @@
- + " />
- + " />
- + " />
@@ -96,8 +98,9 @@
@@ -108,7 +111,7 @@
- +
diff --git a/Layout/default/Cpeprovisioning/PDF_MAIN.php b/Layout/default/Cpeprovisioning/PDF_MAIN.php index 9310875e8..3d4e426a0 100644 --- a/Layout/default/Cpeprovisioning/PDF_MAIN.php +++ b/Layout/default/Cpeprovisioning/PDF_MAIN.php @@ -2,7 +2,7 @@ $maxLength = max(mb_strlen($firstline ?? ''), mb_strlen($secondline ?? ''), mb_strlen($thirdline ?? '')); $fontSize = '13px'; -if ($maxLength <= 11) $fontSize = '28px'; +if ($maxLength <= 11) $fontSize = '20px'; elseif ($maxLength <= 20) $fontSize = '18px'; elseif ($maxLength <= 45) $fontSize = '16px'; diff --git a/Layout/default/Invoice/Index.php b/Layout/default/Invoice/Index.php index 5f269cc04..f80df6423 100644 --- a/Layout/default/Invoice/Index.php +++ b/Layout/default/Invoice/Index.php @@ -342,7 +342,8 @@ $pagination_entity_name = "Rechnungen"; billing_type == "sepa") ? "SEPA" : "Überweisung"?> billing_delivery == "email") ? "Email" : "Papier"?> - $invoice->id])?>" title="CSV-Download"> + $invoice->id])?>" title="CSV-Download"> + $invoice->id])?>" title="Download EGN"> diff --git a/Layout/default/Linework/Index.php b/Layout/default/Linework/Index.php index c3bea08a8..d96af30e1 100644 --- a/Layout/default/Linework/Index.php +++ b/Layout/default/Linework/Index.php @@ -105,8 +105,8 @@
-
- +
+ +
-
+
- + +
+ +
+ +
diff --git a/Layout/default/ManualInvoice/PDF_FOOTER.html b/Layout/default/ManualInvoice/PDF_FOOTER.html new file mode 100644 index 000000000..0a984b7d6 --- /dev/null +++ b/Layout/default/ManualInvoice/PDF_FOOTER.html @@ -0,0 +1,43 @@ + + + + Xinon Rechnung + + + + + + +
+
+ XINON GmbH | Fladnitz 150 | 8322 Studenzen
+ Tel.: +43 3115 40800 | E-Mail: office@xinon.at
+ UID: ATU68711968 | FN: 416556h | LG: Feldbach
+ IBAN: {{ bank_iban }} | BIC: {{ bank_bic }}
+
+ +
Seite von
+ +
+ + diff --git a/Layout/default/ManualInvoice/PDF_HEADER.html b/Layout/default/ManualInvoice/PDF_HEADER.html new file mode 100644 index 000000000..075f2c9bc --- /dev/null +++ b/Layout/default/ManualInvoice/PDF_HEADER.html @@ -0,0 +1,107 @@ + + + + XINON Invoice Header + + + + + +
+ +
+ Xinon Logo +
+ + + + + + +
+
{{ addressLine_1 }}
+
{{ addressLine_2 }}
+
{{ addressLine_3 }}
+
{{ addressLine_4 }}
+
{{ addressLine_5 }}
+
+ + + + + +
+ QR-Code + + + + + + + + + + + + + + + + + + + {{ leistungszeitraumHtml }} + {{ externeReferenzHtml }} + {{ vatHtml }} +
Kundennummer:{{ customerNumber }}
Verrechnungskonto:{{ billingAccount }}
Rechnungsnummer:{{ invoiceNumber }}
Belegdatum:{{ invoiceDate }}
+
+
+ + +
+ + + diff --git a/Layout/default/ManualInvoice/PDF_MAIN.php b/Layout/default/ManualInvoice/PDF_MAIN.php new file mode 100644 index 000000000..49ef1d468 --- /dev/null +++ b/Layout/default/ManualInvoice/PDF_MAIN.php @@ -0,0 +1,257 @@ +total; +$gross_total = $invoice->total_gross; +$is_credit = $net_total < 0; + +// Check if any position has a discount to conditionally show the discount column +$hasDiscount = false; +foreach($invoice->positions as $p) { + if (($p->discount ?? 0) > 0) { + $hasDiscount = true; + break; + } +} + +$gesamtrabatt = $invoice->gesamtrabatt ?? 0; +$subtotal = 0; +foreach($invoice->positions as $p) { + $subtotal += $p->price_total ?? 0; +} + +// Group positions by position_group +$groupedPositions = []; +$hasGroups = false; +foreach($invoice->positions as $p) { + if (!empty($p->position_group)) { + $hasGroups = true; + } +} + +// If no positions have groups, put all in default (no group header will be shown) +if (!$hasGroups) { + $groupedPositions['_default'] = $invoice->positions; +} else { + foreach($invoice->positions as $p) { + $group = $p->position_group ?? 'Sonstige'; + if (!isset($groupedPositions[$group])) { + $groupedPositions[$group] = []; + } + $groupedPositions[$group][] = $p; + } +} + +$this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]); + +?> + + + + + Rechnung + + + + + + + +
+ +

Ihre Xinon vom invoice_date)?>

+ + einleitender_text ?? ''): ?> +

einleitender_text))?>

+ + + + + + + + + + + + + $positions): + ?> + + + + + + + amount ?? 0, 3, ",", "."); + $unit = htmlspecialchars($p->unit ?? 'Stk.'); + $price = number_format($p->price ?? 0, 2, ",","."); + $discount = $p->discount ?? 0; + $price_total = number_format($p->price_total ?? 0, 2, ",","."); + $price_gross = number_format($p->price_gross ?? 0, 2, ",","."); + $vatrate = number_format($p->vatrate ?? 0, 0, ",","."); + ?> + + "> + + + + + + + + + + 0): ?> + + + + + + + + + + + + + + + $vat_total): ?> + + 0): ?> + + + + + + + + + + + + + +
Leistung / ProduktPreisMengeRabatt %Netto €Ust. %Brutto €
+ +
+ product_name ?? '')?> + product_info) && $p->product_info): ?> +
product_info)?>
+ + matchcode) && $p->matchcode): ?> +
matchcode)?>
+ +
%%
Zwischensumme:
Gesamtrabatt %:-
Gesamt Netto:
USt. %:
Gesamt Brutto:
+ + +
+ tax_text): ?> +

tax_text?>

+ + +

Gutschrift! Bitte nicht überweisen.

+ billing_type == "sepa"): ?> +

BITTE NICHT EINZAHLEN – DER BETRAG WIRD AUTOMATISCH VON IHREM KONTO ABGEBUCHT!

+ +
+

+ Zahlungsinformationen: +

+

+ Bitte überweisen Sie den Rechnungsbetrag bis zum invoice_date))->modify("+14 days")->format("d.m.Y")?> auf folgendes Konto: +

+ + + + +
IBAN:
BIC:
Bank:
+
+

+ Verwendungszweck: invoice_number ?? "VORSCHAU"?> +

+

+ Wichtig: Bitte geben Sie den oben angeführten Verwendungszweck bei der Überweisung an, damit wir Ihre Zahlung eindeutig zuordnen können. +

+
+
+ +
+ + + diff --git a/Layout/default/Network/Form.php b/Layout/default/Network/Form.php index 55051113a..9a0c1d385 100644 --- a/Layout/default/Network/Form.php +++ b/Layout/default/Network/Form.php @@ -1,4 +1,6 @@ + +
@@ -8,7 +10,7 @@

Netzgebiete

@@ -22,54 +24,54 @@
-

id) ? "Netzbereich bearbeiten" : "Neuer Netzbereich"?>

- +

id) ? "Netzbereich bearbeiten" : "Neuer Netzbereich"?>

+ ">
- - - + + " /> +
- + ">
- +
- +
- +
- +
@@ -81,22 +83,22 @@
- +
- +
- +
diff --git a/Layout/default/Order/Form.php b/Layout/default/Order/Form.php index 7b28f713c..a513c694e 100644 --- a/Layout/default/Order/Form.php +++ b/Layout/default/Order/Form.php @@ -1860,7 +1860,7 @@ } - reader.readAsText(selectedFile); + reader.readAsArrayBuffer(selectedFile); }); diff --git a/Layout/default/Order/Index.php b/Layout/default/Order/Index.php index d04ec342e..35974b831 100644 --- a/Layout/default/Order/Index.php +++ b/Layout/default/Order/Index.php @@ -1,9 +1,13 @@ getUrl($Mod,"Index"); $pagination_baseurl_params = ["filter" => $filter]; $pagination_entity_name = "Bestellungen"; - //var_dump($mynetworks); $sorted_networks = []; if(is_array($mynetworks) && count($mynetworks)) { @@ -63,7 +67,7 @@ sections) && count($fnet->sections)): ?> sections as $section): ?> - + @@ -75,57 +79,65 @@
- +
- +
- +
- +
- +
- +
- +
- +
- - + +
- +
+ +
+
- +
+ +
+ + +
@@ -245,7 +257,7 @@ $cpe_config_finished = true; } } - if($hw && $voip_chan && $patched && $cpe_config_finished) { + if($hw && $voip && $patched && $cpe_config_finished) { break; } } @@ -697,7 +709,7 @@ $cpe_config_finished = true; } } - if($hw && $voip_chan && $patched && $cpe_config_finished) { + if($hw && $voip && $patched && $cpe_config_finished) { break; } } diff --git a/Layout/default/Pipework/Index.php b/Layout/default/Pipework/Index.php index f9c463ec6..a87ca5d26 100644 --- a/Layout/default/Pipework/Index.php +++ b/Layout/default/Pipework/Index.php @@ -47,7 +47,7 @@
@@ -60,7 +60,7 @@ sections) && count($fnet->sections)): ?> sections as $section): ?> - + @@ -102,12 +102,17 @@
- +
- + +
+ +
+ +
diff --git a/Layout/default/Preorder/Index.php b/Layout/default/Preorder/Index.php index 7660a9dc1..6a1a44cbb 100644 --- a/Layout/default/Preorder/Index.php +++ b/Layout/default/Preorder/Index.php @@ -856,6 +856,16 @@ $pagination_entity_name = "Vorbestellungen"; +
+ + +
+
@@ -1073,8 +1083,19 @@ $pagination_entity_name = "Vorbestellungen";
is(["preorderfront"]) && !$me->is("preorderreadonly")): ?> - + adb_wohneinheit_id && $preorder->adb_wohneinheit && $preorder->adb_wohneinheit->contact) ? json_decode($preorder->adb_wohneinheit->contact, true) : []; + $contactCount = is_array($contacts) ? count($contacts) : 0; + ?> + + + + + + $preorder->id])?>"> + + isAdmin()): ?> $preorder->id, "filter" => $filter])?>" class="text-danger" onclick="if(!confirm('Vorbestellung wirklich löschen?')) return false;" title="Vorbestellung Löschen">
@@ -1337,18 +1358,30 @@ $pagination_entity_name = "Vorbestellungen"; /* * Globals for map display */ - var borderpoly = []; - adb_netzgebiet): ?> - borderpoly = adb_netzgebiet->borderpoly) ? $campaign->adb_netzgebiet->borderpoly : "[]"?>; - is("Admin")): ?> - borderpoly = []; - true]) as $bp_netz): ?> - borderpoly.push(borderpoly?>); - + var borderpolies = []; + is("Admin")): ?> + true]) as $bp_netz): ?> + borderpolies.push([borderpoly?>]); + + salesclusters) && count($campaign->salesclusters)) { + $adb_networks = $campaign->salesclusters; + } else { + $adb_networks = [$campaign->adb_netzgebiet]; + } + + if(count($adb_networks)): ?> + + borderpoly = borderpoly) ?: "[]"?>; + borderpolies.push(borderpoly); + + var preorderMap; var preorders = []; + var fttxlocations = []; var markers = []; var markerState = true; var mapCenterPos = [, ]; @@ -1419,17 +1452,20 @@ $pagination_entity_name = "Vorbestellungen"; function addMarkers() { - if(borderpoly) { - var border = L.polygon(borderpoly, { - fillColor: 'blue', - weight: 8, - opacity: 0.5, - color: 'violet', //Outline color - fillOpacity: 0.05 - }).addTo(preorderMap); + if(borderpolies) { + borderpolies.forEach(function(borderpoly) { + var border = L.polygon(borderpoly, { + fillColor: 'blue', + weight: 8, + opacity: 0.5, + color: 'violet', //Outline color + fillOpacity: 0.05 + }).addTo(preorderMap); + }); + } - if(!Array.isArray(preorders) | !preorders.length) { + if(!Array.isArray(preorders) || !preorders.length) { return false; } // draw markers and calculate center position @@ -1458,10 +1494,12 @@ $pagination_entity_name = "Vorbestellungen"; icon_name = "industry"; } - var marker_popup_content = ``; + // popup fields const preorder_view_url = `/Index?filter[ucode]=${preorder.ucode}#preorder=${preorder.id}`; + var marker_popup_content = ``; + [ ["PREORDER_URL", preorder_view_url], ["street", preorder.adb_strasse], @@ -1482,8 +1520,10 @@ $pagination_entity_name = "Vorbestellungen"; marker_popup_content = marker_popup_content.replaceAll("{{" + item[0].toUpperCase() + "}}", item[1]); }); + var tooltip_content = preorder.adb_strasse + " " + preorder.adb_hausnummer + "
" + preorder.adb_plz + " " + preorder.adb_ort + "

Execution State: " + preorder.adb_ex_state + console.log(tooltip_content); var icon = L.MakiMarkers.icon({icon: icon_name, color: icon_color, size: "l"}); - var marker = L.marker(gps, {icon: icon}).addTo(preorderMap).bindPopup(marker_popup_content); + var marker = L.marker(gps, {icon: icon}).bindPopup(marker_popup_content).bindTooltip(tooltip_content).addTo(preorderMap); markers[preorder.id] = marker; is("Admin")): ?> @@ -1515,6 +1555,7 @@ $pagination_entity_name = "Vorbestellungen"; //fetch fcps and show on map getFCPs(preorderMap); + addFttxLocations(preorderMap); // calculate center position mapCenterPos = GetCenterFromDegrees(all_coords); @@ -1593,6 +1634,29 @@ $pagination_entity_name = "Vorbestellungen"; }); } + function addFttxLocations(preorderMap) { + fttx_c = { + "gross planning": "grey", + "detailed planning": "yellow", + "plan released": "tomato", + "assigned": "aqua", + "executed": "darkblue", + "documented": "lime", + "canceled": "darkred", + }; + + fttxlocations.forEach(loc => { + if(!loc.gps_lat || !loc.gps_long || !loc.ex_state) return; + + var circle = L.circleMarker([loc.gps_lat, loc.gps_long], { + color: fttx_c[loc.ex_state.toLowerCase()], + fillColor: fttx_c[loc.ex_state.toLowerCase()], + fillOpacity: .8, + radius: 6 + }).bindTooltip(loc.street + "
" + loc.zip + " " + loc.city + "

Execution State: " + loc.ex_state).addTo(preorderMap); + }) + } + function centerMap() { preorderMap.setView(mapCenterPos, 12); } @@ -1603,19 +1667,25 @@ $pagination_entity_name = "Vorbestellungen"; $.post('', { 'do': "getFilteredPreorders", - filter: filter + filter: filter, },function(success) { if(success.status == "OK") { - + changes = false; if(Array.isArray(success.result.preorders)) { preorders = success.result.preorders; + changes = true; + } + if(Array.isArray(success.result.fttxlocations)) + fttxlocations = success.result.fttxlocations; + changes = true; + } + + if(changes) { renderMap(); } - } - }, + }, 'json' ); - } function getFilter() { diff --git a/Layout/default/Preorder/export.csv.php b/Layout/default/Preorder/export.csv.php index 0658bd761..e39f34bab 100644 --- a/Layout/default/Preorder/export.csv.php +++ b/Layout/default/Preorder/export.csv.php @@ -75,8 +75,8 @@ while($data = mysqli_fetch_object($res)): if($data->attributes) { $attribs = json_decode($data->attributes, true); - if($attribs['bep_specified']) $bep = true; - if($attribs['inhouse_cabling_supplied']) $inhouse = true; + if(isset($attribs['bep_specified']) && $attribs['bep_specified']) $bep = true; + if(isset($attribs['inhouse_cabling_supplied']) && $attribs['inhouse_cabling_supplied']) $inhouse = true; } $addon_property = 0; diff --git a/Layout/default/Preordercampaign/Form.php b/Layout/default/Preordercampaign/Form.php index 714d766de..2ad1f965c 100644 --- a/Layout/default/Preordercampaign/Form.php +++ b/Layout/default/Preordercampaign/Form.php @@ -1,4 +1,6 @@ + +
@@ -28,7 +30,7 @@ "> - + "/>
@@ -39,7 +41,7 @@
@@ -49,7 +51,7 @@
+ value="name : "" ?>"/>
@@ -57,7 +59,7 @@
+ name="description">description : "" ?>
@@ -65,7 +67,7 @@
+ value="area : "" ?>"/>
@@ -73,7 +75,7 @@
+ value="homes_total : "" ?>"/>
@@ -81,7 +83,7 @@
"/> + value="from) ? date('d.m.Y', $campaign->from) : "" ?>"/>
@@ -89,7 +91,7 @@
"/> + value="to) ? date('d.m.Y', $campaign->to) : "" ?>"/>
@@ -100,30 +102,31 @@
+ types)) ? $campaign->types : []; ?>
@@ -134,16 +137,16 @@
@@ -155,13 +158,13 @@
@@ -171,6 +174,10 @@
+ salesclusters)) ? $campaign->salesclusters : []; ?> + all_fcp_names)) ? $campaign->all_fcp_names : []; ?> + banned_fcps)) ? $campaign->banned_fcps : []; ?> + required_fields)) ? $campaign->required_fields : []; ?>
@@ -182,7 +189,7 @@ name="adb_netzgebiet_ids[]" id="adb_netzgebiet_ids" multiple="multiple" data-placeholder="Salescluster ..."> - +
@@ -195,8 +202,8 @@
@@ -208,7 +215,7 @@
@@ -221,10 +228,10 @@ Ort:
@@ -238,10 +245,10 @@
@@ -253,10 +260,10 @@ pro Wohneinheit (API):
@@ -270,7 +277,7 @@
+ value="cifurl : "" ?>"/> Customer Installation Feedback (für QR-Code bei Status 145).
Templatevariable {{CIFTOKEN}} wird mit echtem Cif Token ersetzt
@@ -284,7 +291,7 @@ for="cifcableurl">Kabelnachbestell-Url
+ value="cifcableurl : "" ?>"/> Für Begleitschreiben - Status 145
@@ -335,13 +342,15 @@
+ active_operators)) ? $campaign->active_operators : []; ?> + passive_operators)) ? $campaign->passive_operators : []; ?>

Netzbetreiber

Aktivnetzbetreiber

- active_operators as $aop): ?> +
@@ -415,7 +424,7 @@ id="passive_operators" multiple="multiple" data-placeholder="Netzbetreiber wählen ..."> ["netowner", "salespartner"]]) as $operator): ?> - +
@@ -433,7 +442,7 @@
- + " />
@@ -611,8 +620,9 @@ + name="corsorigins">corsorigins) ? implode("\n", $campaign->corsorigins) : "" ?> Hostname der Website, mit oder ohne Protokoll (https://); *. als Wildcard erlaubt (*.domain.com); ein Eintrag pro Zeile @@ -642,7 +652,7 @@
+ id="note">note : "" ?>
@@ -754,8 +764,8 @@ + + + + + diff --git a/Layout/default/VueViews/WarehouseStocktakePWA.php b/Layout/default/VueViews/WarehouseStocktakePWA.php new file mode 100644 index 000000000..9c65502bb --- /dev/null +++ b/Layout/default/VueViews/WarehouseStocktakePWA.php @@ -0,0 +1,935 @@ + + + + + + + Inventur Scanner + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/Layout/default/WarehouseArticle/LABEL.php b/Layout/default/WarehouseArticle/LABEL.php new file mode 100644 index 000000000..4de88a8e9 --- /dev/null +++ b/Layout/default/WarehouseArticle/LABEL.php @@ -0,0 +1,40 @@ + QRCode::OUTPUT_IMAGE_PNG, + 'scale' => 10, + 'quietzoneSize' => 1, +]); + +// Generate QR code data - encode article ID for Inventur scanning +$qrData = "WA:" . $articleId . ":" . $articleNumber; +$qrCodeBase64 = (new QRCode($options))->render($qrData); +?> + + + + + + + + + + + +
+ + + +
+
+
+ + diff --git a/Layout/default/WarehouseOffer/PDF_MAIN.php b/Layout/default/WarehouseOffer/PDF_MAIN.php index 365cd1dfc..88888077d 100644 --- a/Layout/default/WarehouseOffer/PDF_MAIN.php +++ b/Layout/default/WarehouseOffer/PDF_MAIN.php @@ -61,7 +61,8 @@ if ($includeTax) { } $formattedOfferDate = date("d.m.Y", $offerDate); -$formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate)); +$validityDays = isset($validity) ? (int)$validity : 14; +$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $offerDate)); ?> @@ -116,7 +117,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate)); - + @@ -173,7 +174,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate)); - + diff --git a/Layout/default/Workflow/items/color.php b/Layout/default/Workflow/items/color.php index 094803bf5..619de4a03 100644 --- a/Layout/default/Workflow/items/color.php +++ b/Layout/default/Workflow/items/color.php @@ -11,7 +11,7 @@ if(preg_match('/^(.+)-1R$/', $color_name, $cmatch)) { " : "", + "{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "" : "", + "{{ vatHtml }}" => ($invoice->uid ?? '') ? "" : "", + "{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2)) + ]; + + $headerHtml = str_replace(array_keys($replacements), array_values($replacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html")); + $headerFile = BASEDIR . "/var/temp/manualinvoice_header-" . date("U") . "-" . rand(1000, 9999) . ".html"; + file_put_contents($headerFile, $headerHtml); + + $footerReplacements = [ + "{{ bank_iban }}" => TT_INVOICE_BANK_IBAN_FORMATTED, + "{{ bank_bic }}" => TT_INVOICE_BANK_BIC, + "{{ bank_bank }}" => TT_INVOICE_BANK_BANK, + "{{ bank_owner }}" => TT_INVOICE_BANK_OWNER + ]; + $footerHtml = str_replace(array_keys($footerReplacements), array_values($footerReplacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_FOOTER.html")); + $footerFile = BASEDIR . "/var/temp/manualinvoice_footer-" . date("U") . "-" . rand(1000, 9999) . ".html"; + file_put_contents($footerFile, $footerHtml); + + $pdf = new PdfForm("ManualInvoice/PDF_MAIN", $pdf_vars); + $filename = $pdf->render("--header-html $headerFile --footer-html $footerFile"); + + if ($returnFilename === true) return $filename; + + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="' . ($invoice->invoice_number ?? 'preview') . '.pdf"'); + readfile($filename); + die(); + } + + protected function downloadInvoicePdfAction() { + $id = $this->request->id; + if (!is_numeric($id) || !$id || !($invoice = ManualInvoiceModel::get($id))) { + $this->layout()->setFlash("Rechnung nicht gefunden", "error"); + $this->redirect("ManualInvoice"); + } + + if(!($pdf_filename = $this->createPDFAction(true)) || !file_exists($pdf_filename)) { + $this->layout()->setFlash("PDF-Datei konnte nicht erstellt werden"); + $this->redirect("ManualInvoice"); + } + + header('Content-Type: application/pdf'); + header('Content-disposition: attachment; filename="'.$invoice->invoice_number.'.pdf"'); + header('Content-Length: ' . filesize($pdf_filename)); + readfile($pdf_filename); + exit; + } + + protected function pdfPreviewAction() { + $post = json_decode(file_get_contents('php://input'), true); + $id = $post['id'] ?? null; + + if (!$id || !($invoice = ManualInvoiceModel::get($id))) { + self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']); + return; + } + + // Log PDF preview in journal + $me = new User(); + $me->loadMe(); + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $id, + 'text' => 'PDF Vorschau geöffnet', + 'createBy' => $me->id, + 'create' => time() + ]); + + // Return URL to open in new tab + $url = "?action=ManualInvoice_createPDF&id=" . $id; + self::returnJson(['success' => true, 'url' => $url]); + } + + protected function getInvoiceEmailAction() { + $post = json_decode(file_get_contents('php://input'), true); + $id = $post['id'] ?? null; + + if (!$id || !($invoice = ManualInvoiceModel::get($id))) { + self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'invoice' => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'email' => $invoice->email, + 'customerName' => trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname) + ] + ]); + } + + protected function sendInvoiceEmailAction() { + // Enable error reporting for debugging + error_reporting(E_ALL); + ini_set('display_errors', 1); + ini_set('display_startup_errors', 1); + + $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']); + return; + } + + $invoice = ManualInvoiceModel::get($id); + if (!$invoice) { + self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']); + return; + } + + // Generate PDF + $pdf_filename = $this->createPDFAction(true); + if (!$pdf_filename || !file_exists($pdf_filename)) { + self::returnJson(['success' => false, 'message' => 'PDF konnte nicht erstellt werden']); + return; + } + + $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 .= '
'; + + // Logos + $html .= '
'; + if ($logoToolExists) $html .= 'The Tool'; + if ($logoXinonExists) $html .= 'Xinon'; + $html .= '
'; + + $html .= '

' . htmlspecialchars($subject) . '

'; + $html .= '
'; + $html .= nl2br(htmlspecialchars($bodyText)); + $html .= '
'; + + $html .= '
'; + $html .= 'XINON GmbH | www.xinon.at'; + $html .= '
'; + + $mail = new PHPMailer(true); + try { + // Server settings + $mail->isSMTP(); + $mail->Host = TT_PIPEWORK_SMTP_HOST; + $mail->SMTPAuth = true; + $mail->Username = TT_PIPEWORK_SMTP_USER; + $mail->Password = TT_PIPEWORK_SMTP_PASS; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + $mail->Encoding = 'base64'; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Port = 587; + + // Logos + if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); + if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); + + $mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice'); + $mail->setFrom('thetool@xinon.at', 'XINON TheTool'); + + $customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname); + $mail->addAddress($recipientEmail, $customerName); + $mail->Subject = $subject; + $mail->isHTML(true); + $mail->Body = $html; + $mail->AltBody = strip_tags($bodyText); + + $mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf'); + + $mail->send(); + + // Update invoice status + $invoice->status = 'gesendet'; + $invoice->save(); + + // Add Journal Entry + $me = new User(); + $me->loadMe(); + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $id, + 'text' => "Rechnung per E-Mail an $recipientEmail gesendet.", + 'statusChange' => 'gesendet', + 'createBy' => $me->id, + 'create' => time() + ]); + + self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet an ' . $recipientEmail]); + } catch (Exception $e) { + self::returnJson(['success' => false, 'message' => 'E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo]); + } + } + + protected function downloadInvoiceAction() { + $post = json_decode(file_get_contents('php://input'), true); + $id = $post['id'] ?? null; + + if (!$id || !($invoice = ManualInvoiceModel::get($id))) { + self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']); + return; + } + + $me = new User(); + $me->loadMe(); + + // Log download in journal + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $id, + 'text' => 'Rechnung heruntergeladen', + 'createBy' => $me->id, + 'create' => time() + ]); + + $downloadUrl = "?action=ManualInvoice_downloadInvoicePdf&id=" . $id; + self::returnJson([ + 'success' => true, + 'url' => $downloadUrl + ]); + } + + protected function beforeCreate(&$data): bool { + if (isset($data['positions']) && is_array($data['positions'])) { + $this->tempPositions = $data['positions']; + unset($data['positions']); + } + + $me = new User(); + $me->loadMe(); + + // Convert invoice_date from string to timestamp if needed + if (isset($data['invoice_date']) && is_string($data['invoice_date'])) { + $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); + + return true; + } + + protected function afterCreate($data) { + $this->savePositions($data['id']); + $this->recalculateTotals($data['id']); + + // Log creation in journal + $me = new User(); + $me->loadMe(); + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $data['id'], + 'text' => 'Rechnung erstellt', + 'statusChange' => 'erstellt', + 'createBy' => $me->id, + 'create' => time() + ]); + } + + protected function beforeUpdate(&$data): bool { + if (isset($data['positions']) && is_array($data['positions'])) { + $this->tempPositions = $data['positions']; + 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; + } + + // Convert invoice_date from string to timestamp if needed + if (isset($data['invoice_date']) && is_string($data['invoice_date'])) { + $data['invoice_date'] = strtotime($data['invoice_date']); + } + + $me = new User(); + $me->loadMe(); + $data['edit_by'] = $me->id; + $data['edit'] = time(); + + return true; + } + + protected function afterUpdate($data) { + $existingPositions = ManualInvoicepositionModel::search(['manualinvoice_id' => $data['id']]); + foreach ($existingPositions as $pos) ManualInvoicepositionModel::delete($pos->id); + + $this->savePositions($data['id']); + $this->recalculateTotals($data['id']); + + // Log update in journal + $me = new User(); + $me->loadMe(); + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $data['id'], + 'text' => 'Rechnung aktualisiert', + 'createBy' => $me->id, + 'create' => time() + ]); + } + + private function savePositions($invoiceId) { + if (empty($this->tempPositions)) return; + + $me = new User(); + $me->loadMe(); + + foreach ($this->tempPositions as $position) { + // Skip empty positions + if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue; + + // Map _group to position_group + $groupName = $position['_group'] ?? null; + unset($position['_group']); + + ManualInvoicepositionModel::create(array_merge([ + 'manualinvoice_id' => $invoiceId, + 'position_group' => $groupName, + 'unit' => 'Stk.', + 'discount' => 0, + 'create_by' => $me->id, + 'edit_by' => $me->id, + 'create' => time(), + 'edit' => time() + ], $position)); + } + $this->tempPositions = []; + } + + protected function recalculateTotals($invoiceId) { + if (!($invoice = ManualInvoiceModel::get($invoiceId))) return; + + $positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]); + $subtotal = array_sum(array_column($positions, 'price_total')); + + // 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); + $grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100); + } + + $invoice->total = $netTotal; + $invoice->total_gross = $grossTotal; + $invoice->save(); + } + + protected function getAction() { + $filter = $this->postData['filters'] ?? []; + $order = $this->postData['order']['key'] ? $this->postData['order'] : ($this->defaultOrder ?? ['key' => null, 'order' => 'ASC']); + $page = $this->postData['pagination']['page'] ?? 1; + $perPage = $this->postData['pagination']['per_page'] ?? 10; + + $rows = ManualInvoiceModel::getAll($filter, $perPage, ($page - 1) * $perPage, $order); + $filteredAvailable = ManualInvoiceModel::count($filter); + $totalRows = ManualInvoiceModel::count(); + + foreach ($rows as &$row) { + $row->customerName = trim(($row->company ?: '') . ' ' . $row->firstname . ' ' . $row->lastname); + $row->positions = array_map([$this, 'formatPosition'], $row->getProperty('positions')); + } + + self::returnJson([ + "rows" => $rows, + "autoCompleteData" => [], + "pagination" => [ + "page" => $page, + "total_pages" => ceil($filteredAvailable / $perPage), + "per_page" => $perPage, + "filtered_available" => intval($filteredAvailable), + "total_rows" => intval($totalRows) + ] + ]); + } + + protected function getByIdAction() { + $id = $_GET['id'] ?? null; + if (!$id || !is_numeric($id)) { + http_response_code(500); + self::returnJson(['success' => false, 'message' => 'No ID provided.']); + die(); + } + + if (!($invoice = ManualInvoiceModel::get($id))) { + http_response_code(404); + self::returnJson(['success' => false, 'message' => 'Invoice not found.']); + die(); + } + + $data = (array) $invoice; + $data['positions'] = array_map([$this, 'formatPosition'], $invoice->getProperty('positions')); + + self::returnJson($data); + } + + private function formatPosition($pos) { + return [ + '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, + 'product_info' => $pos->product_info, + 'amount' => $pos->amount, + 'unit' => $pos->unit ?? 'Stk.', + 'price' => $pos->price, + 'discount' => $pos->discount ?? 0, + 'price_total' => $pos->price_total, + 'price_gross' => $pos->price_gross, + 'vatrate' => $pos->vatrate, + 'fibu_cost_account' => $pos->fibu_cost_account, + 'fibu_cost_account_legacy' => $pos->fibu_cost_account_legacy, + 'fibu_taxcode' => $pos->fibu_taxcode, + 'options' => $pos->options + ]; + } + + protected function customRowsHandler($rows) { + foreach ($rows as &$row) { + $row->customerName = trim(($row->company ?: '') . ' ' . $row->firstname . ' ' . $row->lastname); + } + return $rows; + } + + protected function generateSepaQRCode($paymentReference, $amount) { + $xinonIBAN = TT_INVOICE_BANK_IBAN; + $xinonBIC = TT_INVOICE_BANK_BIC; + $xinonOwner = TT_INVOICE_BANK_OWNER; + $epc = "BCD\n001\n1\nSCT\n$xinonBIC\n$xinonOwner\n$xinonIBAN\nEUR$amount\nXINO\n$paymentReference\n\nXINON GmbH"; + return (new \chillerlan\QRCode\QRCode)->render($epc); + } + + protected function getInvoiceForGutschriftAction() { + if (!($id = $_GET['id'] ?? null) || !($invoice = ManualInvoiceModel::get($id))) { + self::returnJson(['success' => false, 'message' => 'Invoice not found']); + } + + if ($invoice->total < 0) { + self::returnJson(['success' => false, 'message' => 'Kann keine Gutschrift für eine Gutschrift erstellen']); + } + + $positions = $invoice->getProperty('positions'); + $existingCredits = ManualInvoiceModel::getAll(['credit_for_invoice_id' => $id]); + $creditedAmounts = []; + + foreach ($existingCredits as $credit) { + foreach ($credit->getProperty('positions') as $creditPos) { + $key = $creditPos->product_id . '_' . $creditPos->matchcode; + $creditedAmounts[$key] = ($creditedAmounts[$key] ?? 0) + abs($creditPos->amount); + } + } + + $availablePositions = []; + foreach ($positions as $pos) { + $key = $pos->product_id . '_' . $pos->matchcode; + $availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0); + if ($availableAmount > 0) { + $availablePositions[] = [ + 'id' => $pos->id, + 'product_name' => $pos->product_name, + 'product_info' => $pos->product_info, + 'original_amount' => $pos->amount, + 'credited_amount' => $creditedAmounts[$key] ?? 0, + 'available_amount' => $availableAmount, + 'unit' => $pos->unit ?? 'Stk.', + 'price' => $pos->price, + 'vatrate' => $pos->vatrate, + 'product_id' => $pos->product_id, + 'matchcode' => $pos->matchcode, + 'fibu_cost_account' => $pos->fibu_cost_account, + 'fibu_taxcode' => $pos->fibu_taxcode + ]; + } + } + + self::returnJson([ + 'success' => true, + 'invoice' => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'customer_name' => trim(($invoice->company ?? '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname), + 'positions' => $availablePositions + ] + ]); + } + + protected function createGutschriftAction() { + $post = json_decode(file_get_contents('php://input'), true); + $originalInvoiceId = $post['original_invoice_id'] ?? null; + $positions = $post['positions'] ?? []; + + if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) { + self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']); + } + + $me = new User(); + $me->loadMe(); + + $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, + 'owner_id' => $originalInvoice->owner_id, + 'billingaddress_id' => $originalInvoice->billingaddress_id, + 'customer_number' => $originalInvoice->customer_number, + 'fibu_account_number' => $originalInvoice->fibu_account_number, + 'fibu_payment_due' => $originalInvoice->fibu_payment_due, + 'fibu_payment_skonto' => $originalInvoice->fibu_payment_skonto, + 'fibu_payment_skonto_rate' => $originalInvoice->fibu_payment_skonto_rate, + 'sepa_date' => $originalInvoice->sepa_date, + 'sepa_id' => $originalInvoice->sepa_id, + 'sepa_last_date' => $originalInvoice->sepa_last_date, + 'fibu_cost_area' => $originalInvoice->fibu_cost_area, + 'fibu_cost_account' => $originalInvoice->fibu_cost_account, + 'fibu_cost_account_legacy' => $originalInvoice->fibu_cost_account_legacy, + 'fibu_taxcode' => $originalInvoice->fibu_taxcode, + 'tax_text' => $originalInvoice->tax_text, + 'company' => $originalInvoice->company, + 'firstname' => $originalInvoice->firstname, + 'lastname' => $originalInvoice->lastname, + 'street' => $originalInvoice->street, + 'zip' => $originalInvoice->zip, + 'city' => $originalInvoice->city, + 'country' => $originalInvoice->country, + '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, + 'bank_account_bic' => $originalInvoice->bank_account_bic, + 'total' => 0, + 'total_gross' => 0, + 'vatgroup_id' => $originalInvoice->vatgroup_id, + 'credit_for_invoice_id' => $originalInvoiceId, + 'status' => 'erstellt', + 'create' => time(), + 'edit' => time(), + 'create_by' => $me->id, + 'edit_by' => $me->id + ]; + + if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) { + self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']); + } + + foreach ($positions as $pos) { + $priceTotal = (-abs($pos['amount'])) * $pos['price']; + ManualInvoicepositionModel::create([ + 'manualinvoice_id' => $creditInvoiceId, + 'position_group' => null, + 'product_id' => $pos['product_id'], + 'product_name' => $pos['product_name'], + 'product_info' => $pos['product_info'] ?? '', + 'amount' => -abs($pos['amount']), + 'unit' => $pos['unit'] ?? 'Stk.', + 'price' => $pos['price'], + 'discount' => 0, + 'vatrate' => $pos['vatrate'], + 'price_total' => $priceTotal, + 'price_gross' => $priceTotal * (1 + $pos['vatrate'] / 100), + '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(), + 'edit' => time() + ]); + } + + $this->recalculateTotals($creditInvoiceId); + + self::returnJson(['success' => true, 'message' => 'Gutschrift erfolgreich erstellt', 'credit_invoice_id' => $creditInvoiceId]); + } + + protected function beforeDelete(): bool { + if ($id = $this->request->id) { + $invoice = ManualInvoiceModel::get($id); + if ($invoice && $invoice->status === 'exported') { + $this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden'; + return false; + } + if (ManualInvoiceModel::count(['credit_for_invoice_id' => $id]) > 0) { + $this->infoMessages['delete'] = 'Rechnung kann nicht gelöscht werden, da bereits Gutschriften existieren'; + return false; + } + foreach (ManualInvoicepositionModel::search(['manualinvoice_id' => $id]) as $pos) { + ManualInvoicepositionModel::delete($pos->id); + } + } + return true; + } } \ No newline at end of file diff --git a/application/ManualInvoice/ManualInvoiceModel.php b/application/ManualInvoice/ManualInvoiceModel.php index 3f3eb170d..77408527e 100644 --- a/application/ManualInvoice/ManualInvoiceModel.php +++ b/application/ManualInvoice/ManualInvoiceModel.php @@ -1,186 +1,80 @@ 1, 'invoiceNumber' => 'RE-2025-001', 'customerName' => 'Musterfirma GmbH', 'billingAddressId' => 1, - 'invoiceDate' => strtotime('2025-09-11'), 'dueDate' => strtotime('2025-09-25'), 'totalAmount' => 948.00, 'status' => 'paid', - 'positions' => json_encode([ - ['product_name' => 'IT-Support-Stunden', 'product_info' => 'Remote-Hilfe für Mitarbeiter', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 2, 'price' => 120.00, 'vatrate' => 20], - ['product_name' => 'Netzwerk-Switch 24-Port', 'product_info' => 'Modell: XYZ-24G', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 1, 'price' => 550.00, 'vatrate' => 20], - ]), - 'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => '' - ], - [ - 'id' => 2, 'invoiceNumber' => 'RE-2025-002', 'customerName' => 'Beispiel AG', 'billingAddressId' => 2, - 'invoiceDate' => strtotime('2025-09-14'), 'dueDate' => strtotime('2025-09-28'), 'totalAmount' => 720.00, 'status' => 'sent', - 'positions' => json_encode([ - ['product_name' => 'Beratung Digitalisierungsstrategie', 'product_info' => 'Workshop am 05.09.2025', 'start_date' => '2025-09-05', 'end_date' => '2025-09-05', 'amount' => 4, 'price' => 150.00, 'vatrate' => 20], - ]), - 'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => '' - ], - [ - 'id' => 3, 'invoiceNumber' => 'RE-2025-003', 'customerName' => 'John Doe Services', 'billingAddressId' => 3, - 'invoiceDate' => strtotime('2025-09-16'), 'dueDate' => strtotime('2025-09-30'), 'totalAmount' => 912.00, 'status' => 'draft', - 'positions' => json_encode([ - ['product_name' => 'Kabelverlegung LWL', 'product_info' => 'Inhouse-Verkabelung Bürogebäude', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 8, 'price' => 85.00, 'vatrate' => 20], - ['product_name' => 'LWL-Kabel 8 Fasern', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 100, 'price' => 0.80, 'vatrate' => 20], - ]), - 'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => '' - ], - [ - 'id' => 4, 'invoiceNumber' => 'RE-2025-004', 'customerName' => 'Bau & Co KG', 'billingAddressId' => 4, - 'invoiceDate' => strtotime('2025-09-06'), 'dueDate' => strtotime('2025-09-20'), 'totalAmount' => 1890.00, 'status' => 'paid', - 'positions' => json_encode([ - ['product_name' => 'Netzwerk-Grundinstallation Baustelle', 'product_info' => 'Containerdorf Einrichtung', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 1, 'price' => 1200.00, 'vatrate' => 20], - ['product_name' => 'Stunden Elektriker', 'product_info' => 'Anpassungen Verteilerkasten', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 5, 'price' => 75.00, 'vatrate' => 20], - ]), - 'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => '' - ], - [ - 'id' => 5, 'invoiceNumber' => 'RE-2025-005', 'customerName' => 'Creative Solutions', 'billingAddressId' => 5, - 'invoiceDate' => strtotime('2025-09-15'), 'dueDate' => strtotime('2025-09-29'), 'totalAmount' => 1920.00, 'status' => 'sent', - 'positions' => json_encode([ - ['product_name' => 'Web-Entwicklung', 'product_info' => 'Umsetzung Landingpage "Herbst-Aktion"', 'start_date' => '2025-09-01', 'end_date' => '2025-09-12', 'amount' => 10, 'price' => 110.00, 'vatrate' => 20], - ['product_name' => 'Domain-Registrierung (.at)', 'product_info' => 'herbst-aktion.at', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 500.00, 'vatrate' => 20], - ]), - 'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => '' - ], - [ - 'id' => 6, 'invoiceNumber' => 'RE-2025-006', 'customerName' => 'Logistik Express', 'billingAddressId' => 6, - 'invoiceDate' => strtotime('2025-08-28'), 'dueDate' => strtotime('2025-09-11'), 'totalAmount' => 3432.00, 'status' => 'paid', - 'positions' => json_encode([ - ['product_name' => 'Software-Lizenz WMS Pro', 'product_info' => 'Jahreslizenz für 10 User', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 2500.00, 'vatrate' => 20], - ['product_name' => 'Mitarbeiterschulung WMS', 'product_info' => 'Vor Ort am 27.08.2025', 'start_date' => '2025-08-27', 'end_date' => '2025-08-27', 'amount' => 4, 'price' => 90.00, 'vatrate' => 20], - ]), - 'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => '' - ], - [ - 'id' => 7, 'invoiceNumber' => 'RE-2025-007', 'customerName' => 'Gastro Profi', 'billingAddressId' => 7, - 'invoiceDate' => strtotime('2025-09-10'), 'dueDate' => strtotime('2025-09-24'), 'totalAmount' => 2577.60, 'status' => 'draft', - 'positions' => json_encode([ - ['product_name' => 'Kassensystem "GastroTouch"', 'product_info' => '2x Terminal, 1x Bondrucker', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 2, 'price' => 899.00, 'vatrate' => 20], - ['product_name' => 'Installationspauschale', 'product_info' => 'Inkl. Einschulung', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 1, 'price' => 350.00, 'vatrate' => 20], - ]), - 'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => '' - ], - [ - 'id' => 8, 'invoiceNumber' => 'RE-2025-008', 'customerName' => 'Sicherheitsdienst Huber', 'billingAddressId' => 8, - 'invoiceDate' => strtotime('2025-09-01'), 'dueDate' => strtotime('2025-09-15'), 'totalAmount' => 1782.00, 'status' => 'sent', - 'positions' => json_encode([ - ['product_name' => 'IP Kamera 4K Dome', 'product_info' => 'Modell SEC-4K-D', 'start_date' => '2025-08-29', 'end_date' => '2025-08-29', 'amount' => 8, 'price' => 180.00, 'vatrate' => 20], - ['product_name' => 'Monatliche Wartungspauschale', 'product_info' => 'September 2025', 'start_date' => '2025-09-01', 'end_date' => '2025-09-30', 'amount' => 1, 'price' => 45.00, 'vatrate' => 20], - ]), - 'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => '' - ], - [ - 'id' => 9, 'invoiceNumber' => 'RE-2025-009', 'customerName' => 'Praxis Dr. Eder', 'billingAddressId' => 9, - 'invoiceDate' => strtotime('2025-09-12'), 'dueDate' => strtotime('2025-09-26'), 'totalAmount' => 3090.00, 'status' => 'draft', - 'positions' => json_encode([ - ['product_name' => 'Arbeitsstunden IT-Migration', 'product_info' => 'Serverumzug und Client-Setup', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 5, 'price' => 95.00, 'vatrate' => 20], - ['product_name' => 'Server-Hardware "MedServ"', 'product_info' => 'Spez. für Arztpraxen', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 1800.00, 'vatrate' => 20], - ['product_name' => 'Datensicherungslösung "CloudSafe"', 'product_info' => 'Einrichtungspauschale', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 300.00, 'vatrate' => 20], - ]), - 'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => '' - ], - [ - 'id' => 10, 'invoiceNumber' => 'RE-2025-010', 'customerName' => 'Architekturbüro Planweit', 'billingAddressId' => 10, - 'invoiceDate' => strtotime('2025-09-08'), 'dueDate' => strtotime('2025-09-22'), 'totalAmount' => 357.60, 'status' => 'paid', - 'positions' => json_encode([ - ['product_name' => 'Plotter Service', 'product_info' => 'Wartung und Reinigung', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 1, 'price' => 250.00, 'vatrate' => 20], - ['product_name' => 'Netzwerkkabel Cat7', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 40, 'price' => 1.20, 'vatrate' => 20], - ]), - 'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => '' - ], -]; - -return $mockData; -} - - class ManualInvoiceModel extends TTCrudBaseModel { public int $id; - public ?string $invoiceNumber; - public ?int $invoiceDate; - public ?int $dueDate; - public int $billingAddressId; - public ?string $customerName; - public ?float $totalAmount; + public ?string $invoice_number; + public int $invoice_date; + public ?string $leistungszeitraum; + public ?string $einleitender_text; + public ?string $externe_referenz; + public float $gesamtrabatt; + public int $owner_id; + public int $billingaddress_id; + public int $customer_number; + public ?int $fibu_account_number; + public ?int $fibu_payment_due; + public int $fibu_payment_skonto; + public int $fibu_payment_skonto_rate; + public ?string $sepa_date; + public ?string $sepa_id; + public ?string $sepa_last_date; + public ?string $fibu_cost_area; + public ?int $fibu_cost_account; + public ?int $fibu_cost_account_legacy; + public ?int $fibu_taxcode; + public ?string $tax_text; + public ?string $company; + public ?string $firstname; + public ?string $lastname; + public string $street; + public string $zip; + public string $city; + public ?string $country; + 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; + public ?string $bank_account_bic; + public float $total; + public float $total_gross; + public int $vatgroup_id; + public ?int $bmd_export_date; + public ?int $date_delivered; public string $status; - public string $positions; - public string $closingText; - public string $taxText; + public ?int $credit_for_invoice_id; + public int $create_by; + public int $edit_by; + public int $create; + public int $edit; - private static function applyFilter(array $data, array $filter): array { - if (empty($filter)) { - return $data; - } - return array_filter($data, function ($row) use ($filter) { - foreach ($filter as $key => $value) { - if (!isset($row[$key]) || empty($value)) { - continue; - } - if (is_array($value)) { // Handle date ranges - if (isset($value['from']) && $row[$key] < $value['from']) return false; - if (isset($value['to']) && $row[$key] > $value['to']) return false; - } else if (is_array($row[$key])) { - if (!in_array($value, $row[$key])) return false; - } else if (stripos($row[$key], $value) === false) { - return false; - } - } - return true; - }); - } + public static function getNextInvoiceNumber() { + $invoices = parent::getAll(['invoice_number' => '!NULL'], 1, 0, ['key' => 'invoice_number', 'order' => 'DESC']); + $last = $invoices[0]->invoice_number ?? null; + $year = date("Y"); - public static function getAll($filter = [], $limit = null, $offset = 0, $order = ["key" => null]): array - - { - $mockData = getMockData(); - $filteredData = self::applyFilter($mockData, $filter); - - if ($order['key'] !== null) { - usort($filteredData, function ($a, $b) use ($order) { - if ($a[$order['key']] == $b[$order['key']]) return 0; - if ($order['order'] === 'ASC') { - return $a[$order['key']] < $b[$order['key']] ? -1 : 1; - } else { - return $a[$order['key']] > $b[$order['key']] ? -1 : 1; - } - }); + if ($last && preg_match('/^RN(\d+)-C(\d+)$/', $last, $m)) { + $num = ($m[1] == $year) ? $m[2] + 1 : 1; + } else { + $num = 1; } - if ($limit !== null) { - return array_slice($filteredData, $offset, $limit); + return sprintf("RN%s-C%06d", $year, $num); + } + + public function getProperty($name) { + if (!$this->id) return null; + + switch ($name) { + case 'positions': return ManualInvoicepositionModel::search(['manualinvoice_id' => $this->id]); + case 'creator': return $this->create_by ? new User($this->create_by) : null; + case 'editor': return $this->edit_by ? new User($this->edit_by) : null; + default: + $classname = ucfirst($name); + $idfield = $name . '_id'; + return (property_exists($this, $idfield) && class_exists($classname)) ? new $classname($this->$idfield) : null; } - return $filteredData; - } - - public static function count($filter = []): int { - $mockData = getMockData(); - return count(self::applyFilter($mockData, $filter)); - } - - public static function get($id) { - $mockData = getMockData(); - foreach ($mockData as $row) - if ($row['id'] == $id) - return new self($row); - return null; - } - - public static function create($data) { - error_log("ManualInvoiceModel::create called with: " . json_encode($data)); - return time(); - } - - public static function update($data) { - error_log("ManualInvoiceModel::update called with: " . json_encode($data)); - return 1; - } - - public static function delete($id) { - error_log("ManualInvoiceModel::delete called with ID: " . $id); - return 1; } } \ No newline at end of file diff --git a/application/ManualInvoiceJournal/ManualInvoiceJournalModel.php b/application/ManualInvoiceJournal/ManualInvoiceJournalModel.php new file mode 100644 index 000000000..bb483b8b2 --- /dev/null +++ b/application/ManualInvoiceJournal/ManualInvoiceJournalModel.php @@ -0,0 +1,12 @@ +escape($filter['product_name']); + if($product_name) { + $where .= " AND `Product`.name LIKE '%$product_name%'"; + } + } if(array_key_exists("product_id", $filter)) { $product_id = $filter['product_id']; diff --git a/application/Pipework/PipeworkController.php b/application/Pipework/PipeworkController.php index fdd713a1b..3128d4028 100644 --- a/application/Pipework/PipeworkController.php +++ b/application/Pipework/PipeworkController.php @@ -113,9 +113,33 @@ class PipeworkController extends mfBaseController { $this->log->debug("is pipeworker"); $building_search["pipeworker_id"] = ($this->me->address->parent_id) ? $this->me->address->parent_id : $this->me->address_id; } - - $pagination['maxItems'] = BuildingModel::count($building_search); - foreach(BuildingModel::search($building_search, $pagination) as $b) { + + // Store ap_name filter separately for post-processing + $ap_name_filter = null; + if(array_key_exists('ap_name', $building_search) && $building_search['ap_name']) { + $ap_name_filter = $building_search['ap_name']; + unset($building_search['ap_name']); // Remove from search as it's a workflow value + } + + if($ap_name_filter) { + $all_buildings = BuildingModel::search($building_search, false); + $filtered_buildings = []; + + foreach($all_buildings as $b) { + $ap_name = $b->getWorkflowvalue('ist_anschlusspunkt_name') ?: $b->getWorkflowvalue('anschlusspunkt_name'); + if($ap_name && stripos($ap_name, $ap_name_filter) !== false) { + $filtered_buildings[] = $b; + } + } + + $pagination['maxItems'] = count($filtered_buildings); + $buildings = array_slice($filtered_buildings, $pagination['start'], $pagination['count']); + } else { + $pagination['maxItems'] = BuildingModel::count($building_search); + $buildings = BuildingModel::search($building_search, $pagination); + } + + foreach($buildings as $b) { if(!array_key_exists($b->network->name, $networks)) { $networks[$b->network->name] = []; } diff --git a/application/Preorder/Preorder.php b/application/Preorder/Preorder.php index ac8c92016..fffeb2fbe 100644 --- a/application/Preorder/Preorder.php +++ b/application/Preorder/Preorder.php @@ -734,15 +734,22 @@ class Preorder extends mfBaseModel { // get start of ctag range $first_ctag = $search_ctag - ($search_ctag % $ctags_per_home); + $last_ctag = $first_ctag + $ctags_per_home - 1; + $mgmt_ctag = null; $ctag_range = []; - for($i = $first_ctag; $i < $first_ctag + $ctags_per_home; $i++) { + for($i = $first_ctag; $i <= $last_ctag; $i++) { if(!PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name])) { + if($i == $last_ctag) { + // mgmt ctag should be the last in range + $mgmt_ctag = $i; + continue; + } $ctag_range[] = $i; - } + } } - return $ctag_range; + return [$ctag_range, $mgmt_ctag]; } public function getNextFreeCtags() { @@ -790,7 +797,9 @@ class Preorder extends mfBaseModel { $new_ctags[] = $i; } - return $new_ctags; + $mgmt_ctag = array_pop($new_ctags); + + return [$new_ctags, $mgmt_ctag]; } public function setOrCreateOaid($oaid_attributes = false) { diff --git a/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php index 087affdc5..2379756eb 100644 --- a/application/Preorder/PreorderController.php +++ b/application/Preorder/PreorderController.php @@ -791,6 +791,10 @@ class PreorderController extends mfBaseController { $qs = http_build_query($qs); } + if(!$this->me->isAdmin()) { + $this->redirect("Preorder", "Index", $qs); + } + $id = $this->request->id; if(!is_numeric($id) || $id < 1) { $this->layout()->setFlash("Vorbestellung nicht gefunden!", "error"); @@ -988,7 +992,9 @@ class PreorderController extends mfBaseController { foreach($my_networks as $network) { if($network->adb_netzgebiet_id && !in_array($network->id, $netzgebiet_ids)) { $netzgebiet_ids[] = $network->id; - $my_adb_networks[$network->adb_netzgebiet_id] = new ADBNetzgebiet($network->adb_netzgebiet_id); + $adb_network = new ADBNetzgebiet($network->adb_netzgebiet_id); + if(!$adb_network->isLoaded()) continue; + $my_adb_networks[$network->adb_netzgebiet_id] = $adb_network; } } @@ -997,34 +1003,38 @@ class PreorderController extends mfBaseController { $campaign_ids = []; foreach(PreordercampaignModel::search(["network_id" => $netzgebiet_ids]) as $campaign) { - echo "campaign: ".$campaign->id."
"; if(!in_array($campaign->id, $campaign_ids)) { $campaign_ids[] = $campaign->id; } } - if(array_key_exists("preordercampaign_id", $filter) && in_array($filter['preordercampaign_id'], $campaign_ids)) { - $preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id']; + if($this->me->is("Admin")) { + if(array_key_exists("preordercampaign_id", $filter) && $filter['preordercampaign_id']) { + $preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id']; + } } else { - $preorder_filter["preordercampaign_id"] = $campaign_ids; - } + if(array_key_exists("preordercampaign_id", $filter) && in_array($filter['preordercampaign_id'], $campaign_ids)) { + $preorder_filter["preordercampaign_id"] = $filter['preordercampaign_id']; + } else { + $preorder_filter["preordercampaign_id"] = $campaign_ids; + } - if($preorder_filter['preordercampaign_id'] && in_array($preorder_filter['preordercampaign_id'], $campaign_ids)) { - $campaign_id = $preorder_filter['preordercampaign_id']; - if(is_numeric($campaign_id) && $campaign_id > 0) { - $campaign = new Preordercampaign($campaign_id); - $this->layout()->set("campaign", $campaign); + if($preorder_filter['preordercampaign_id'] && in_array($preorder_filter['preordercampaign_id'], $campaign_ids)) { + $campaign_id = $preorder_filter['preordercampaign_id']; + if(is_numeric($campaign_id) && $campaign_id > 0) { + $campaign = new Preordercampaign($campaign_id); + $this->layout()->set("campaign", $campaign); - if($campaign->network->owner_id != $this->me->address_id && NetworkAddressModel::getFirst(["network_id" => $campaign->network_id, "address_id" => $this->me->address_id, "addresstype" => "salespartner"])) { + if($campaign->network->owner_id != $this->me->address_id && NetworkAddressModel::getFirst(["network_id" => $campaign->network_id, "address_id" => $this->me->address_id, "addresstype" => "salespartner"])) { + $preorder_filter["operator_id"] = $this->me->address_id; + } + } + } else { + $preorder_filter['preordercampaign_id'] = $campaign_ids; + if(NetworkAddressModel::getFirst(["address_id" => $this->me->address_id, "addresstype" => "salespartner"])) { $preorder_filter["operator_id"] = $this->me->address_id; } } - } else { - $preorder_filter['preordercampaign_id'] = $campaign_ids; - if(NetworkAddressModel::getFirst(["address_id" => $this->me->address_id, "addresstype" => "salespartner"])) { - $preorder_filter["operator_id"] = $this->me->address_id; - } - } //$preorder_filter['layout()->setTemplate("Preorder/export.csv"); $this->layout()->set("res", $res); + $this->layout()->set("no_filename", false); } protected function apiAction() { @@ -1479,10 +1490,19 @@ class PreorderController extends mfBaseController { private function getFilteredPreordersApi() { $preorders = []; + $fttxlocations = []; + $filter = []; + $type = ["preorders", "fttx"]; if(is_array($this->request->filter)) { $filter = $this->request->filter; } + if($this->request->type == "preorders") { + $type = ["preorders"]; + } + if($this->request->type == "fttxlocations") { + $type = ["fttx"]; + } $filter = $this->getPreparedFilter($filter); @@ -1545,42 +1565,106 @@ class PreorderController extends mfBaseController { } } - if(!$filter['preordercampaign_id']) $filter['preordercampaign_id'] = 0; + if(!array_key_exists("preordercampaign_id", $filter) || !$filter['preordercampaign_id']) $filter['preordercampaign_id'] = 0; - //var_dump($filter);exit; - $results = PreorderModel::searchActive($filter); - foreach($results as $preorder) { - //$this->log->debug("building status: ".print_r($building->status,true)); - $data = clone($preorder->data); - unset($data->workorder_export_data); - unset($data->submit_request); - unset($data->addon_services); - $data->id = $preorder->id; - $data->status_code = $preorder->status->code; - $data->adrcd = $preorder->adb_hausnummer->adrcd; - $data->extref = $preorder->adb_hausnummer->extref; - $data->adb_strasse = $preorder->adb_hausnummer->strasse->name; - $data->adb_hausnummer = $preorder->adb_hausnummer->hausnummer; - $data->adb_plz = $preorder->adb_hausnummer->plz->plz; - $data->adb_ort = $preorder->adb_hausnummer->ortschaft->name; - $data->adb_gemeinde = $preorder->adb_hausnummer->strasse->gemeinde->name; - $data->gps_lat = $preorder->adb_hausnummer->gps_lat; - $data->gps_long = $preorder->adb_hausnummer->gps_long; + if(in_array("preorders", $type)) { + $this->log->debug(__METHOD__.": requested preorders"); + //var_dump($filter);exit; + $results = PreorderModel::searchActive($filter); + foreach($results as $preorder) { + //$this->log->debug("building status: ".print_r($building->status,true)); + $data = clone($preorder->data); + unset($data->workorder_export_data); + unset($data->submit_request); + unset($data->addon_services); + $data->id = $preorder->id; + $data->status_code = $preorder->status->code; + $data->adrcd = $preorder->adb_hausnummer->adrcd; + $data->extref = $preorder->adb_hausnummer->extref; + $data->adb_strasse = $preorder->adb_hausnummer->strasse->name; + $data->adb_hausnummer = $preorder->adb_hausnummer->hausnummer; + $data->adb_plz = $preorder->adb_hausnummer->plz->plz; + $data->adb_ort = $preorder->adb_hausnummer->ortschaft->name; + $data->adb_gemeinde = $preorder->adb_hausnummer->strasse->gemeinde->name; + $data->adb_ex_state = $preorder->adb_hausnummer->rimo_ex_state; + $data->adb_op_state = $preorder->adb_hausnummer->rimo_op_state; + $data->gps_lat = $preorder->adb_hausnummer->gps_lat; + $data->gps_long = $preorder->adb_hausnummer->gps_long; - if($this->me->is("Admin")) { - $data->borderpoint_lat = ($preorder->adb_hausnummer->borderpoint_lat) ? json_decode($preorder->adb_hausnummer->borderpoint_lat) : null; - $data->borderpoint_long = ($preorder->adb_hausnummer->borderpoint_long) ? json_decode($preorder->adb_hausnummer->borderpoint_long) : null; - //$data->trenches = ($preorder->adb_hausnummer->trenches) ? json_decode($preorder->adb_hausnummer->trenches) : null; - $data->home_trench = ($preorder->adb_hausnummer->home_trench) ? json_decode($preorder->adb_hausnummer->home_trench) : null; + if($this->me->is("Admin")) { + $data->borderpoint_lat = ($preorder->adb_hausnummer->borderpoint_lat) ? json_decode($preorder->adb_hausnummer->borderpoint_lat) : null; + $data->borderpoint_long = ($preorder->adb_hausnummer->borderpoint_long) ? json_decode($preorder->adb_hausnummer->borderpoint_long) : null; + //$data->trenches = ($preorder->adb_hausnummer->trenches) ? json_decode($preorder->adb_hausnummer->trenches) : null; + $data->home_trench = ($preorder->adb_hausnummer->home_trench) ? json_decode($preorder->adb_hausnummer->home_trench) : null; + } + + $data->type_label = __($data->type, "preorder"); + $data->connection_type_label = __($data->connection_type, "preorder"); + + $preorders[] = $data; } - - $data->type_label = __($data->type, "preorder"); - $data->connection_type_label = __($data->connection_type, "preorder"); - - $preorders[] = $data; } - return ["preorders" => $preorders]; + if(in_array("fttx", $type)) { + $this->log->debug(__METHOD__.": requested fttxlocations"); + // get all fttp locations in current campaign network with status + + if($filter["preordercampaign_id"]) { + $my_adb_networks = []; + + $campaign = new Preordercampaign($filter["preordercampaign_id"]); + if($campaign->id) { + $salesclusters = $campaign->salesclusters; + if(is_array($salesclusters) && count($salesclusters)) { + foreach($salesclusters as $sc) { + $my_adb_networks[] = new ADBNetzgebiet($sc->id); + } + } else { + $my_adb_networks[] = $campaign->adb_netzgebiet; + } + } + + foreach($my_adb_networks as $adb_network) { + if(!$adb_network->isLoaded()) continue; + + /*foreach(ADBHausnummerModel::search(['netzgebiet_id' => $adb_network->id]) as $hausnummer) { + $loc = []; + $loc["street"] = $hausnummer->strasse->name . " " . $hausnummer->hausnummer; + $loc["zip"] = $hausnummer->plz->plz; + $loc["city"] = $hausnummer->strasse->gemeinde->name; + + $loc["gps_lat"] = $hausnummer->gps_lat; + $loc["gps_long"] = $hausnummer->gps_long; + $loc["ex_state"] = $hausnummer->rimo_ex_state; + $loc["op_state"] = $hausnummer->rimo_op_state; + + $fttxlocations[] = $loc; + }*/ + + $adb = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $sql = "SELECT * FROM view_hausnummer WHERE netzgebiet_id=".$adb_network->id; + $res = $adb->query($sql); + while($data = $adb->fetch_object($res)) { + $loc = []; + $loc["street"] = $data->strasse . " " . $data->hausnummer; + $loc["zip"] = $data->plz; + $loc["city"] = $data->gemeinde; + + $loc["gps_lat"] = $data->gps_lat; + $loc["gps_long"] = $data->gps_long; + $loc["ex_state"] = $data->rimo_ex_state; + $loc["op_state"] = $data->rimo_op_state; + + $fttxlocations[] = $loc; + } + } + } + } + + return [ + "preorders" => $preorders, + "fttxlocations" => $fttxlocations + ]; } private function saveAttributeApi() { diff --git a/application/Preorder/PreorderModel.php b/application/Preorder/PreorderModel.php index a4a5bf6b8..ac5b621b4 100644 --- a/application/Preorder/PreorderModel.php +++ b/application/Preorder/PreorderModel.php @@ -482,6 +482,13 @@ class PreorderModel return self::count($filter); } + /** + * @param $filter + * @param $limit + * @param $returnDBRessource + * @param $returnArray + * @return Preorder[] + */ public static function searchActive($filter = [], $limit = false, $returnDBRessource = false, $returnArray = false) { if (!is_array($filter)) return false; @@ -556,13 +563,13 @@ class PreorderModel mfLoghandler::singleton()->debug($sql); $res = $db->query($sql); + + // hack for Preorder::exportAction + if ($returnDBRessource) { + return $res; + } + if ($db->num_rows($res)) { - - // hack for Preorder::exportAction - if ($returnDBRessource) { - return $res; - } - while ($data = $db->fetch_object($res)) { if ($returnArray) { $items[] = $data; @@ -864,9 +871,9 @@ class PreorderModel if (is_array($tool_building_type) && count($tool_building_type)) { $where .= " AND adb_hausnummer.tool_building_type IN ('" . implode("','", $tool_building_type) . "')"; } else { - $tool_building_type = FronkDB::singleton()->escape($filter['connection_type']); - if ($tool_building_type) { - $where .= " AND adb_hausnummer.tool_building_type like '%$tool_building_type%'"; + $tool_building_type = FronkDB::singleton()->escape($filter['tool_building_type']); + if ($tool_building_type === '0' || $tool_building_type) { + $where .= " AND adb_hausnummer.tool_building_type = $tool_building_type "; } } } @@ -1254,46 +1261,34 @@ class PreorderModel ]; } - public static function countTotalUnits($preorderCampaignId = null) { + public static function countTotalUnits($preorderCampaignId = null, $gemeindeId = null) { $db = FronkDB::singleton(); + $where = ["1=1"]; - // The new WHERE condition is more complex and implemented directly in the main query. - $where = "1=1"; + // Support both array and single campaign ID if ($preorderCampaignId) { - $where .= " AND pc.id = " . (int)$preorderCampaignId; + $campaignIds = is_array($preorderCampaignId) ? array_map('intval', $preorderCampaignId) : [(int)$preorderCampaignId]; + $where[] = "pc.id IN (" . implode(',', $campaignIds) . ")"; } - // This query now implements the conditional logic for counting units. - // A unit is counted if its building type is standard, OR if its type is special AND has an active preorder. - $sql = "SELECT - pc.id AS campaign_id, - - -- Total unit count based on the new logic + if ($gemeindeId) { + $gemeindeIds = is_array($gemeindeId) ? array_map('intval', $gemeindeId) : [(int)$gemeindeId]; + $where[] = "s.gemeinde_id IN (" . implode(',', $gemeindeIds) . ")"; + } + + $whereClause = implode(' AND ', $where); + + $sql = "SELECT COUNT(w.id) AS total_unit_count, - - -- SD unit count (Single Dwelling) - SUM(CASE - WHEN h.tool_building_type IN (0, 1) THEN 1 - ELSE 0 - END) AS total_unit_count_sd, - - -- MD unit count (Multi Dwelling) - SUM(CASE - WHEN h.tool_building_type = 2 THEN 1 - ELSE 0 - END) AS total_unit_count_md, - - -- NEW Not2Connect unit count - SUM(CASE - WHEN h.rimo_op_state = 'Not2Connect' THEN 1 - ELSE 0 - END) AS total_unit_count_not2connect + SUM(CASE WHEN h.tool_building_type IN (0, 1) THEN 1 ELSE 0 END) AS total_unit_count_sd, + SUM(CASE WHEN h.tool_building_type = 2 THEN 1 ELSE 0 END) AS total_unit_count_md, + SUM(CASE WHEN h.rimo_op_state = 'Not2Connect' THEN 1 ELSE 0 END) AS total_unit_count_not2connect FROM `".FRONKDB_DBNAME."`.Preordercampaign pc LEFT JOIN `".FRONKDB_DBNAME."`.PreordercampaignSalescluster pcs ON pc.id = pcs.preordercampaign_id LEFT JOIN `".ADDRESSDB_DBNAME."`.Netzgebiet n ON pcs.salescluster_id = n.id LEFT JOIN `".ADDRESSDB_DBNAME."`.Hausnummer h ON n.id = h.netzgebiet_id + LEFT JOIN `".ADDRESSDB_DBNAME."`.Strasse s ON h.strasse_id = s.id LEFT JOIN `".ADDRESSDB_DBNAME."`.Wohneinheit w ON h.id = w.hausnummer_id - -- Subquery to find all buildings that have at least one active preorder LEFT JOIN ( SELECT p_sub.adb_hausnummer_id FROM `".FRONKDB_DBNAME."`.Preorder p_sub @@ -1301,26 +1296,12 @@ class PreorderModel WHERE p_sub.deleted = 0 AND ps_sub.code NOT IN (20) AND ps_sub.code < 899 GROUP BY p_sub.adb_hausnummer_id ) AS active_preorders ON h.id = active_preorders.adb_hausnummer_id - WHERE - ($where) - AND - ( - -- Condition 1: Include unit if its building rimo_type is NOT one of the special types. - h.rimo_type NOT IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet') - - OR - - -- Condition 2: OR if the rimo_type IS special (or NULL), include it ONLY IF an active preorder exists for the building. - ( - (h.rimo_type IS NULL OR h.rimo_type IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet')) - AND active_preorders.adb_hausnummer_id IS NOT NULL - ) - ) - GROUP BY pc.id"; + WHERE ($whereClause) + AND (h.rimo_type NOT IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet') + OR ((h.rimo_type IS NULL OR h.rimo_type IN ('greenfield', 'other', 'transmitting station', 'transformer station', 'outdoor cabinet')) + AND active_preorders.adb_hausnummer_id IS NOT NULL))"; - $queryStart = microtime(true); $res = $db->query($sql); - mfLoghandler::singleton()->debug("[Query took: ".(microtime(true) - $queryStart)." seconds] " . $sql); if ($db->num_rows($res)) { $data = $db->fetch_object($res); @@ -1328,16 +1309,11 @@ class PreorderModel 'total_unit_count' => (int)$data->total_unit_count, 'total_unit_count_sd' => (int)$data->total_unit_count_sd, 'total_unit_count_md' => (int)$data->total_unit_count_md, - 'total_unit_count_not2connect' => (int)$data->total_unit_count_not2connect // New return value + 'total_unit_count_not2connect' => (int)$data->total_unit_count_not2connect ]; } - return [ - 'total_unit_count' => 0, - 'total_unit_count_sd' => 0, - 'total_unit_count_md' => 0, - 'total_unit_count_not2connect' => 0 - ]; + return ['total_unit_count' => 0, 'total_unit_count_sd' => 0, 'total_unit_count_md' => 0, 'total_unit_count_not2connect' => 0]; } public static function countHistoryStatus($filter = [], $status_code = null) { @@ -1415,7 +1391,7 @@ ORDER BY } public static function getPreorderRimoTypeData(int $campaignId): array { - $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $db = FronkDB::singleton(); $fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool'; $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; $safeCampaignId = (int)$campaignId; @@ -1423,6 +1399,23 @@ ORDER BY // add time debug $startTime = microtime(true); + $campaign = PreordercampaignModel::getFirst(['id' => $safeCampaignId]); + $faultyHausnummerIds = []; + if ($campaign && !empty($campaign->rimo_type_map_faults)) { + $faults = json_decode($campaign->rimo_type_map_faults, true); + foreach ($faults as $fault) { + if (empty($fault['done'])) { + $faultyHausnummerIds[] = (int)$fault['hausnummer_id']; + } + } + } + + $visibilityCondition = "AND (h.visibility IS NULL OR h.visibility != 'private')"; + if (!empty($faultyHausnummerIds)) { + $ids = implode(',', $faultyHausnummerIds); + $visibilityCondition = "AND ((h.visibility IS NULL OR h.visibility != 'private') OR h.id IN ({$ids}))"; + } + $sql = " SELECT h.id AS hausnummer_id, h.gps_lat, h.gps_long, h.rimo_type, h.rimo_op_state, h.rimo_ex_state, h.hausnummer, @@ -1444,6 +1437,7 @@ ORDER BY JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id WHERE pc.id = {$safeCampaignId} ) AND h.gps_lat IS NOT NULL AND h.gps_long IS NOT NULL + {$visibilityCondition} GROUP BY h.id ORDER BY h.id "; @@ -1460,7 +1454,7 @@ ORDER BY } public static function getPreorderRimoFaultsData(int $campaignId): array { - $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $db = FronkDB::singleton(); $fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool'; $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; $safeCampaignId = (int)$campaignId; diff --git a/application/PreorderBilling/PreorderBillingController.php b/application/PreorderBilling/PreorderBillingController.php index e4b8222f2..232ee4915 100644 --- a/application/PreorderBilling/PreorderBillingController.php +++ b/application/PreorderBilling/PreorderBillingController.php @@ -398,6 +398,11 @@ class PreorderBillingController extends mfBaseController { return true; // already billed } + if($price->price_setup <= 0.01 && $price->price_setup >= 0.00000) { + $this->log->debug(__METHOD__.": Preorder ".$preorder->id." / ".$preorder->oaid." enduser_setup price is 0 so skipping..."); + return true; + } + // search for customer $customer_data = [ "company" => trim($preorder->company), @@ -441,7 +446,7 @@ class PreorderBillingController extends mfBaseController { die("fibu_revenue code not found for preorder ".$preorder->id); } - $change_to_active = PreorderHistoryModel::getFirstStatusChangeTo($preorder->id, 500); + $change_to_active = PreorderHistoryModel::getLastStatusChangeTo($preorder->id, 500); if($change_to_active) { $status_change_date = new DateTime("@".$change_to_active->changed); $billing_data["start_date"] = $status_change_date->format("Y-m-d"); @@ -632,7 +637,14 @@ class PreorderBillingController extends mfBaseController { //var_dump($existing_bill); if(!$existing_bill) { if($netoperator_config["billing-period"] == "quarterly" && $create_date->format("Ymd") > $latest_quarter_bill_date->format("Ymd")) { - $this->log->debug(__METHOD__.": Skipping operator_usage ".$create_date->format("m/Y")." for preorder ".$preorder->id." because Billing date ".$create_date->format("Y-m-d")." is after latest_quarter_bill_date ".$latest_quarter_bill_date->format("Y-m-d")); + // if this preorder was never billed before and activation date is before latest quarterly billing date, we still need to consider earlier months + $any_previous_bill = PreorderBilling::getFirst(["product_id" => $product->id, "preorder_id" => $preorder->id]); + if(!$any_previous_bill && $status_change_date->format("Ym") < $latest_quarter_bill_date->format("Ym")) { + $create_date->modify("-1 months"); + continue; + } + // otherwise if activation was this month, then we need not bill anything now + $this->log->debug(__METHOD__.": Skipping operator_usage blubb ".$create_date->format("m/Y")." for preorder ".$preorder->id." because Billing date ".$create_date->format("Y-m-d")." is after latest_quarter_bill_date ".$latest_quarter_bill_date->format("Y-m-d")." (status 500 change date: ".$status_change_date->format("Y-m-d").")"); return true; } $new_create_date = clone $create_date; diff --git a/application/PreorderCtag/PreorderCtag.php b/application/PreorderCtag/PreorderCtag.php index 62eb0e910..db3f194f0 100644 --- a/application/PreorderCtag/PreorderCtag.php +++ b/application/PreorderCtag/PreorderCtag.php @@ -107,12 +107,12 @@ class PreorderCtag extends mfBaseModel { // add to interface-list - $ros->add("/interface list member", ["interface" => $vlan_name, "list" => CITYCOM_OAN_API_NNI_IFLIST_NAME]); - $this->log->info(__METHOD__.": done => /interface list member add interface=$vlan_name list=".CITYCOM_OAN_API_NNI_IFLIST_NAME); + $ros->add("/interface list member", ["interface" => $vlan_name, "list" => CITYCOM_OAN_NNI_IFLIST_NAME]); + $this->log->info(__METHOD__.": done => /interface list member add interface=$vlan_name list=".CITYCOM_OAN_NNI_IFLIST_NAME); - // add to bridge CITYCOM_OAN_APU_NNI_BRIDGE_NAME - $ros->add("/interface bridge port", ["bridge" => CITYCOM_OAN_APU_NNI_BRIDGE_NAME, "interface" => $vlan_name]); - $this->log->info(__METHOD__.": done => /bridge port add bridge=".CITYCOM_OAN_APU_NNI_BRIDGE_NAME." interface=$vlan_name"); + // add to bridge CITYCOM_OAN_NNI_BRIDGE_NAME + $ros->add("/interface bridge port", ["bridge" => CITYCOM_OAN_NNI_BRIDGE_NAME, "interface" => $vlan_name]); + $this->log->info(__METHOD__.": done => /bridge port add bridge=".CITYCOM_OAN_NNI_BRIDGE_NAME." interface=$vlan_name"); return true; diff --git a/application/PreorderIFrame/PreorderIFrameModel.php b/application/PreorderIFrame/PreorderIFrameModel.php index aa85d31cf..a0c123881 100644 --- a/application/PreorderIFrame/PreorderIFrameModel.php +++ b/application/PreorderIFrame/PreorderIFrameModel.php @@ -10,14 +10,18 @@ class PreorderIFrameModel extends mfBaseModel public function getClusters($frame_referrer): array { $query = " - SELECT n.adb_netzgebiet_id as id, ng.name, pc.id as campaign_id, pc.name as campaign_name - FROM thetool.Preordercampaign pc - JOIN thetool.Network n ON pc.Network_id = n.id - JOIN addressdb.Netzgebiet ng ON n.adb_netzgebiet_id = ng.id - WHERE JSON_SEARCH(pc.iframe_origins, 'one', '" . $this->db->escape($frame_referrer) . "') IS NOT NULL - GROUP BY n.adb_netzgebiet_id, ng.name - ORDER BY ng.name ASC - "; + SELECT + n.adb_netzgebiet_id as id, + ng.name, + GROUP_CONCAT(pc.id SEPARATOR ', ') as campaign_ids, + GROUP_CONCAT(pc.name SEPARATOR ', ') as campaign_names + FROM thetool.Preordercampaign pc + JOIN thetool.Network n ON pc.Network_id = n.id + JOIN addressdb.Netzgebiet ng ON n.adb_netzgebiet_id = ng.id + WHERE JSON_SEARCH(pc.iframe_origins, 'one', '" . $this->db->escape($frame_referrer) . "') IS NOT NULL + GROUP BY n.adb_netzgebiet_id, ng.name + ORDER BY ng.name ASC + "; $res = $this->db->query($query); $clusters = $this->db->fetch_all_assoc($res); @@ -32,124 +36,105 @@ class PreorderIFrameModel extends mfBaseModel public function findCities(array $params): array { - $whereClause = "p.plzstring = " . $this->db->escape($params['zip']); - if (!empty($params['gemeindeId'])) { - $whereClause .= " AND g.id = " . intval($params['gemeindeId']); - } elseif (!empty($params['clusterId'])) { - $whereClause .= " AND gn.netzgebiet_id = " . intval($params['clusterId']); - } else { - return []; // No identifier provided - } + if (empty($params['gemeindeId']) && empty($params['clusterId'])) return []; - $query = " - SELECT DISTINCT o.name - FROM addressdb.Plz p - JOIN addressdb.Ortschaft o ON p.gemeinde_id = o.gemeinde_id - JOIN addressdb.Gemeinde g ON o.gemeinde_id = g.id - LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id - WHERE $whereClause - ORDER BY o.name ASC - "; + $sql = "SELECT DISTINCT o.name FROM addressdb.Plz p + JOIN addressdb.Ortschaft o ON p.gemeinde_id = o.gemeinde_id + JOIN addressdb.Gemeinde g ON o.gemeinde_id = g.id + LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id + WHERE p.plzstring = " . $this->db->escape($params['zip']); - $res = $this->db->query($query); - return array_column($this->db->fetch_all_assoc($res), 'name'); + $cond = !empty($params['gemeindeId']) + ? " AND g.id = " . intval($params['gemeindeId']) + : " AND gn.netzgebiet_id = " . intval($params['clusterId']); + + $rows = $this->db->fetch_all_assoc($this->db->query($sql . $cond)); + + if (empty($rows) && empty($params['gemeindeId'])) + $rows = $this->db->fetch_all_assoc($this->db->query($sql)); + + return array_column($rows, 'name'); } public function findStreets(array $params): array { - $whereClauses = []; - if (!empty($params['gemeindeId'])) { - $whereClauses[] = "g.id = " . intval($params['gemeindeId']); - } elseif (!empty($params['clusterId'])) { - $whereClauses[] = "gn.netzgebiet_id = " . intval($params['clusterId']); - } else { - return []; - } + if (empty($params['gemeindeId']) && empty($params['clusterId'])) return []; - $whereClauses[] = "o.name = '" . $this->db->escape($params['city']) . "'"; - $whereClauses[] = "EXISTS (SELECT 1 FROM addressdb.Plz p WHERE p.plzstring = " . $this->db->escape($params['zip']) . " AND p.gemeinde_id = o.gemeinde_id)"; - $whereString = implode(" AND ", $whereClauses); + $sql = "SELECT DISTINCT s.name + FROM addressdb.Strasse s + JOIN addressdb.Gemeinde g ON s.gemeinde_id = g.id + JOIN addressdb.Ortschaft o ON o.gemeinde_id = g.id + LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id + WHERE o.name = '" . $this->db->escape($params['city']) . "' + AND EXISTS (SELECT 1 FROM addressdb.Plz p WHERE p.plzstring = " . $this->db->escape($params['zip']) . " AND p.gemeinde_id = o.gemeinde_id)"; - $query = " - SELECT DISTINCT s.name - FROM addressdb.Strasse s - JOIN addressdb.Gemeinde g ON s.gemeinde_id = g.id - JOIN addressdb.Ortschaft o ON o.gemeinde_id = g.id - LEFT JOIN addressdb.GemeindeNetzgebiet gn ON g.id = gn.gemeinde_id - WHERE $whereString - ORDER BY s.name ASC - "; + $cond = !empty($params['gemeindeId']) + ? " AND g.id = " . intval($params['gemeindeId']) + : " AND gn.netzgebiet_id = " . intval($params['clusterId']); - $res = $this->db->query($query); - return array_column($this->db->fetch_all_assoc($res), 'name'); + $rows = $this->db->fetch_all_assoc($this->db->query($sql . $cond)); + + // Fallback: If empty result and we were using clusterId, run without the specific ID constraint + if (empty($rows) && empty($params['gemeindeId'])) + $rows = $this->db->fetch_all_assoc($this->db->query($sql)); + + return array_column($rows, 'name'); } - + public function findAddresses(array $params): array { - $whereClauses = [ - "p.plzstring = " . $this->db->escape($params['zip']), - "o.name = '" . $this->db->escape($params['city']) . "'", - "s.name = '" . $this->db->escape($params['street']) . "'", - "h.hausnummer = '" . $this->db->escape($params['housenumber']) . "'", - ]; + if (empty($params['gemeinde_id']) && empty($params['cluster_id'])) return []; - if (!empty($params['gemeinde_id'])) { - $whereClauses[] = "h.gemeinde_id = " . intval($params['gemeinde_id']); - } elseif (!empty($params['cluster_id'])) { - $whereClauses[] = "h.netzgebiet_id = " . intval($params['cluster_id']); - } else { - return []; - } + $sql = "SELECT h.oaid, h.hausnummer, s.name as street, p.plzstring as zip, o.name as city, h.id as hausnummer_id, w.id as wohneinheit_id, + h.stiege, h.unit_count as building_unit_count, h.tool_building_type as building_type, + w.oaid as unit_oaid, w.stock, w.tuer, w.zusatz + FROM addressdb.Hausnummer h + JOIN addressdb.Strasse s ON h.strasse_id = s.id + JOIN addressdb.Plz p ON h.plz_id = p.id + JOIN addressdb.Ortschaft o ON s.gemeinde_id = o.gemeinde_id + LEFT JOIN addressdb.Wohneinheit w ON w.hausnummer_id = h.id + WHERE p.plzstring = " . $this->db->escape($params['zip']) . " + AND o.name = '" . $this->db->escape($params['city']) . "' + AND s.name = '" . $this->db->escape($params['street']) . "' + AND h.hausnummer = '" . $this->db->escape($params['housenumber']) . "'"; - $whereString = implode(" AND ", $whereClauses); + $cond = !empty($params['gemeinde_id']) + ? " AND h.gemeinde_id = " . intval($params['gemeinde_id']) + : " AND h.netzgebiet_id = " . intval($params['cluster_id']); - $query = " - SELECT h.oaid, h.hausnummer, s.name as street, p.plzstring as zip, o.name as city, h.id as hausnummer_id, w.id as wohneinheit_id, - h.stiege, h.unit_count as building_unit_count, h.tool_building_type as building_type, - w.oaid as unit_oaid, w.stock, w.tuer, w.zusatz - FROM addressdb.Hausnummer h - JOIN addressdb.Strasse s ON h.strasse_id = s.id - JOIN addressdb.Plz p ON h.plz_id = p.id - JOIN addressdb.Ortschaft o ON s.gemeinde_id = o.gemeinde_id - LEFT JOIN addressdb.Wohneinheit w ON w.hausnummer_id = h.id - WHERE $whereString - "; + $results = $this->db->fetch_all_assoc($this->db->query($sql . $cond)); + + if (empty($results) && empty($params['gemeinde_id'])) + $results = $this->db->fetch_all_assoc($this->db->query($sql)); - $results = $this->db->fetch_all_assoc($this->db->query($query)); if (empty($results)) return []; - $orderType = $params['orderType'] ?? 'order'; - - // For 'interest' order type, return a single entry for the whole building. - if ($orderType === 'interest') { - $representativeAddress = $this->formatAddressRow($results[0]); - $representativeAddress['wohneinheit_id'] = null; // Critical: No specific unit - $representativeAddress['oaid'] = $results[0]['oaid']; // Use building OAID - $representativeAddress['showText'] = "Gesamtes Gebäude"; - $representativeAddress['preorderTypes'] = ['interest']; - return [$representativeAddress]; // Return one item, so frontend proceeds directly. + if (($params['orderType'] ?? 'order') === 'interest') { + $addr = $this->formatAddressRow($results[0]); + $addr['wohneinheit_id'] = null; + $addr['oaid'] = $results[0]['oaid']; + $addr['showText'] = "Gesamtes Gebäude"; + $addr['preorderTypes'] = ['interest']; + return [$addr]; } - // Original logic for 'order' type $addresses = []; - $topCounter = 1; - if (count($results) > 1 && $results[0]['wohneinheit_id'] !== null) { + $i = 1; foreach ($results as $row) { - $address = $this->formatAddressRow($row); - $address['showText'] = $this->buildShowText($row, $topCounter++); - $address['preorderTypes'] = ['order']; - $addresses[] = $address; + $addr = $this->formatAddressRow($row); + $addr['showText'] = $this->buildShowText($row, $i++); + $addr['preorderTypes'] = ['order']; + $addresses[] = $addr; } } else { - // Single unit or building without units - $address = $this->formatAddressRow($results[0]); - $address['preorderTypes'] = ['order']; - $addresses[] = $address; + $addr = $this->formatAddressRow($results[0]); + $addr['preorderTypes'] = ['order']; + $addresses[] = $addr; } return $addresses; - } +} private function formatAddressRow(array $row): array { diff --git a/application/Preordercampaign/Preordercampaign.php b/application/Preordercampaign/Preordercampaign.php index 59f39398d..cfbcc99ce 100644 --- a/application/Preordercampaign/Preordercampaign.php +++ b/application/Preordercampaign/Preordercampaign.php @@ -192,7 +192,9 @@ class Preordercampaign extends mfBaseModel { if($name == "salesclusters") { $items = PreordercampaignSalesclusterModel::search(["preordercampaign_id" => $this->id]); foreach($items as $pog) { - $this->salesclusters[$pog->salescluster_id] = new ADBNetzgebiet($pog->salescluster_id); + $sc = new ADBNetzgebiet($pog->salescluster_id); + if(!$sc->id) continue; + $this->salesclusters[$pog->salescluster_id] = $sc; } return $this->salesclusters; } diff --git a/application/Radius/RadiusController.php b/application/Radius/RadiusController.php index 194a95832..5af86bb65 100644 --- a/application/Radius/RadiusController.php +++ b/application/Radius/RadiusController.php @@ -16,10 +16,16 @@ class RadiusController extends mfBaseController { protected function indexAction() { $this->layout()->set('additionalJS', ["plugins/chart.js/chart.4.4.6.js", "plugins/chart.js/chartjs-adapter-moment.min.js"]); - Helper::renderVue($this, $this->mod, "Radius", ['CAN_BILLING' => $this->me->can("Billing"), 'HIDE_PAGE_TITLE' => true]); + + Helper::renderVue3($this, $this->mod, "Radius", [ + 'CAN_BILLING' => $this->me->can("Billing"), + 'HIDE_PAGE_TITLE' => true, + 'USER_ID' => $this->me->id, + ]); } protected function proxyUnsecureHTTPRequestToRadiusAction() { + $this->log->debug("proxyUnsecureHTTPRequestToRadiusAction", $_GET); $url = "http://radius.xinon.at/api.php?" . http_build_query($_GET); $url = str_replace("proxyUnsecureHTTPRequestToRadius", "", $url); $opts = [ @@ -35,252 +41,529 @@ class RadiusController extends mfBaseController { die(); } - protected function sendCustomerEmailAction() { - $input = json_decode(file_get_contents('php://input'), true); - if (!$input || !isset($input['username'], $input['year'], $input['month'], $input['monthlySummary'], $input['monthlyDetails'], $input['recipient'])) - self::sendError("Ungültige oder unvollständige Eingabedaten."); - - $username = $input['username']; - $year = $input['year']; - $month = $input['month']; - $monthlySummary = $input['monthlySummary']; - $monthlyDetails = $input['monthlyDetails']; - $chartImage = $input['chartImage'] ?? ''; - $recipient = $input['recipient']; - - if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) - self::sendError("Ungültige E-Mail-Adresse des Empfängers."); - - $monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; - $monthName = $monthNames[$month - 1] ?? $month; - $subject = "Ihre Transfer-Statistik für {$monthName} {$year}"; - - // --- Helper Functions --- - $formatBytes = function($bytes, $precision = 2) { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $bytes = max($bytes, 0); - $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); - $pow = min($pow, count($units) - 1); - $bytes /= (1 << (10 * $pow)); - return round($bytes, $precision) . ' ' . $units[$pow]; - }; - - $formatDuration = function($seconds) { - $seconds = max(0, intval($seconds)); - if ($seconds === 0) return '00:00:00'; - $days = floor($seconds / 86400); - if ($days > 0) { - $remainder = $seconds % 86400; - $dayString = $days . ' ' . ($days == 1 ? 'Tag' : 'Tage'); - if ($remainder === 0) return $dayString; - return $dayString . ', ' . sprintf('%02d:%02d:%02d', floor($remainder / 3600), floor(($remainder % 3600) / 60), $remainder % 60); - } - return sprintf('%02d:%02d:%02d', floor($seconds / 3600), floor(($seconds % 3600) / 60), $seconds % 60); - }; - - // --- Data Preparation --- - $customerNumber = preg_replace('/[^0-9]/', '', $username) ?: 'Kunde'; - $logoToolPath = LIBDIR . '/../public/assets/images/the-tool-logo.png'; - $logoXinonPath = LIBDIR . '/../public/assets/images/xinon-full.png'; - $logoToolTag = file_exists($logoToolPath) ? 'TheTOOL Logo' : ''; - $logoXinonTag = file_exists($logoXinonPath) ? 'XINON Logo' : ''; - $currentYear = date("Y"); - $xinonBlue = '#005384'; - - // Monthly summary values - $monthlyTotal = $formatBytes($monthlySummary['grandTotalBytes']); - $monthlyDuration = $formatDuration($monthlySummary['totalDurationSeconds']); - $monthlyUpload = $formatBytes($monthlySummary['totalUploadBytes']); - $monthlyDownload = $formatBytes($monthlySummary['totalDownloadBytes']); - - // --- Daily Details Table Generation --- - $dailyDetailsTable = ''; - foreach ($monthlyDetails as $detail) { - $date = date("d.m.Y", strtotime($detail['startTime'])); - $dailyDetailsTable .= " - - - - - - - "; - } - - // --- Base64 Encoded Icons (shortened as requested) --- - $icons = [ - 'total' => 'data:image/png;base64,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', - 'download' => 'data:image/png;base64,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', - 'upload' => 'data:image/png;base64,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', - 'duration' => 'data:image/png;base64,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', - ]; - - // --- Bulletproof HTML Email Template --- - $html = << - - - - - - {$subject} - - - -
- vversion) ? $offer->version : 1 ?>
Leistungszeitraum:" . htmlspecialchars($invoice->leistungszeitraum) . "
Externe Referenz:" . htmlspecialchars($invoice->externe_referenz) . "
Ihre UID:" . $invoice->uid . "
{$date}{$formatDuration($detail['durationSeconds'])}{$formatBytes($detail['uploadBytes'])}{$formatBytes($detail['downloadBytes'])}{$formatBytes($detail['totalBytes'])}
- - - -
- - - - - - - - - - -
- - - - - -
{$logoToolTag}{$logoXinonTag}
-
-

Ihre Transfer-Statistik

-

für {$monthName} {$year}

-

Sehr geehrter Kunde,
anbei finden Sie eine Übersicht Ihrer Netzwerk-Nutzung.

- - - - - - - - - - - -
- - - - -
Gesamt
Monat gesamt
{$monthlyTotal}
-
- - - - -
Download
Download
{$monthlyDownload}
-
 
- - - - -
Upload
Upload
{$monthlyUpload}
-
- - - - -
Dauer
Dauer
{$monthlyDuration}
-
- -
Transfer Statistik Chart
- -

Tagesübersicht

- - - - - - - - - - - - {$dailyDetailsTable} - -
DatumDauerUploadDownloadGesamt
-

Bei Fragen zu Ihrer Statistik stehen wir Ihnen gerne zur Verfügung.

-
-

XINON GmbH

-

Fladnitz im Raabtal 150 | A-8322 Studenzen

-

- +43 3115 40800 | - office@xinon.at -

-

© {$currentYear} XINON GmbH | Impressum

-
-
- - -HTML; - $altBody = "Ihre Transfer-Statistik für {$monthName} {$year}\n\n" . - "Sehr geehrter Kunde,\n\n" . - "anbei finden Sie eine Übersicht Ihrer Netzwerk-Nutzung für {$monthName} {$year}.\n\n" . - "Monatszusammenfassung:\n" . - "Gesamt: {$monthlyTotal}\n" . - "Download: {$monthlyDownload}\n" . - "Upload: {$monthlyUpload}\n" . - "Dauer: {$monthlyDuration}\n\n" . - "Eine detaillierte tägliche Aufstellung finden Sie in der HTML-Version dieser E-Mail.\n\n" . - "Bei Fragen zu Ihrer Statistik stehen wir Ihnen gerne zur Verfügung.\n\n" . - "© {$currentYear} XINON GmbH | Impressum: https://xinon.at/impressum/"; - - $mail = new PHPMailer(true); + protected function genieacsRunSpeedtestAction() { try { - $mail->isSMTP(); - $mail->Host = TT_PIPEWORK_SMTP_HOST; - $mail->SMTPAuth = true; - $mail->Username = TT_PIPEWORK_SMTP_USER; - $mail->Password = TT_PIPEWORK_SMTP_PASS; - $mail->CharSet = PHPMailer::CHARSET_UTF8; - $mail->Encoding = 'base64'; - $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; - $mail->Port = 587; + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRunSpeedtestAction", ['deviceId' => $deviceId]); - if (file_exists($logoToolPath)) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); - if (file_exists($logoXinonPath)) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); + if (!$deviceId) self::sendError("Device ID is required"); - if (!empty($chartImage) && ($imageData = base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $chartImage)))) { - $mail->addStringEmbeddedImage($imageData, 'chart_image', 'chart.png', 'base64', 'image/png'); - } + $acs = $this->getGenieACS(); - $mail->setFrom('thetool@xinon.at', 'TheTOOL by XINON'); - $mail->addReplyTo('office@xinon.at', 'XINON Office'); - $mail->addAddress($recipient, $customerNumber); - $mail->isHTML(true); - $mail->Subject = $subject; - $mail->Body = $html; - $mail->AltBody = $altBody; + // Set speedtest parameters on the device + $acs->setParameterValues($deviceId, [ + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start' => 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1, + 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true + ]); - $mail->send(); - self::returnJson(['success' => true, 'message' => 'Transfer-Statistik E-Mail wurde erfolgreich gesendet.']); + // Get device and extract IP + $device = $acs->getDevice($deviceId); + $ip = GenieACS::getExternalIP($device); + + 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]); + + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\n" . + "X-API-Key: " . $apiKey . "\r\n" . + "Content-Length: " . strlen($data) . "\r\n", + "content" => $data + ] + ]; + + $context = stream_context_create($opts); + $response = file_get_contents($url, false, $context); + + if ($response === false) self::sendError("Failed to connect to speedtest server"); + + self::returnJson(['success' => true, 'message' => 'Speedtest started']); } catch (Exception $e) { - error_log("Mailer Error in sendCustomerEmailAction for username {$username}: " . $mail->ErrorInfo); - self::sendError("E-Mail konnte nicht gesendet werden. Bitte kontaktieren Sie den Support."); + $this->log->debug("Speedtest Error", ['error' => $e->getMessage()]); + self::sendError("Error running speedtest: " . $e->getMessage()); } } -} + + protected function genieacsGetSpeedtestResultAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsGetSpeedtestResultAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + + // Request parameter refresh + $acs->getParameterValues($deviceId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']); + + // Get device info with full data + $device = $acs->getDevice($deviceId); + + if (!$device) self::sendError("Device not found"); + + // Extract speedtest result parameter + $paramName = 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'; + $rawValue = null; + + if (isset($device[$paramName]) && isset($device[$paramName]['value'][0])) { + $rawValue = $device[$paramName]['value'][0]; + } + + if (!$rawValue || !is_string($rawValue) || !str_contains($rawValue, 'BPS')) { + self::returnJson(['success' => true, 'result' => null]); + return; + } + + // Parse the result string (format: "BPS 12345678 Bytes 9876543 Packets 1234") + $parsed = $this->parseSpeedtestResult($rawValue); + + self::returnJson(['success' => true, 'result' => $parsed]); + } catch (Exception $e) { + $this->log->debug("Speedtest Result Error", ['error' => $e->getMessage()]); + self::sendError($e->getMessage()); + } + } + + private function parseSpeedtestResult($raw) { + try { + preg_match('/BPS\s+(\d+)/', $raw, $bpsMatch); + preg_match('/Bytes\s+(\d+)/', $raw, $bytesMatch); + preg_match('/Packets\s+(\d+)/', $raw, $packetsMatch); + + if (!$bpsMatch) return null; + + $bps = (int)$bpsMatch[1]; + $bytes = $bytesMatch ? (int)$bytesMatch[1] : 0; + $packets = $packetsMatch ? (int)$packetsMatch[1] : 0; + + return [ + 'raw' => $raw, + 'bps' => $bps, + 'bpsFormatted' => $this->formatBits($bps), + 'bytes' => $bytes, + 'bytesFormatted' => $this->formatBytes($bytes), + 'packets' => $packets + ]; + } catch (Exception $e) { + $this->log->debug("Error parsing speedtest result", ['error' => $e->getMessage()]); + return null; + } + } + + private function formatBits($bps) { + if (!$bps) return '0 Mbit/s'; + $mbits = $bps / 1000000; + return number_format($mbits, 2, ',', '.') . ' Mbit/s'; + } + + private function formatBytes($bytes) { + if ($bytes == 0) return '0 B'; + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = floor(log($bytes) / log(1024)); + return number_format($bytes / pow(1024, $i), 2, ',', '.') . ' ' . $units[$i]; + } + + private function getGenieACS() { + $host = defined('GENIEACS_HOST') ? GENIEACS_HOST : 'http://acs.xinon.at:3000'; + $username = defined('GENIEACS_USERNAME') ? GENIEACS_USERNAME : 'admin'; + $password = defined('GENIEACS_PASSWORD') ? GENIEACS_PASSWORD : 'savemanfb545aw'; + return new GenieACS($host, $username, $password); + } + + protected function genieacsGetDeviceByIpAction() { + try { + $ip = $_GET['ip'] ?? null; + $this->log->debug("genieacsGetDeviceByIpAction", ['ip' => $ip]); + if (!$ip) self::sendError("IP address is required"); + + $acs = $this->getGenieACS(); + $devices = $acs->getDevices(); + + if (!$devices) { + self::returnJson(['success' => false, 'message' => 'No devices found']); + return; + } + + $matchedDevice = null; + foreach ($devices as $device) { + if (GenieACS::getExternalIP($device) === $ip) { + $matchedDevice = $device; + break; + } + } + + if (!$matchedDevice) { + self::returnJson(['success' => false, 'message' => 'No device found with this IP']); + return; + } + + self::returnJson([ + 'success' => true, + 'deviceId' => GenieACS::getDeviceId($matchedDevice), + 'deviceInfo' => GenieACS::getDeviceInfo($matchedDevice), + 'ip' => $ip, + 'managementIp' => GenieACS::getManagementIP($matchedDevice) + ]); + } catch (Exception $e) { + $this->log->debug("GetDeviceByIp Error", ['error' => $e->getMessage()]); + self::sendError("Error fetching device: " . $e->getMessage()); + } + } + + protected function genieacsRebootDeviceAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsRebootDeviceAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + $acs->rebootDevice($deviceId); + + self::returnJson(['success' => true, 'message' => 'Reboot task created']); + } catch (Exception $e) { + $this->log->debug("Reboot Error", ['error' => $e->getMessage()]); + self::sendError("Error rebooting device: " . $e->getMessage()); + } + } + + protected function genieacsGetDeviceInfoAction() { + try { + $deviceId = $_GET['deviceId'] ?? null; + $this->log->debug("genieacsGetDeviceInfoAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + $device = $acs->getDevice($deviceId); + + if (!$device) self::sendError("Device not found"); + + self::returnJson([ + 'success' => true, + 'deviceInfo' => GenieACS::getDeviceInfo($device), + 'externalIp' => GenieACS::getExternalIP($device), + 'macAddress' => GenieACS::getMacAddress($device), + 'fullData' => $device + ]); + } catch (Exception $e) { + $this->log->debug("GetDeviceInfo Error", ['error' => $e->getMessage()]); + self::sendError("Error getting device info: " . $e->getMessage()); + } + } + + protected function genieacsPingAction() { + try { + $ip = $_GET['ip'] ?? null; + $this->log->debug("genieacsPingAction", ['ip' => $ip]); + + if (!$ip) self::sendError("IP address is required"); + + $acs = $this->getGenieACS(); + $result = $acs->ping($ip); + + self::returnJson(['success' => true, 'result' => $result]); + } catch (Exception $e) { + $this->log->debug("Ping Error", ['error' => $e->getMessage()]); + self::sendError("Error pinging: " . $e->getMessage()); + } + } + + protected function genieacsRemoteAccessAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $forceRecreate = $input['forceRecreate'] ?? false; + $this->log->debug("genieacsRemoteAccessAction", ['deviceId' => $deviceId, 'forceRecreate' => $forceRecreate]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + $result = $acs->createRemoteUser($deviceId, $forceRecreate); + + if ($result) { + self::returnJson(['success' => true] + $result); + } else { + self::sendError("Could not retrieve TR069 username from device"); + } + } catch (Exception $e) { + $this->log->debug("Remote Access Error", ['error' => $e->getMessage()]); + self::sendError("Error configuring remote access: " . $e->getMessage()); + } + } + + protected function genieacsEventLogAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsEventLogAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + $creds = $acs->createRemoteUser($deviceId); + + if (!$creds) self::sendError("Could not obtain credentials for FritzBox"); + + $url = "http://acs.xinon.at:5000/read-fritz-eventlog"; + $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; + + $data = json_encode([ + 'fritz_ip' => $creds['ip'], + 'fritz_port' => "9090", + 'fritz_user' => $creds['username'], + 'fritz_pass' => $creds['password'] + ]); + + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\n" . + "X-API-Key: " . $apiKey . "\r\n" . + "Content-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 60 + ] + ]; + + $context = stream_context_create($opts); + $response = file_get_contents($url, false, $context); + + if ($response) { + $json = json_decode($response, true); + if ($json && isset($json['data'])) { + self::returnJson(['success' => true, 'events' => $json['data']]); + return; + } + } + + self::sendError("Failed to fetch event log"); + } catch (Exception $e) { + $this->log->debug("Event Log Error", ['error' => $e->getMessage()]); + self::sendError("Error: " . $e->getMessage()); + } + } + + protected function genieacsNetworkStructureAction() { + try { + $input = json_decode(file_get_contents('php://input'), true); + $deviceId = $input['deviceId'] ?? null; + $this->log->debug("genieacsNetworkStructureAction", ['deviceId' => $deviceId]); + + if (!$deviceId) self::sendError("Device ID is required"); + + $acs = $this->getGenieACS(); + $creds = $acs->createRemoteUser($deviceId); + + if (!$creds) self::sendError("Could not obtain credentials for FritzBox"); + + $url = "http://acs.xinon.at:5000/read-fritz"; + $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; + + $data = json_encode([ + 'fritz_ip' => $creds['ip'], + 'fritz_port' => "9090", + 'fritz_user' => $creds['username'], + 'fritz_pass' => $creds['password'], + 'page' => 'netDev' + ]); + + $opts = [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json\r\n" . + "X-API-Key: " . $apiKey . "\r\n" . + "Content-Length: " . strlen($data) . "\r\n", + "content" => $data, + "timeout" => 60 + ] + ]; + + $context = stream_context_create($opts); + $response = file_get_contents($url, false, $context); + + if ($response) { + $json = json_decode($response, true); + // Check deeper structure: data -> data -> fbox/active + if ($json && isset($json['data']['data'])) { + $this->ensureMacDb(); + + $raw = $json['data']['data']; + $fbox = $raw['fbox'][0] ?? null; + $active = $raw['active'] ?? []; + + if (!$fbox) { + self::returnJson(['root' => null]); + return; + } + + // 2. Enrich active devices with Vendor and Initialize Children + foreach ($active as &$dev) { + $dev['children'] = []; + if (isset($dev['mac']) && $dev['mac']) { + $dev['vendor'] = $this->getVendor($dev['mac']); + } + } + unset($dev); + + // 3. Prepare Root + if (isset($fbox['mac']) && $fbox['mac']) { + $fbox['vendor'] = $this->getVendor($fbox['mac']); + } + $fbox['children'] = []; + $fbox['model'] = 'fbox'; + $fbox['name'] = $fbox['name'] ?? 'FRITZ!Box'; + $fboxIp = $fbox['ipv4']['ip'] ?? '192.168.178.1'; + + // 4. Map Active Devices by IP + $deviceMap = []; + foreach ($active as &$dev) { + if (isset($dev['ipv4']['ip']) && $dev['ipv4']['ip']) { + $deviceMap[$dev['ipv4']['ip']] = &$dev; + } + } + unset($dev); + + // 5. Build Tree + foreach ($active as &$dev) { + $parentIp = null; + // Attempt to extract IP from parent URL + if (!empty($dev['parent']['url'])) { + if (preg_match('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/', urldecode($dev['parent']['url']), $matches)) { + $parentIp = $matches[1]; + } + } elseif (!empty($dev['parent']['name']) && $dev['parent']['name'] === 'fritz.repeater') { + // Fallback: if parent name is generic repeater but no URL/IP, strictly it's a child of root? + // But usually repeaters have IPs. If no IP found, attach to root. + } + + // Attach to parent if found, otherwise Root + if ($parentIp && isset($deviceMap[$parentIp]) && $parentIp !== $dev['ipv4']['ip']) { + $deviceMap[$parentIp]['children'][] = &$dev; + } else { + // Check if parent is Root (via IP or name) + // If parent IP is Root IP, or no parent found -> Root + $fbox['children'][] = &$dev; + } + } + unset($dev); + + // 6. Sort Function (Recursive) + $sortChildren = function(&$node) use (&$sortChildren) { + if (!empty($node['children'])) { + usort($node['children'], function($a, $b) { + // Ethernet First + $aType = strtolower($a['type'] ?? ''); + $bType = strtolower($b['type'] ?? ''); + + // Check for 'repeater' in name to prioritize repeaters/access points in sorting if needed + // User said: "middle-mans" (repeaters) then devices. + // Let's prioritize Repeaters/LAN Bridges. + $aIsRepeater = stripos($a['name'] ?? '', 'repeater') !== false; + $bIsRepeater = stripos($b['name'] ?? '', 'repeater') !== false; + + if ($aIsRepeater && !$bIsRepeater) return -1; + if (!$aIsRepeater && $bIsRepeater) return 1; + + if ($aType === 'ethernet' && $bType !== 'ethernet') return -1; + if ($aType !== 'ethernet' && $bType === 'ethernet') return 1; + + // Then by Name + return strcasecmp($a['name'] ?? '', $b['name'] ?? ''); + }); + + foreach ($node['children'] as &$child) { + $sortChildren($child); + } + } + }; + + $sortChildren($fbox); + + self::returnJson(['root' => $fbox]); + } + } + + self::sendError("Failed to fetch network structure"); + } catch (Exception $e) { + $this->log->debug("Network Structure Error", ['error' => $e->getMessage()]); + self::sendError("Error: " . $e->getMessage()); + } + } + + + + private function ensureMacDb() { + + $path = TEMP_DIR . '/mac-vendors.csv'; + + if (!file_exists($path)) { + + $this->log->debug("Downloading MAC Vendor DB..."); + + $ctx = stream_context_create(['http'=> ['timeout' => 30]]); + + $data = @file_get_contents("https://maclookup.app/downloads/csv-database/get-db", false, $ctx); + + if ($data) file_put_contents($path, $data); + + } + + } + + + + 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? + + // 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; + + } + + } + + \ No newline at end of file diff --git a/application/Termination/Termination.php b/application/Termination/Termination.php index 112a0b3df..47313cb5e 100644 --- a/application/Termination/Termination.php +++ b/application/Termination/Termination.php @@ -219,61 +219,60 @@ class Termination extends mfBaseModel { return $code; } - - public function getLineworkportPairs() { - $ports = $this->getProperty("workflowitems")["ports"]->value->value_string; - $ist_port = $this->getProperty("workflowitems")["ist_ports"]->value->value_string; - if(strlen($ist_port)) { - $ports = $ist_port; - } - - if(!$ports) { - return []; - } - - $return = []; - $return["range"] = []; - $return["pairs"] = []; - - $ports = preg_replace('/[^0-9-]+/', "", $ports); - if(strpos($ports, "-") !== false) { - // port range - $this->log->debug("is range"); - $port_parts = explode("-", $ports); - if(is_array($port_parts) && count($port_parts) == 2) { - $from = $port_parts[0]; - $to = $port_parts[1]; - - if($port_parts[0] > $port_parts[1]) { - $from = $port_parts[1]; - $to = $port_parts[0]; + + public function getLineworkportPairs() { + $ports = $this->getProperty("workflowitems")["ports"]->value->value_string; + $ist_port = $this->getProperty("workflowitems")["ist_ports"]->value->value_string; + if(strlen($ist_port)) { + $ports = $ist_port; } - - $range = []; - $pairs = []; - - - - for($i = $from; $i <= $to; $i++) { - $range[] = intval($i); - if($i + 1 <= $to) { - $pairs[] = $i."-".($i+1); - } + + if(!$ports) { + return []; } - - $return["range"] = $range; - $return["pairs"] = $pairs; - } - } else { - // single port - $this->log->debug("not a range"); - $return["range"][] = $ports; + + $return = []; + $return["range"] = []; + $return["pairs"] = []; + + $ports = preg_replace('/[^0-9-]+/', "", $ports); + + $port_parts = false; + if(preg_match('/^(\d+)-(\d+)$/', $ports, $m)) { + $port_parts = [$m[1], $m[2]]; + } + + if(!is_array($port_parts) || count($port_parts) < 2) { + // not a valid port range, treat as single port + $return["range"][] = $ports; + } else { + // valid port range + $from = $port_parts[0]; + $to = $port_parts[1]; + + if($port_parts[0] > $port_parts[1]) { + $from = $port_parts[1]; + $to = $port_parts[0]; + } + + $range = []; + $pairs = []; + + for($i = $from; $i <= $to; $i++) { + $range[] = intval($i); + if($i + 1 <= $to) { + $pairs[] = $i."-".($i+1); + } + } + + $return["range"] = $range; + $return["pairs"] = $pairs; + } + + //var_dump($return);exit; + return $return; + } - - //var_dump($return);exit; - return $return; - - } public function resetProperties() { $this->building = null; diff --git a/application/User/User.php b/application/User/User.php index 7370f1771..e8eaab15b 100644 --- a/application/User/User.php +++ b/application/User/User.php @@ -7,7 +7,7 @@ */ class User extends mfBaseModel { public $permissions; - public $flags; + public $flags = []; public $address; protected $forcestr = ['mobile','twofactorcode']; diff --git a/application/VoiceCallHistory/VoiceCallHistoryController.php b/application/VoiceCallHistory/VoiceCallHistoryController.php index e4b1c2c11..56edcef91 100644 --- a/application/VoiceCallHistory/VoiceCallHistoryController.php +++ b/application/VoiceCallHistory/VoiceCallHistoryController.php @@ -142,6 +142,7 @@ class VoiceCallHistoryController extends mfBaseController { "^43317244160", "^4368181877218", "^491744919930", + "^4924194559562", ]; $unknown_numbers = []; diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php index 59e648ea9..c80214bfd 100644 --- a/application/WarehouseArticle/WarehouseArticleController.php +++ b/application/WarehouseArticle/WarehouseArticleController.php @@ -2,7 +2,7 @@ class WarehouseArticleController extends TTCrud { protected string $headerTitle = 'Artikel'; - protected $createText = 'Artikel erstellen'; + protected $createText = false; protected string $singleText = 'Artikel'; protected bool $reopenOnCreate = true; @@ -12,7 +12,7 @@ class WarehouseArticleController extends TTCrud { ['key' => 'articleNumber', 'text' => 'Nr.', 'required' => true], ['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]], ['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']], - ['key' => 'unit', 'text' => 'Einheit', 'required' => true,'table' => false], + ['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]], ['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false], ['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']], ['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']], @@ -32,10 +32,13 @@ class WarehouseArticleController extends TTCrud { protected array $autocompleteColumns = ['articleNumber', 'title', 'description']; protected array $permissionCheck = ['WarehouseUser']; - protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']]; + protected array $additionalActions = [ + ['key' => 'printLabel','title' => 'Label drucken','class' => 'fas fa-print text-secondary'], + ['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary'] + ]; // @formatter:on - protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true]; + protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_PAGE_TITLE' => true]; protected function prepareCrudConfig() { $categories = array_map(fn($category) => ['value' => $category->id, 'text' => $category->name], WarehouseCategory::getAll()); @@ -50,15 +53,19 @@ class WarehouseArticleController extends TTCrud { $this->additionalJSVariables['WAREHOUSE_ADMIN'] = false; } - protected function beforeCreate() { - if (!in_array($this->user->id, [2, 5, 6, 145])) + protected function beforeCreate($postData): bool { + if (!in_array($this->user->id, [2, 5, 6, 145, 14])) self::sendError("Sie haben keine Berechtigung, Artikel zu erstellen."); + + $this->validateArticleNumber($postData); return true; } protected function beforeUpdate($postData): bool { - if (!in_array($this->user->id, [2, 5, 6, 145])) + if (!in_array($this->user->id, [2, 5, 6, 145, 14])) self::sendError("Sie haben keine Berechtigung, Artikel zu bearbeiten."); + + $this->validateArticleNumber($postData, $postData['id'] ?? null); (new WarehouseHistoryController)->create($postData, $this->mod); return true; } @@ -81,6 +88,38 @@ class WarehouseArticleController extends TTCrud { self::updateSellPrices($postData['id']); } + /** + * Validate article number for duplicates and correct category prefix + */ + private function validateArticleNumber(array $postData, ?int $excludeId = null): void { + $articleNumber = $postData['articleNumber'] ?? ''; + $categoryId = $postData['category_id'] ?? null; + + if (empty($articleNumber)) { + self::sendError("Artikelnummer ist erforderlich."); + } + + // Check for duplicate article number + $existingArticles = WarehouseArticleModel::getAll(['articleNumber' => $articleNumber]); + foreach ($existingArticles as $existing) { + if ($excludeId === null || $existing->id != $excludeId) { + self::sendError("Artikelnummer '{$articleNumber}' existiert bereits (Artikel ID: {$existing->id})."); + } + } + + // Validate category prefix + if ($categoryId) { + $category = WarehouseCategory::get($categoryId); + if ($category && $category->articleNumberPrefix) { + $expectedPrefix = $category->articleNumberPrefix; + $articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix)); + if ($articlePrefix !== $expectedPrefix) { + self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen."); + } + } + } + } + public static function updateSellPrices(int $id): void { // Added return type hint $a = WarehouseArticleModel::get($id); if (!$a instanceof WarehouseArticleModel) throw new Exception("Invalid article type"); @@ -131,6 +170,41 @@ class WarehouseArticleController extends TTCrud { self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); } + protected function getNextArticleNumberAction() { + $categoryId = intval($this->request->categoryId ?? 0); + if (!$categoryId) self::sendError("Kategorie nicht angegeben"); + + $category = WarehouseCategory::get($categoryId); + if (!$category) self::sendError("Kategorie nicht gefunden"); + if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix"); + + $prefix = $category->articleNumberPrefix; + $db = FronkDB::singleton(); + + // Get all existing article numbers with this prefix, sorted + $result = $db->query("SELECT CAST(articleNumber AS UNSIGNED) as num FROM WarehouseArticle WHERE articleNumber LIKE '{$prefix}%' ORDER BY num ASC"); + $existingNumbers = []; + while ($row = $db->fetch_array($result)) { + $existingNumbers[] = intval($row['num']); + } + + // Start from prefix * 10000 + 1 (e.g., 1800 -> 18000001) + $startNumber = intval($prefix) * 10000 + 1; + $nextNumber = $startNumber; + + // Find first gap + foreach ($existingNumbers as $num) { + if ($num == $nextNumber) { + $nextNumber++; + } else if ($num > $nextNumber) { + // Found a gap + break; + } + } + + self::returnJson(['success' => true, 'articleNumber' => str_pad($nextNumber, 8, '0', STR_PAD_LEFT)]); + } + protected function autocompleteAction() { $textKey = property_exists($this->model, 'name') ? 'name' : 'title'; if (strlen($this->request->searchedID) > 0) { @@ -163,4 +237,29 @@ class WarehouseArticleController extends TTCrud { return ['value' => $item->id, 'text' => $item->$textKey]; }, $data)); } + + protected function printLabelAction() { + $articleId = $this->request->id; + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::sendError("Artikel nicht gefunden", 404); + } + + $pdf_vars = [ + 'articleId' => $article->id, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title + ]; + + $pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars); + $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + + $filename = $pdf->render($wkhtmltopdfArgs); + + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="label-' . $article->articleNumber . '.pdf"'); + readfile($filename); + die(); + } } diff --git a/application/WarehouseArticlePriceType/WarehouseArticlePriceTypeController.php b/application/WarehouseArticlePriceType/WarehouseArticlePriceTypeController.php index 18a343264..79b9165d0 100644 --- a/application/WarehouseArticlePriceType/WarehouseArticlePriceTypeController.php +++ b/application/WarehouseArticlePriceType/WarehouseArticlePriceTypeController.php @@ -48,6 +48,18 @@ class WarehouseArticlePriceTypeController extends TTCrud { $WarehouseArticleController->updatePricesAction(); } + protected function beforeDelete(): bool { + $priceTypeId = $this->request->id; + $usedByAddresses = AddressPriceTypeModel::getAll(['priceType_id' => $priceTypeId]); + + if (!empty($usedByAddresses)) { + $this->infoMessages['delete'] = 'Dieser Preistyp kann nicht gelöscht werden, da er von ' . count($usedByAddresses) . ' Kunde(n) verwendet wird.'; + return false; + } + + return true; + } + protected function getHistoryAction() { $history = WarehouseHistoryModel::getByRowId($this->request->id, $this->mod); diff --git a/application/WarehouseCategory/WarehouseCategory.php b/application/WarehouseCategory/WarehouseCategory.php index aa6447a6f..129b19f18 100644 --- a/application/WarehouseCategory/WarehouseCategory.php +++ b/application/WarehouseCategory/WarehouseCategory.php @@ -3,7 +3,7 @@ class WarehouseCategory extends TTCrudBaseModel { public int $id; public string $name; public string $description; - public ?int $articleNumberPrefix; + public ?string $articleNumberPrefix; public int $create; public int $create_by; public ?int $edit; diff --git a/application/WarehouseCategory/WarehouseCategoryController.php b/application/WarehouseCategory/WarehouseCategoryController.php index d79cba66f..26d16ab83 100644 --- a/application/WarehouseCategory/WarehouseCategoryController.php +++ b/application/WarehouseCategory/WarehouseCategoryController.php @@ -9,7 +9,7 @@ class WarehouseCategoryController extends TTCrud { protected array $columns = [ ['key' => 'name', 'text' => 'Name', 'required' => true,], ['key' => 'description', 'text' => 'Beschreibung', 'required' => true], - ['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => true], + ['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']], ['key' => 'create', 'text' => 'Erstellt am', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], ['key' => 'create_by', 'text' => 'Erstellt von', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]], @@ -18,11 +18,45 @@ class WarehouseCategoryController extends TTCrud { protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']]; + protected function beforeCreate(): bool { + $this->postData['articleNumberPrefix'] = $this->getNextFreePrefix(); + return true; + } + protected function beforeUpdate($postData): bool { + // Preserve existing prefix - don't allow changes + $existing = WarehouseCategory::get($postData['id']); + if ($existing) { + $this->postData['articleNumberPrefix'] = $existing->articleNumberPrefix; + } (new WarehouseHistoryController)->create($postData, $this->mod); return true; } + private function getNextFreePrefix(): string { + $db = FronkDB::singleton(); + $result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1"); + $row = $db->fetch_array($result); + + if ($row && $row['articleNumberPrefix']) { + $lastPrefix = intval($row['articleNumberPrefix']); + // Skip special ranges (9900+) + if ($lastPrefix >= 9900) { + // Find highest non-special prefix + $result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL AND CAST(articleNumberPrefix AS UNSIGNED) < 9900 ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1"); + $row = $db->fetch_array($result); + $lastPrefix = $row ? intval($row['articleNumberPrefix']) : 1800; + } + $nextPrefix = $lastPrefix + 100; + // Skip 9900+ range + if ($nextPrefix >= 9900) $nextPrefix = 9900; + } else { + $nextPrefix = 1900; + } + + return str_pad($nextPrefix, 4, '0', STR_PAD_LEFT); + } + protected function getHistoryAction() { self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); } diff --git a/application/WarehouseEShopOrder/WarehouseEShopOrderController.php b/application/WarehouseEShopOrder/WarehouseEShopOrderController.php index 6300603d0..4ef6420db 100644 --- a/application/WarehouseEShopOrder/WarehouseEShopOrderController.php +++ b/application/WarehouseEShopOrder/WarehouseEShopOrderController.php @@ -151,7 +151,7 @@ class WarehouseEShopOrderController extends TTCrud { 'deliveryAddressPLZ' => $order->deliveryAddressPLZ, 'deliveryAddressCity' => $order->deliveryAddressCity, 'deliveryAddressEMail' => '', - 'note' => 'Erstellung aus Shop Bestellung #' . $id, + 'note' => 'Erstellung aus Shop Bestellung #' . $id . " | Externe Referenz: " . $order->extRef, 'status' => 'new', 'positions' => $positions, 'textElements' => '[]', @@ -442,11 +442,11 @@ class WarehouseEShopOrderController extends TTCrud { if ($_SERVER['HTTP_HOST'] !== 'localhost') { $recipientEmails = ["office@xinon.at", $user->email]; // Add shop-specific email if applicable - if ($json['addressId'] === 209) { - $recipientEmails[] = "ftth-versand@triotronik.com"; - } elseif ($json['addressId'] === 9633) { - $recipientEmails[] = "sbidi-versand@xinon.at"; // Example for SBIDI - } + //if ($json['addressId'] === 209) { + // //$recipientEmails[] = "ftth-versand@triotronik.com"; + //} elseif ($json['addressId'] === 9633) { + // //$recipientEmails[] = "sbidi-versand@xinon.at"; // Example for SBIDI + //} $recipientEmails = array_unique($recipientEmails); // Remove duplicates foreach ($recipientEmails as $emailAddr) { diff --git a/application/WarehouseLocation/WarehouseLocationModel.php b/application/WarehouseLocation/WarehouseLocationModel.php index 44eab9211..00a66f571 100644 --- a/application/WarehouseLocation/WarehouseLocationModel.php +++ b/application/WarehouseLocation/WarehouseLocationModel.php @@ -3,7 +3,7 @@ class WarehouseLocationModel extends TTCrudBaseModel { public int $id; public string $title; - public string $description; + public ?string $description = null; public int $assignedTo; public int $createBy; public int $create; diff --git a/application/WarehouseOffer/WarehouseOfferController.php b/application/WarehouseOffer/WarehouseOfferController.php index 39211f47d..8658804f4 100644 --- a/application/WarehouseOffer/WarehouseOfferController.php +++ b/application/WarehouseOffer/WarehouseOfferController.php @@ -1,5 +1,8 @@ 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['alternativePositions'] = json_encode([]); return true; } @@ -119,7 +123,7 @@ class WarehouseOfferController extends TTCrud { $id = $this->request->id; if (!$id) self::sendError("ID fehlt"); - $journalEntries = WarehouseOfferJournalModel::search(['offerId' => $id], ['create' => 'DESC']); + $journalEntries = WarehouseOfferJournalModel::searchOfferJournal(['offerId' => $id], ['create' => 'DESC']); self::returnJson($journalEntries); } @@ -182,11 +186,16 @@ class WarehouseOfferController extends TTCrud // E-Mail Actions public function sendOfferEmailAction() { + //display errors for debugging + error_reporting(E_ALL); + ini_set('display_errors', 1); + ini_set('display_startup_errors', 1); + $_POST = json_decode(file_get_contents('php://input'), true); $id = $_POST['id'] ?? null; $recipientEmail = $_POST['email'] ?? null; $subject = $_POST['subject'] ?? 'Ihr Angebot von XINON GmbH'; - $body = $_POST['body'] ?? 'Anbei finden Sie Ihr angefordertes Angebot.'; + $bodyText = $_POST['body'] ?? 'Anbei finden Sie Ihr angefordertes Angebot.'; if (!$id || !$recipientEmail) { self::sendError("ID oder E-Mail-Adresse fehlt."); @@ -208,16 +217,66 @@ class WarehouseOfferController extends TTCrud // Generate PDF $pdfContent = $this->createPDFAction(true, $id, true); - // Send Email - $mail = new Mail(); - $mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName); - $mail->setSubject($subject); - $mail->setBody($body); - $mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf'); + // --- 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 = 'Angebot'; + $html .= '
'; + + // Logos + $html .= '
'; + if ($logoToolExists) $html .= 'The Tool'; + if ($logoXinonExists) $html .= 'Xinon'; + $html .= '
'; + + $html .= '

' . htmlspecialchars($subject) . '

'; + $html .= '
'; + $html .= nl2br(htmlspecialchars($bodyText)); + $html .= '
'; + + $html .= '
'; + $html .= 'XINON GmbH | www.xinon.at'; + $html .= '
'; + + $mail = new PHPMailer(true); + try { + // Server settings + $mail->isSMTP(); + $mail->Host = TT_PIPEWORK_SMTP_HOST; + $mail->SMTPAuth = true; + $mail->Username = TT_PIPEWORK_SMTP_USER; + $mail->Password = TT_PIPEWORK_SMTP_PASS; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + $mail->Encoding = 'base64'; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Port = 587; + + // Logos + if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); + if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); + + $mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice'); + $mail->setFrom('thetool@xinon.at', 'XINON TheTool'); + // set replyto to backoffice@xinon.at + $mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName); + $mail->Subject = ($subject); + $mail->isHTML(true); + $mail->Body = $html; + $mail->AltBody = strip_tags($bodyText); + + $mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf'); + + $mail->send(); - if ($mail->send()) { // Update offer status and last sent date - WarehouseOfferModel::update($id, ['status' => 'sent', 'lastSentDate' => time()]); + $WarehouseOffer = (array) WarehouseOfferModel::get($id); + $WarehouseOffer['status'] = 'sent'; + $WarehouseOffer['lastSentDate'] = time(); + WarehouseOfferModel::update($WarehouseOffer); // Add Journal Entry WarehouseOfferJournalModel::create([ @@ -228,7 +287,7 @@ class WarehouseOfferController extends TTCrud ]); self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet.']); - } else { + } catch (Exception $e) { self::sendError('E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo); } } @@ -377,6 +436,7 @@ class WarehouseOfferController extends TTCrud "includeTax" => true, "vatRate" => 0.20, "offerText" => $offerData->notes ?? '', + "validity" => $offerData->validity ?? 14, "closingText" => $offerData->closingText ?? '', "bank_iban" => TT_INVOICE_BANK_IBAN, "bank_bic" => TT_INVOICE_BANK_BIC, diff --git a/application/WarehouseOffer/WarehouseOfferModel.php b/application/WarehouseOffer/WarehouseOfferModel.php index 239e66d03..3a57cb2f8 100644 --- a/application/WarehouseOffer/WarehouseOfferModel.php +++ b/application/WarehouseOffer/WarehouseOfferModel.php @@ -24,6 +24,7 @@ class WarehouseOfferModel extends TTCrudBaseModel { public string $closingText; public string $notes; public string $status; + public ?int $validity; // New field public ?int $lastSentDate; // New field public float $totalAmount; public int $create; @@ -92,7 +93,7 @@ class WarehouseOfferJournalModel extends TTCrudBaseModel * @param false $count * @return array|int */ - public static function search(array $filter = [], array $orderBy = [], $limit = null, $offset = null, $count = false) + public static function searchOfferJournal(array $filter = [], array $orderBy = [], $limit = null, $offset = null, $count = false) { $db = self::getDB(); $tableName = self::getFullyQualifiedTable(); diff --git a/application/WarehouseProject/WarehouseProjectController.php b/application/WarehouseProject/WarehouseProjectController.php index 7da44ce9d..58df149b5 100644 --- a/application/WarehouseProject/WarehouseProjectController.php +++ b/application/WarehouseProject/WarehouseProjectController.php @@ -2,46 +2,379 @@ class WarehouseProjectController extends TTCrud { protected string $headerTitle = 'Projekte'; - protected string $createText = 'Neues Projekt erstellen'; protected string $singleText = 'Projekt'; + protected bool $createText = false; //@formatter:off protected array $columns = [ - ['key' => 'title', 'text' => 'Titel', 'required' => true], - ['key' => 'description', 'text' => 'Projektbeschreibung', 'modal' => ['type' => 'textarea']], - - ['key' => 'startDate', 'text' => 'Startdatum', 'required' => true, 'modal' => ['type' => 'datepicker']], - ['key' => 'endDate', 'text' => 'Enddatum', 'required' => true, 'modal' => ['type' => 'datepicker']], - ['key' => 'positions', 'text' => 'Positionen', 'required' => true, 'modal' => ['type' => 'positions-manager', 'config' => [ - 'header' => 'Positionen', - 'fields' => [ - 'articleId' => ['apiUrl' => '/WarehouseArticle/autoComplete','type' => 'autocomplete','customFieldReference' => 'WarehouseArticle','label' => 'Artikel'], - 'amount' => ['type' => 'input', 'label' => 'Menge', 'inputType' => 'number'], - 'purpose' => ['type' => 'input', 'label' => 'Zweck'], - ], - 'validateFormOptions' => [ - ['key' => 'articleId', 'message' => 'Bitte füllen Sie den Artikel aus'], - ['key' => 'amount', 'message' => 'Bitte füllen Sie die Menge aus'], - ['key' => 'purpose', 'message' => 'Bitte füllen Sie den Zweck aus'], - ], - ]], 'table' => false], - ['key' => 'linkedOrderIds', 'text' => 'Verlinkte Bestellung', 'modal' => false], -// - ['key' => 'assignedPersons', 'text' => 'Zugewiesene Personen', 'modal' => ['type' => 'positions-manager', 'config' => [ - 'header' => 'Zugewiesene Personen', - 'fields' => [ - 'userId' => ['apiUrl' => '/WarehouseShippingNote/userAutoComplete','type' => 'autocomplete','label' => 'Person','customFieldReference' => 'User'] - ], - 'validateFormOptions' => [ - ['key' => 'userId', 'message' => 'Bitte füllen Sie die Person aus'], - ], - ]], 'table' => false], - - ['key' => 'storageLocation', 'text' => 'Lagerort', 'modal' => ['type' => 'input']], - ['key' => 'note', 'text' => 'Notiz', 'modal' => ['type' => 'textarea']], - ['key' => 'createBy', 'text' => 'Erstellt von', 'required' => true, 'modal' => ['visible' => false, 'type' => 'select'], 'table' => ['filter' => 'select']], - ['key' => 'create', 'text' => 'Erstellt', 'required' => true, 'modal' => false], + ['key' => 'id', 'text' => 'ID', 'table' => false, 'modal' => false], + ['key' => 'projectNumber', 'text' => 'Projekt-Nr.', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']], + ['key' => 'title', 'text' => 'Bezeichnung', 'required' => true], + ['key' => 'status', 'text' => 'Status', 'required' => true, 'modal' => ['type' => 'select', 'items' => [ + ['value' => 'new', 'text' => 'Neu'], + ['value' => 'wip', 'text' => 'In Bearbeitung'], + ['value' => 'finished', 'text' => 'Abgeschlossen'], + ['value' => 'cancelled', 'text' => 'Storniert'], + ]], 'table' => ['filter' => 'select']], + ['key' => 'startDate', 'text' => 'Startdatum', 'required' => true, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']], + ['key' => 'endDate', 'text' => 'Enddatum', 'required' => true, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']], + ['key' => 'financials', 'text' => 'Gesamtsumme', 'required' => false, 'modal' => ['disabled' => true], 'table' => ['formatter' => 'formatPrice']], + ['key' => 'storageLocation', 'text' => 'Lagerort', 'required' => false], + ['key' => 'externalTeam', 'text' => 'Externes Team', 'required' => false, 'modal' => ['type' => 'textarea'], 'table' => false], + ['key' => 'description', 'text' => 'Beschreibung', 'required' => false, 'modal' => ['type' => 'textarea'], 'table' => false], + ['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => false, 'table' => ['formatter' => 'formatDate']], ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], ]; //@formatter:on -} \ No newline at end of file + + protected array $permissionCheck = ['WarehouseUser']; + protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => false]; + + protected function prepareCrudConfig(): void { + if ($this->user->can('WarehouseAdmin')) { + $this->additionalJSVariables['WAREHOUSE_ADMIN'] = true; + } + } + + private array $tempInternalTeam = []; + + protected function beforeCreate(): bool { + $json = json_decode(file_get_contents('php://input'), true); + if ($json) { + $this->postData = array_merge($this->postData ?? [], $json); + } + + if (isset($this->postData['internalTeam'])) { + $this->tempInternalTeam = $this->postData['internalTeam']; + unset($this->postData['internalTeam']); + } + + $this->postData['projectNumber'] = WarehouseProjectModel::getNextProjectNumber(); + // Ensure defaults if not provided + if (!isset($this->postData['status'])) $this->postData['status'] = 'new'; + if (!isset($this->postData['financials'])) $this->postData['financials'] = 0.00; + + return true; + } + + protected function afterCreate($id): void + { + WarehouseProjectJournalModel::create([ + 'projectId' => $id, + 'text' => 'Projekt erstellt.', + 'createBy' => $this->user->id, + 'create' => time() + ]); + + // Handle initial Internal Team + if (!empty($this->tempInternalTeam) && is_array($this->tempInternalTeam)) { + foreach ($this->tempInternalTeam as $userId) { + WarehouseProjectMemberModel::create([ + 'projectId' => $id, + 'userId' => $userId, + 'create' => time() + ]); + + $u = UserModel::getOne($userId); + $this->logJournal($id, "Teammitglied initial hinzugefügt: " . ($u ? $u->name : $userId)); + } + } + } + + protected function afterUpdate($postData): void + { + $id = $postData['id']; + // Simple journaling of main record update + WarehouseProjectJournalModel::create([ + 'projectId' => $id, + 'text' => 'Projektstammdaten aktualisiert.', + 'createBy' => $this->user->id, + 'create' => time() + ]); + } + + // --- API for Vue --- + + public function getProjectDetailsAction() { + $id = $this->request->id; + if (!$id) self::sendError("Projekt ID fehlt"); + + $project = WarehouseProjectModel::get($id); + if (!$project) self::sendError("Projekt nicht gefunden"); + + self::returnJson(['project' => $project]); + } + + public function getTasksAction() { + $projectId = $this->request->id; + if (!$projectId) self::sendError("Projekt ID fehlt"); + + $tasks = WarehouseProjectTaskModel::getAll(['projectId' => $projectId], null, 0, ['key' => 'order', 'order' => 'ASC']); + foreach ($tasks as $task) { + if ($task->assignedUserId) { + $user = UserModel::getOne($task->assignedUserId); + $task->assignedUserName = $user ? $user->name : 'Unbekannt'; + } else { + $task->assignedUserName = null; + } + } + self::returnJson($tasks); + } + + public function saveTaskAction() { + $data = json_decode(file_get_contents('php://input'), true); + $projectId = $data['projectId'] ?? null; + + if (!$projectId) self::sendError("Projekt ID fehlt"); + + $taskData = [ + 'projectId' => $projectId, + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'status' => $data['status'] ?? 'todo', + 'assignedUserId' => !empty($data['assignedUserId']) ? $data['assignedUserId'] : null, + 'createBy' => $this->user->id, + 'create' => time() + ]; + + if (!empty($data['id'])) { + $existingTask = WarehouseProjectTaskModel::get($data['id']); + if (!$existingTask) self::sendError("Aufgabe nicht gefunden"); + + // Merge existing data with new data to ensure all required fields are present + $updatedData = array_merge((array)$existingTask, $taskData); + $updatedData['id'] = $data['id']; // Ensure ID is in the data for update + + // update method expects an array with 'id' key for update. + WarehouseProjectTaskModel::update($updatedData); + $this->logJournal($projectId, "Aufgabe aktualisiert: {$data['title']}"); + } else { + // Get max order to append + $count = WarehouseProjectTaskModel::count(['projectId' => $projectId]); + $taskData['order'] = $count + 1; + + WarehouseProjectTaskModel::create($taskData); + $this->logJournal($projectId, "Aufgabe erstellt: {$data['title']}"); + } + + self::returnJson(['success' => true]); + } + + public function updateTaskStatusAction() { + $data = json_decode(file_get_contents('php://input'), true); + if (empty($data['id']) || empty($data['status'])) self::sendError("Daten fehlen"); + + $task = WarehouseProjectTaskModel::get($data['id']); + if ($task) { + // Retrieve existing task data to preserve projectId and other required fields + $updatedData = (array)$task; + $updatedData['status'] = $data['status']; + + // WarehouseProjectTaskModel::update expects an array with 'id' key for update. + WarehouseProjectTaskModel::update($updatedData); + $this->logJournal($task->projectId, "Aufgabenstatus '{$task->title}' geändert auf {$data['status']}"); + } + self::returnJson(['success' => true]); + } + + public function deleteTaskAction() { + $id = $this->request->id; + if (!$id) self::sendError("ID fehlt"); + + $task = WarehouseProjectTaskModel::get($id); + if ($task) { + WarehouseProjectTaskModel::delete($id); + $this->logJournal($task->projectId, "Aufgabe gelöscht: {$task->title}"); + } + self::returnJson(['success' => true]); + } + + public function getTeamAction() { + $projectId = $this->request->id; + if (!$projectId) self::sendError("ID fehlt"); + + $members = WarehouseProjectMemberModel::getAll(['projectId' => $projectId]); + $users = []; + foreach($members as $m) { + $u = UserModel::getOne($m->userId); + if ($u) { + $users[] = [ + 'memberId' => $m->id, + 'userId' => $u->id, + 'name' => $u->name, + 'role' => $m->role + ]; + } + } + self::returnJson($users); + } + + public function addTeamMemberAction() { + $data = json_decode(file_get_contents('php://input'), true); + if (empty($data['projectId']) || empty($data['userId'])) self::sendError("Daten fehlen"); + + $exists = WarehouseProjectMemberModel::count(['projectId' => $data['projectId'], 'userId' => $data['userId']]); + if ($exists > 0) self::sendError("Benutzer bereits im Team"); + + WarehouseProjectMemberModel::create([ + 'projectId' => $data['projectId'], + 'userId' => $data['userId'], + 'role' => $data['role'] ?? null, + 'create' => time() + ]); + + $u = UserModel::getOne($data['userId']); + $this->logJournal($data['projectId'], "Teammitglied hinzugefügt: " . ($u ? $u->name : $data['userId'])); + + self::returnJson(['success' => true]); + } + + public function removeTeamMemberAction() { + $id = $this->request->id; + $member = WarehouseProjectMemberModel::get($id); + if ($member) { + $u = UserModel::getOne($member->userId); + WarehouseProjectMemberModel::delete($id); + $this->logJournal($member->projectId, "Teammitglied entfernt: " . ($u ? $u->name : $member->userId)); + } + self::returnJson(['success' => true]); + } + + public function getAvailableOrderRequestsAction() { + // Return open requests (not done, not cancelled) + // You might want to filter out ones already linked to THIS project, but maybe not strictly necessary. + $requests = WarehouseOrderRequest::getAll([], 100, 0, ['key' => 'create', 'order' => 'DESC']); + + $available = []; + foreach($requests as $r) { + if (!$r->done && !$r->cancelled) { + $available[] = [ + 'id' => $r->id, + 'purpose' => $r->purpose, + 'create' => $r->create + ]; + } + } + self::returnJson($available); + } + + public function getLinkedOrdersAction() { + $projectId = $this->request->id; + if (!$projectId) self::sendError("ID fehlt"); + + $links = WarehouseProjectOrderRequestModel::getAll(['projectId' => $projectId]); + $result = []; + + foreach($links as $l) { + $req = WarehouseOrderRequest::get($l->orderRequestId); + if ($req) { + $positions = json_decode($req->positions, true); + + // Resolve actual Orders + $orders = []; + $ids = []; + + if (!empty($req->linkedOrderIds)) { + // Check if it is JSON array + $decoded = json_decode($req->linkedOrderIds, true); + if (is_array($decoded)) { + $ids = $decoded; + } else { + // Fallback to comma separated + $ids = explode(',', $req->linkedOrderIds); + } + } + + foreach($ids as $oid) { + $oid = trim($oid); + if (empty($oid)) continue; + + $o = WarehouseOrderModel::get($oid); + if ($o) { + $orders[] = [ + 'id' => $o->id, + 'orderNumber' => $o->orderNumber, + 'status' => $o->status, + 'distributorId' => $o->distributorId + ]; + } + } + + $result[] = [ + 'linkId' => $l->id, + 'requestId' => $req->id, + 'purpose' => $req->purpose, + 'create' => $req->create, + 'status' => $req->done ? 'done' : ($req->cancelled ? 'cancelled' : 'open'), + 'positionsCount' => is_array($positions) ? count($positions) : 0, + 'orders' => $orders + ]; + } + } + self::returnJson($result); + } + + public function linkOrderAction() { + $data = json_decode(file_get_contents('php://input'), true); + if (empty($data['projectId']) || empty($data['orderId'])) self::sendError("Daten fehlen"); + + $order = WarehouseOrderRequest::get($data['orderId']); + if (!$order) self::sendError("Bestellwunsch nicht gefunden"); + + WarehouseProjectOrderRequestModel::create([ + 'projectId' => $data['projectId'], + 'orderRequestId' => $data['orderId'], + 'create' => time() + ]); + + $this->logJournal($data['projectId'], "Bestellwunsch #{$data['orderId']} verknüpft."); + self::returnJson(['success' => true]); + } + + public function unlinkOrderAction() { + $id = $this->request->id; + $link = WarehouseProjectOrderRequestModel::get($id); + if ($link) { + WarehouseProjectOrderRequestModel::delete($id); + $this->logJournal($link->projectId, "Bestellwunsch #{$link->orderRequestId} Verknüpfung aufgehoben."); + } + self::returnJson(['success' => true]); + } + + public function createJournalEntryAction() { + $data = json_decode(file_get_contents('php://input'), true); + if (empty($data['projectId'])) self::sendError("Projekt ID fehlt"); + + $this->logJournal($data['projectId'], $data['message'] ?? ''); + self::returnJson(['success' => true]); + } + + public function getJournalAction() { + $projectId = $this->request->id; + $logs = WarehouseProjectJournalModel::getAll(['projectId' => $projectId], null, 0, ['order' => 'DESC', 'key' => 'create']); + + foreach($logs as $log) { + $u = UserModel::getOne($log->createBy); + $log->userName = $u ? $u->name : 'System'; + } + + self::returnJson($logs); + } + + private function logJournal($projectId, $text) { + WarehouseProjectJournalModel::create([ + 'projectId' => $projectId, + 'text' => $text, + 'createBy' => $this->user->id, + 'create' => time() + ]); + } + + // Users for Team Selection + public function getUsersAction() { + $users = array_map(function($u) { + return ['id' => $u->id, 'name' => $u->name]; + }, UserModel::search(['employee' => true])); + self::returnJson($users); + } +} diff --git a/application/WarehouseProject/WarehouseProjectModel.php b/application/WarehouseProject/WarehouseProjectModel.php index a1aa8f680..42bb92e2d 100644 --- a/application/WarehouseProject/WarehouseProjectModel.php +++ b/application/WarehouseProject/WarehouseProjectModel.php @@ -2,14 +2,35 @@ class WarehouseProjectModel extends TTCrudBaseModel { public int $id; + public string $projectNumber; public string $title; - public string $description; - public string $startDate; - public string $endDate; + public ?string $description; + public ?int $startDate; + public ?int $endDate; public string $status; - public string $priority; - - public int $assignedTo; + public float $financials; + public ?string $storageLocation; + public ?string $externalTeam; + public ?int $createdFromOrderId; public int $createBy; public int $create; + + public static function getNextProjectNumber(): string { + $year = date('Y'); + $prefix = "XP-$year-"; + + $db = self::getDB(); + $tableName = self::getFullyQualifiedTable(); + + $sql = "SELECT projectNumber FROM $tableName WHERE projectNumber LIKE '$prefix%' ORDER BY projectNumber DESC LIMIT 1"; + $result = $db->query($sql); + + $nextNum = 1; + if ($row = $result->fetch_assoc()) { + $lastNumStr = substr($row['projectNumber'], strrpos($row['projectNumber'], '-') + 1); + $nextNum = intval($lastNumStr) + 1; + } + + return $prefix . str_pad((string)$nextNum, 4, '0', STR_PAD_LEFT); + } } \ No newline at end of file diff --git a/application/WarehouseProjectJournal/WarehouseProjectJournalModel.php b/application/WarehouseProjectJournal/WarehouseProjectJournalModel.php new file mode 100644 index 000000000..d747af1ec --- /dev/null +++ b/application/WarehouseProjectJournal/WarehouseProjectJournalModel.php @@ -0,0 +1,10 @@ + 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']], - ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'autocomplete'], 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], + ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true], + ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], ['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [ ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'], @@ -21,7 +22,6 @@ class WarehouseShippingNoteController extends TTCrud { ['key' => 'deliveryAddressLine', 'text' => 'L.-Adr.', 'required' => true], ['key' => 'deliveryAddressPLZ', 'text' => 'L.-Adr. PLZ', 'required' => true], ['key' => 'deliveryAddressEMail', 'text' => 'L.-Adr. EMail', 'required' => false, 'table' => false], - ['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true, 'table' => false], ['key' => 'positions', 'text' => 'Positionen', 'required' => true, 'table' => false, 'modal' => false], ['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => ['visible' => false], 'table' => ['filter' => 'date']], ['key' => 'createBy', 'text' => 'Erstellt von', 'required' => false, 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'select'], 'modal' => ['items' => [], 'type' => 'select',]], @@ -34,6 +34,14 @@ class WarehouseShippingNoteController extends TTCrud { 'delete' => 'Lieferschein wurde gelöscht', 'noChanges' => 'Keine Änderungen vorgenommen']; protected array $permissionCheck = ['WarehouseUser']; + protected array $additionalActions = [ + [ + 'key' => 'createManualInvoice', + 'title' => 'Rechnung erstellen', + 'class' => 'fas fa-file-invoice text-primary', + 'condition' => ['status' => 'accepted'] + ] + ]; //@formatter:on protected function prepareCrudConfig() { @@ -109,6 +117,177 @@ class WarehouseShippingNoteController extends TTCrud { )); } + protected function getShippingNoteForInvoiceAction() { + $id = $this->request->id; + + // Get shipping note + $shippingNote = WarehouseShippingNoteModel::get($id); + if (!$shippingNote) { + self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']); + return; + } + + // Get billing address info + $billingAddress = null; + if ($shippingNote->billingAddressId) { + $billingAddress = Address::getOne($shippingNote->billingAddressId); + } + + // Determine price type ONCE (not in loop for performance) + $priceType = 'Verkauf'; + if ($shippingNote->billingAddressId) { + $addressPriceType = AddressPriceTypeModel::getFirst(['address_id' => $shippingNote->billingAddressId]); + if ($addressPriceType) { + $warehousePriceType = WarehouseArticlePriceTypeModel::get($addressPriceType->priceType_id); + if ($warehousePriceType) { + $priceType = $warehousePriceType->title; + } + } + } + + // Decode and enrich positions + $positions = json_decode($shippingNote->positions, true); + if (!is_array($positions)) { + $positions = []; + } + + $enrichedPositions = []; + + foreach ($positions as $position) { + if (isset($position['article'])) { + // Fetch article details + $article = WarehouseArticleModel::get($position['article']); + if (!$article) continue; + + // Get price for determined price type + $prices = json_decode($article->cheapestSellPrice, true) ?: []; + $price = 0; + foreach ($prices as $p) { + if ($p['title'] === $priceType) { + $price = $p['price']; + break; + } + } + + $enrichedPositions[] = [ + 'type' => 'article', + 'articleId' => $article->id, + 'product_name' => $article->articleNumber . " | " . $article->title, + 'product_info' => $article->description, + 'amount' => $position['amount'], + 'unit' => $article->unit, + 'price' => $price, + 'discount' => 0, + 'vatrate' => 20 + ]; + + } elseif (isset($position['articlePacket'])) { + // Handle article packets + $packet = WarehouseArticlePacketModel::get($position['articlePacket']); + if (!$packet) continue; + + $enrichedPositions[] = [ + 'type' => 'packet', + 'packetId' => $packet->id, + 'product_name' => $packet->title, + 'product_info' => $packet->description ?? '', + 'amount' => $position['amount'], + 'unit' => 'Pau.', + 'price' => 0, + 'discount' => 0, + 'vatrate' => 20 + ]; + + } elseif (isset($position['articleText'])) { + // Handle custom text entries + $enrichedPositions[] = [ + 'type' => 'text', + 'product_name' => $position['articleText'], + 'product_info' => '', + 'amount' => $position['amount'] ?? 1, + 'unit' => 'Stk.', + 'price' => 0, + 'discount' => 0, + 'vatrate' => 20 + ]; + } + } + + // Add hours entries as positions + $hoursEntries = json_decode($shippingNote->hoursEntries, true); + if (!is_array($hoursEntries)) { + $hoursEntries = []; + } + + foreach ($hoursEntries as $hoursEntry) { + if (empty($hoursEntry['hourCount']) || floatval(str_replace(",", ".", $hoursEntry['hourCount'])) <= 0) { + continue; + } + + $userName = 'Unbekannt'; + if (!empty($hoursEntry['userId']) && is_numeric($hoursEntry['userId'])) { + try { + $user = UserModel::getOne($hoursEntry['userId']); + $userName = $user ? $user->name : 'Unbekannt'; + } catch (Exception $e) { + $userName = 'Unbekannt'; + } + } elseif (!empty($hoursEntry['userId_text'])) { + $userName = $hoursEntry['userId_text']; + } + + $enrichedPositions[] = [ + 'type' => 'hours', + 'product_name' => 'Arbeitsstunden - ' . $userName, + 'product_info' => 'Datum: ' . (isset($hoursEntry['date']) ? date('d.m.Y', strtotime($hoursEntry['date'])) : ''), + 'amount' => str_replace(",", ".", $hoursEntry['hourCount']), + 'unit' => 'h', + 'price' => 60, + 'discount' => 0, + 'vatrate' => 20 + ]; + } + + self::returnJson([ + 'success' => true, + 'data' => [ + 'shippingNoteId' => $shippingNote->id, + 'billingAddress' => $billingAddress ? [ + 'id' => $billingAddress->id, + 'customer_number' => $billingAddress->customer_number, + 'company' => $billingAddress->company, + 'firstname' => $billingAddress->firstname, + 'lastname' => $billingAddress->lastname, + 'street' => $billingAddress->street, + 'zip' => $billingAddress->zip, + 'city' => $billingAddress->city, + 'email' => $billingAddress->email, + 'uid' => $billingAddress->uid, + 'fibu_account_number' => $billingAddress->fibu_account_number, + 'billing_type' => $billingAddress->billing_type, + 'billing_delivery' => $billingAddress->billing_delivery, + 'bank_account_bank' => $billingAddress->bank_account_bank, + 'bank_account_owner' => $billingAddress->bank_account_owner, + 'bank_account_iban' => $billingAddress->bank_account_iban, + 'bank_account_bic' => $billingAddress->bank_account_bic, + 'sepa_date' => $billingAddress->sepa_date, + 'fibu_payment_due' => $billingAddress->fibu_payment_due, + 'fibu_payment_skonto' => $billingAddress->fibu_payment_skonto, + 'fibu_payment_skonto_rate' => $billingAddress->fibu_payment_skonto_rate + ] : null, + 'deliveryAddress' => [ + 'name' => $shippingNote->deliveryAddressName, + 'line' => $shippingNote->deliveryAddressLine, + 'plz' => $shippingNote->deliveryAddressPLZ, + 'city' => $shippingNote->deliveryAddressCity, + 'email' => $shippingNote->deliveryAddressEMail + ], + 'note' => $shippingNote->note, + 'positions' => $enrichedPositions + ] + ]); + } + protected function getArticleAddressPriceAction() { empty($this->request->articleId) && $this->sendError('Keine Artikel ID gefunden'); empty($this->request->addressId) && $this->sendError('Keine Adress ID gefunden'); diff --git a/application/WarehouseStocktake/WarehouseStocktakeController.php b/application/WarehouseStocktake/WarehouseStocktakeController.php new file mode 100644 index 000000000..da775dab8 --- /dev/null +++ b/application/WarehouseStocktake/WarehouseStocktakeController.php @@ -0,0 +1,462 @@ + 'stocktakeNumber', 'text' => 'Inventur-Nr.', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 10]], + ['key' => 'title', 'text' => 'Titel', 'required' => true, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 9]], + ['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true, + 'modal' => ['type' => 'select', 'items' => []], + 'table' => ['priority' => 8, 'filter' => 'select']], + ['key' => 'status', 'text' => 'Status', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 7, 'filter' => 'iconSelect', 'filterOptions' => [ + ['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary'], + ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success'], + ['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger'], + ]]], + ['key' => 'progress', 'text' => 'Fortschritt', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 6, 'sortable' => false, 'filter' => false]], + ['key' => 'startedAt', 'text' => 'Gestartet', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 5, 'filter' => false]], + ['key' => 'description', 'text' => 'Beschreibung', 'required' => false, + 'modal' => ['type' => 'textarea'], + 'table' => false], + ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, + 'modal' => false, + 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']], + ]; + + protected array $additionalActions = [ + ['key' => 'startStocktake', 'title' => 'Inventur starten', 'class' => 'fas fa-play text-success'], + ['key' => 'viewProgress', 'title' => 'Fortschritt anzeigen', 'class' => 'fas fa-chart-line text-primary'], + ['key' => 'completeStocktake', 'title' => 'Inventur abschließen', 'class' => 'fas fa-check text-success'], + ['key' => 'applyToStock', 'title' => 'Auf Lager anwenden', 'class' => 'fas fa-boxes text-warning'], + ['key' => 'exportReport', 'title' => 'Excel Export', 'class' => 'fas fa-download text-secondary'], + ['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-secondary'], + ]; + + protected array $additionalJSVariables = []; + + protected array $statusOptions = [ + ['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary', 'color' => 'secondary'], + ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary', 'color' => 'primary'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success', 'color' => 'success'], + ['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger', 'color' => 'danger'], + ]; + + protected array $permissionCheck = ['WarehouseUser']; + + protected array $infoMessages = [ + 'create' => 'Inventur wurde erstellt', + 'update' => 'Inventur wurde aktualisiert', + 'delete' => 'Inventur wurde gelöscht', + 'noChanges' => 'Keine Änderungen', + ]; + + public function prepareCrudConfig() { + // Populate locations dropdown + $locations = array_map(function($location) { + return ['value' => $location->id, 'text' => $location->title]; + }, WarehouseLocationModel::getAll()); + + foreach ($this->columns as &$col) { + if ($col['key'] === 'warehouseLocationId') { + $col['modal']['items'] = $locations; + $col['table']['filterOptions'] = $locations; + } + } + + $this->additionalJSVariables['STATUS_ITEMS'] = $this->statusOptions; + } + + protected function beforeCreate(): bool { + // Set default values + $this->postData['status'] = 'planned'; + $this->postData['totalItems'] = 0; + $this->postData['totalScannedItems'] = 0; + return true; + } + + protected function afterCreate($postData) { + // Generate stocktake number + $stocktake = WarehouseStocktakeModel::get($postData['id']); + if ($stocktake) { + $stocktakeNumber = WarehouseStocktakeModel::generateStocktakeNumber(); + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET stocktakeNumber = '{$stocktakeNumber}' WHERE id = {$stocktake->id}"); + + // Log creation + WarehouseStocktakeLogModel::log($stocktake->id, 'created', null, ['title' => $stocktake->title]); + } + } + + protected function beforeUpdate($postData): bool { + (new WarehouseHistoryController)->create($postData, $this->mod); + return true; + } + + protected function customRowsHandler($rows) { + return array_map(fn($row) => $this->formatRow((array)$row), $rows); + } + + protected function formatRow($row) { + // Keep raw status for frontend conditional logic (don't modify 'status' - table needs raw value for filter) + $row['rawStatus'] = $row['status']; + + // Don't modify warehouseLocationId - table uses items to display the text + // Don't modify status - table uses filterOptions to display + + // Format progress (no filter on this column) + $row['progress'] = "{$row['totalScannedItems']} Artikel gescannt"; + + // Format startedAt (no filter on this column) + if ($row['startedAt']) { + $row['startedAt'] = date('d.m.Y H:i', $row['startedAt']); + } else { + $row['startedAt'] = '-'; + } + + return $row; + } + + /** + * Start a stocktake - changes status to in_progress + */ + protected function startStocktakeAction() { + $id = intval($this->postData['id'] ?? 0); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'planned') { + self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "Geplant" gestartet werden']); + return; + } + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET + status = 'in_progress', + startedAt = " . time() . ", + startedBy = {$this->user->id} + WHERE id = {$id}"); + + WarehouseStocktakeLogModel::log($id, 'started', null, ['startedBy' => $this->user->name]); + + self::returnJson(['success' => true, 'message' => 'Inventur wurde gestartet']); + } + + /** + * Complete a stocktake - changes status to completed + */ + protected function completeStocktakeAction() { + $id = intval($this->postData['id'] ?? 0); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "In Bearbeitung" abgeschlossen werden']); + return; + } + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET + status = 'completed', + completedAt = " . time() . ", + completedBy = {$this->user->id} + WHERE id = {$id}"); + + WarehouseStocktakeLogModel::log($id, 'completed', null, ['completedBy' => $this->user->name]); + + self::returnJson(['success' => true, 'message' => 'Inventur wurde abgeschlossen']); + } + + /** + * Get progress data for live updates + */ + protected function getProgressAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + // Get items via direct SQL to avoid any ORM issues + $db = FronkDB::singleton(); + $result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName, + CASE WHEN si.overwrittenById IS NOT NULL THEN 1 ELSE 0 END as isOverwritten + FROM WarehouseStocktakeItem si + LEFT JOIN WarehouseArticle a ON si.articleId = a.id + LEFT JOIN Worker w ON si.scannedBy = w.id + WHERE si.stocktakeId = {$id} + ORDER BY si.`create` DESC"); + + $formattedItems = []; + $totalValue = 0; + $totalQuantity = 0; + while ($row = $result->fetch_assoc()) { + $unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0; + $quantity = (float)$row['countedQuantity']; + $lineTotal = $unitPrice * $quantity; + $isOverwritten = (bool)$row['isOverwritten']; + + // Only count non-overwritten items in totals + if (!$isOverwritten) { + $totalValue += $lineTotal; + $totalQuantity += $quantity; + } + + $formattedItems[] = [ + 'id' => (int)$row['id'], + 'articleId' => (int)$row['articleId'], + 'articleNumber' => $row['articleNumber'] ?? '', + 'articleTitle' => $row['articleTitle'] ?? 'Unbekannt', + 'countedQuantity' => $quantity, + 'unitPrice' => $unitPrice, + 'lineTotal' => $lineTotal, + 'rack' => $row['rack'], + 'shelf' => $row['shelf'], + 'note' => $row['note'], + 'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null, + 'scannedBy' => $row['scannedByName'], + 'isOverwritten' => $isOverwritten, + ]; + } + + $location = $stocktake->getLocation(); + + self::returnJson([ + 'success' => true, + 'stocktake' => [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'status' => $stocktake->status, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ], + 'items' => $formattedItems, + 'summary' => [ + 'totalValue' => $totalValue, + 'totalQuantity' => $totalQuantity, + ], + ]); + } + + /** + * Apply stocktake results to actual warehouse stock + */ + protected function applyToStockAction() { + $id = intval($this->postData['id'] ?? 0); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'completed') { + self::returnJson(['success' => false, 'message' => 'Inventur muss abgeschlossen sein, um die Bestände anzupassen']); + return; + } + + $db = FronkDB::singleton(); + $items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]); + $appliedCount = 0; + $createdCount = 0; + + foreach ($items as $item) { + // Check if a WarehouseItem already exists for this article at this location + $existingItems = WarehouseItemModel::getAll([ + 'articleId' => $item->articleId, + 'warehouseLocationId' => $stocktake->warehouseLocationId + ]); + + if (count($existingItems) > 0) { + // Update existing item + $existingItem = $existingItems[0]; + $oldQuantity = $existingItem->quantity; + + $db->query("UPDATE WarehouseItem SET + quantity = {$item->countedQuantity}, + rack = " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ", + shelf = " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . " + WHERE id = {$existingItem->id}"); + + // Log history + (new WarehouseHistoryController)->create([ + 'id' => $existingItem->id, + 'quantity' => $item->countedQuantity, + 'rack' => $item->rack, + 'shelf' => $item->shelf, + ], 'WarehouseItem'); + + $appliedCount++; + } else { + // Create new WarehouseItem + $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, rack, shelf, createBy, `create`) + VALUES ({$item->articleId}, {$stocktake->warehouseLocationId}, {$item->countedQuantity}, + " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ", + " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . ", + {$this->user->id}, " . time() . ")"); + + $createdCount++; + } + } + + WarehouseStocktakeLogModel::log($id, 'applied_to_stock', null, [ + 'appliedCount' => $appliedCount, + 'createdCount' => $createdCount, + 'appliedBy' => $this->user->name + ]); + + self::returnJson([ + 'success' => true, + 'message' => "Bestände angepasst: {$appliedCount} aktualisiert, {$createdCount} neu erstellt" + ]); + } + + /** + * Export stocktake report to Excel + */ + protected function exportReportAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + // Get items via direct SQL to include price and overwritten status + $db = FronkDB::singleton(); + $result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName + FROM WarehouseStocktakeItem si + LEFT JOIN WarehouseArticle a ON si.articleId = a.id + LEFT JOIN Worker w ON si.scannedBy = w.id + WHERE si.stocktakeId = {$id} + ORDER BY si.`create` ASC"); + + $rows = []; + $totalSum = 0; + + while ($row = $result->fetch_assoc()) { + $unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0; + $quantity = (float)$row['countedQuantity']; + $lineTotal = $unitPrice * $quantity; + $isOverwritten = !empty($row['overwrittenById']); + + // Skip overwritten items in calculation but show them + if (!$isOverwritten) { + $totalSum += $lineTotal; + } + + $rows[] = [ + 'Artikel Titel' => $row['articleTitle'] ?? 'Unbekannt', + 'Artikel Nummer' => $row['articleNumber'] ?? '', + 'Einzelpreis' => number_format($unitPrice, 2, ',', '.') . ' €', + 'Anzahl' => $quantity, + 'Gesamtsumme' => number_format($lineTotal, 2, ',', '.') . ' €', + 'Gescannt am' => $row['scannedAt'] ? date('d.m.Y H:i', $row['scannedAt']) : '', + 'Gescannt von' => $row['scannedByName'] ?? '', + 'Status' => $isOverwritten ? 'Überschrieben' : '', + ]; + } + + // Add summary row + $rows[] = [ + 'Artikel Titel' => '', + 'Artikel Nummer' => '', + 'Einzelpreis' => '', + 'Anzahl' => 'SUMME:', + 'Gesamtsumme' => number_format($totalSum, 2, ',', '.') . ' €', + 'Gescannt am' => '', + 'Gescannt von' => '', + 'Status' => '', + ]; + + $filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv"; + $csv = Helper::arrayToCsv($rows); + + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + echo "\xEF\xBB\xBF"; // UTF-8 BOM + echo $csv; + exit; + } + + /** + * Get history for a stocktake + */ + protected function getHistoryAction() { + $this->prepareCrudConfig(); + self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns)); + } + + /** + * Get logs for a stocktake + */ + protected function getLogsAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $logs = WarehouseStocktakeLogModel::getLogsForStocktake($id); + $formattedLogs = []; + + foreach ($logs as $log) { + $user = UserModel::get($log->userId); + $formattedLogs[] = [ + 'id' => $log->id, + 'action' => $log->action, + 'details' => $log->details ? json_decode($log->details, true) : null, + 'userName' => $user ? $user->name : 'Unbekannt', + 'create' => date('d.m.Y H:i:s', $log->create), + ]; + } + + self::returnJson(['success' => true, 'logs' => $formattedLogs]); + } +} diff --git a/application/WarehouseStocktake/WarehouseStocktakeModel.php b/application/WarehouseStocktake/WarehouseStocktakeModel.php new file mode 100644 index 000000000..c36c8c071 --- /dev/null +++ b/application/WarehouseStocktake/WarehouseStocktakeModel.php @@ -0,0 +1,74 @@ +query("SELECT stocktakeNumber FROM WarehouseStocktake + WHERE stocktakeNumber LIKE '{$prefix}%' + ORDER BY stocktakeNumber DESC LIMIT 1"); + + if ($row = $result->fetch_assoc()) { + $lastNumber = intval(substr($row['stocktakeNumber'], -6)); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT); + } + + /** + * Get location object + */ + public function getLocation(): ?WarehouseLocationModel { + return WarehouseLocationModel::get($this->warehouseLocationId); + } + + /** + * Get user who started the stocktake + */ + public function getStartedByUser(): ?UserModel { + if (!$this->startedBy) return null; + return UserModel::get($this->startedBy); + } + + /** + * Get items for this stocktake + */ + public function getItems(): array { + return WarehouseStocktakeItemModel::getAll(['stocktakeId' => $this->id]); + } + + /** + * Update progress counters + */ + public function updateProgress(): void { + $items = $this->getItems(); + $this->totalScannedItems = count($items); + + $db = FronkDB::singleton(); + $db->query("UPDATE WarehouseStocktake SET totalScannedItems = {$this->totalScannedItems} WHERE id = {$this->id}"); + } +} diff --git a/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php b/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php new file mode 100644 index 000000000..e6dd8ac6e --- /dev/null +++ b/application/WarehouseStocktakeItem/WarehouseStocktakeItemController.php @@ -0,0 +1,195 @@ + 'articleId', 'text' => 'Artikel', 'required' => true, + 'modal' => ['type' => 'autocomplete', 'apiUrl' => '/WarehouseArticle/autocomplete'], + 'table' => ['priority' => 10]], + ['key' => 'countedQuantity', 'text' => 'Menge', 'required' => true, + 'modal' => ['type' => 'number'], + 'table' => ['priority' => 9]], + ['key' => 'rack', 'text' => 'Regal', 'required' => false, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 8]], + ['key' => 'shelf', 'text' => 'Fach', 'required' => false, + 'modal' => ['type' => 'text'], + 'table' => ['priority' => 7]], + ['key' => 'note', 'text' => 'Notiz', 'required' => false, + 'modal' => ['type' => 'textarea'], + 'table' => ['priority' => 6]], + ['key' => 'scannedAt', 'text' => 'Gescannt am', 'required' => false, + 'modal' => false, + 'table' => ['priority' => 5]], + ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, + 'modal' => false, + 'table' => ['filter' => false, 'sortable' => false]], + ]; + + protected array $permissionCheck = ['WarehouseUser']; + + protected function formatRow($row) { + // Format article + if ($row['articleId']) { + $article = WarehouseArticleModel::get($row['articleId']); + $row['articleId'] = $article ? "[{$article->articleNumber}] {$article->title}" : 'Unbekannt'; + } + + // Format scannedAt + if ($row['scannedAt']) { + $row['scannedAt'] = date('d.m.Y H:i', $row['scannedAt']); + } else { + $row['scannedAt'] = '-'; + } + + return $row; + } + + /** + * Add item via scan (used by PWA) + */ + protected function scanItemAction() { + $stocktakeId = intval($this->request->stocktakeId); + $articleId = intval($this->request->articleId); + $quantity = floatval($this->request->quantity); + $rack = $this->request->rack ?? null; + $shelf = $this->request->shelf ?? null; + $note = $this->request->note ?? null; + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + // Verify stocktake exists and is in progress + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']); + return; + } + + // Verify article exists + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + // Check if this article was already scanned in this stocktake + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId + ]); + + $db = FronkDB::singleton(); + + if ($existing) { + // Update existing entry - add to quantity + $newQuantity = $existing->countedQuantity + $quantity; + $db->query("UPDATE WarehouseStocktakeItem SET + countedQuantity = {$newQuantity}, + rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ", + shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ", + scannedAt = " . time() . ", + scannedBy = {$this->me->id} + WHERE id = {$existing->id}"); + + $itemId = $existing->id; + $message = "Artikel aktualisiert: {$article->title} (Neue Menge: {$newQuantity})"; + } else { + // Create new entry + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->me->id}, {$this->me->id}, " . time() . ")"); + + $itemId = $db->insert_id; + $message = "Artikel hinzugefügt: {$article->title} (Menge: {$quantity})"; + } + + // Update stocktake progress + $stocktake->updateProgress(); + + // Log the scan + WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'rack' => $rack, + 'shelf' => $shelf, + ]); + + self::returnJson([ + 'success' => true, + 'message' => $message, + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $existing ? ($existing->countedQuantity + $quantity) : $quantity, + 'rack' => $rack, + 'shelf' => $shelf, + ], + 'totalScanned' => $stocktake->totalScannedItems + 1, + ]); + } + + /** + * Get article info by QR code or article number + */ + protected function getArticleByCodeAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + // Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article) + // Also accept WH: for backwards compatibility + $articleId = null; + if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + // Try to find by article number + $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]); + if ($article) { + $articleId = $article->id; + } + } + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'description' => $article->description ?? '', + 'unit' => $article->unit ?? 'Stk.', + ] + ]); + } +} diff --git a/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php b/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php new file mode 100644 index 000000000..ea289249d --- /dev/null +++ b/application/WarehouseStocktakeItem/WarehouseStocktakeItemModel.php @@ -0,0 +1,39 @@ +articleId); + } + + /** + * Get the stocktake object + */ + public function getStocktake(): ?WarehouseStocktakeModel { + return WarehouseStocktakeModel::get($this->stocktakeId); + } + + /** + * Get user who scanned this item + */ + public function getScannedByUser(): ?User { + if (!$this->scannedBy) return null; + return UserModel::getOne($this->scannedBy); + } +} diff --git a/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php b/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php new file mode 100644 index 000000000..2a09602c6 --- /dev/null +++ b/application/WarehouseStocktakeLog/WarehouseStocktakeLogModel.php @@ -0,0 +1,43 @@ +get("me"); + $logUserId = $userId ?? ($me ? $me->id : 0); + + $log = new self(); + $log->stocktakeId = $stocktakeId; + $log->stocktakeItemId = $stocktakeItemId; + $log->action = $action; + $log->details = $details ? json_encode($details) : null; + $log->userId = $logUserId; + $log->create = time(); + + $db = FronkDB::singleton(); + $db->query("INSERT INTO WarehouseStocktakeLog (stocktakeId, stocktakeItemId, action, details, userId, `create`) + VALUES ({$log->stocktakeId}, " . ($log->stocktakeItemId ? $log->stocktakeItemId : "NULL") . ", + '{$db->escape($log->action)}', " . ($log->details ? "'{$db->escape($log->details)}'" : "NULL") . ", + {$log->userId}, {$log->create})"); + + $log->id = $db->insert_id; + return $log; + } + + /** + * Get logs for a stocktake + */ + public static function getLogsForStocktake(int $stocktakeId): array { + return self::getAll(['stocktakeId' => $stocktakeId], 0, 0, ['create' => 'DESC']); + } +} diff --git a/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php b/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php new file mode 100644 index 000000000..64a8b2e1b --- /dev/null +++ b/application/WarehouseStocktakePWA/WarehouseStocktakePWAController.php @@ -0,0 +1,494 @@ +needlogin = true; + + $me = mfValuecache::singleton()->get("me"); + if (!$me) { + $me = new User(); + $me->loadMe(); + mfValuecache::singleton()->set("me", $me); + } + $this->me = $me; + $this->user = $me; + $this->layout()->set("me", $me); + + // Check permission + if (!$me->can('WarehouseUser')) { + $this->redirect("Dashboard"); + } + } + + /** + * Main PWA View + */ + public function indexAction() { + $this->layout()->setTemplate("VueViews/WarehouseStocktakePWA"); + $this->layout()->set("JSGlobals", [ + 'BASE_PATH' => '/WarehouseStocktakePWA', + 'USER_ID' => $this->user->id, + 'USER_NAME' => $this->user->name, + ]); + } + + /** + * Logout + */ + protected function logoutAction() { + mfLoginController::staticLogout(); + $this->redirect('/WarehouseStocktakePWA'); + } + + /** + * Get active stocktakes that user can participate in + */ + protected function getActiveStocktakesAction() { + $stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']); + + $result = []; + foreach ($stocktakes as $stocktake) { + $location = $stocktake->getLocation(); + $result[] = [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ]; + } + + self::returnJson(['success' => true, 'stocktakes' => $result]); + } + + /** + * Get stocktake details + */ + protected function getStocktakeAction() { + $id = intval($this->request->id); + if (!$id) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($id); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $location = $stocktake->getLocation(); + + self::returnJson([ + 'success' => true, + 'stocktake' => [ + 'id' => $stocktake->id, + 'stocktakeNumber' => $stocktake->stocktakeNumber, + 'title' => $stocktake->title, + 'status' => $stocktake->status, + 'locationId' => $stocktake->warehouseLocationId, + 'locationName' => $location ? $location->title : 'Unbekannt', + 'totalScannedItems' => $stocktake->totalScannedItems, + 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null, + ] + ]); + } + + /** + * Get article by QR code or article number + */ + protected function getArticleAction() { + $code = $this->request->code; + + if (!$code) { + self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']); + return; + } + + $articleId = null; + + // Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article) + // Also accept WH: for backwards compatibility + if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) { + $articleId = intval($matches[1]); + } else { + // Try to find by article number + $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]); + if ($article) { + $articleId = $article->id; + } + } + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + // Get category name + $category = WarehouseCategory::get($article->category_id); + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'articleNumber' => $article->articleNumber, + 'title' => $article->title, + 'description' => $article->description ?? '', + 'unit' => $article->unit ?? 'Stk.', + 'categoryName' => $category ? $category->name : '', + ] + ]); + } + + /** + * Search articles by text with optional category filter + */ + protected function searchArticlesAction() { + $query = $this->request->query ?? ''; + $categoryId = intval($this->request->categoryId ?? 0); + + $db = FronkDB::singleton(); + $conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"]; + + if ($query && strlen($query) >= 2) { + $escapedQuery = $db->escape($query); + $conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')"; + } + + if ($categoryId > 0) { + $conditions[] = "category_id = {$categoryId}"; + } + + if (count($conditions) === 1 && !$categoryId) { + self::returnJson(['success' => true, 'articles' => []]); + return; + } + + $whereClause = implode(' AND ', $conditions); + $result = $db->query("SELECT id, articleNumber, title, unit, category_id + FROM WarehouseArticle + WHERE {$whereClause} + ORDER BY title ASC + LIMIT 50"); + + $articles = []; + while ($row = $result->fetch_assoc()) { + $articles[] = [ + 'id' => intval($row['id']), + 'articleNumber' => $row['articleNumber'], + 'title' => $row['title'], + 'unit' => $row['unit'] ?? 'Stk.', + 'categoryId' => intval($row['category_id'] ?? 0), + ]; + } + + self::returnJson(['success' => true, 'articles' => $articles]); + } + + /** + * Get all categories for browsing + */ + protected function getCategoriesAction() { + $db = FronkDB::singleton(); + $res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC"); + + $categories = []; + while ($row = $res->fetch_assoc()) { + $categories[] = [ + 'id' => intval($row['id']), + 'name' => $row['name'], + ]; + } + self::returnJson(['success' => true, 'categories' => $categories]); + } + + /** + * Check if article is already scanned in stocktake + */ + protected function checkAlreadyScannedAction() { + $stocktakeId = intval($this->request->stocktakeId); + $articleId = intval($this->request->articleId); + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId, + 'overwrittenById' => null + ]); + + if ($existing) { + $db = FronkDB::singleton(); + $scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}"); + $scannedByRow = $scannedByResult->fetch_assoc(); + + self::returnJson([ + 'success' => true, + 'alreadyScanned' => true, + 'existingItem' => [ + 'id' => $existing->id, + 'countedQuantity' => $existing->countedQuantity, + 'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null, + 'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt', + ] + ]); + } else { + self::returnJson(['success' => true, 'alreadyScanned' => false]); + } + } + + /** + * Submit a scanned item + */ + protected function submitScanAction() { + $postData = json_decode(file_get_contents('php://input'), true) ?? []; + + $stocktakeId = intval($postData['stocktakeId'] ?? 0); + $articleId = intval($postData['articleId'] ?? 0); + $quantity = floatval($postData['quantity'] ?? 0); + $rack = $postData['rack'] ?? null; + $shelf = $postData['shelf'] ?? null; + $note = $postData['note'] ?? null; + $overwrite = boolval($postData['overwrite'] ?? false); + $overwriteItemId = intval($postData['overwriteItemId'] ?? 0); + + if (!$stocktakeId || !$articleId) { + self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']); + return; + } + + if ($quantity <= 0) { + self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']); + return; + } + + // Verify stocktake exists and is in progress + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + if ($stocktake->status !== 'in_progress') { + self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']); + return; + } + + // Verify article exists + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']); + return; + } + + $db = FronkDB::singleton(); + + // If overwrite mode is enabled, mark existing item as overwritten + if ($overwrite && $overwriteItemId) { + // Create new entry + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $itemId = $db->insert_id; + + // Mark old item as overwritten by new item + $db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}"); + + $finalQuantity = $quantity; + $isOverwrite = true; + + // Log the overwrite + WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'overwrittenItemId' => $overwriteItemId, + ]); + + // Update stocktake progress (don't increase count since we're replacing) + $stocktake->updateProgress(); + + self::returnJson([ + 'success' => true, + 'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})", + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $finalQuantity, + 'unit' => $article->unit ?? 'Stk.', + 'rack' => $rack, + 'shelf' => $shelf, + 'isOverwrite' => true, + ] + ]); + return; + } + + // Check if this article was already scanned in this stocktake (non-overwritten) + $existing = WarehouseStocktakeItemModel::getFirst([ + 'stocktakeId' => $stocktakeId, + 'articleId' => $articleId, + 'overwrittenById' => null + ]); + + if ($existing) { + // Update existing entry - add to quantity + $newQuantity = $existing->countedQuantity + $quantity; + $db->query("UPDATE WarehouseStocktakeItem SET + countedQuantity = {$newQuantity}, + rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ", + shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ", + scannedAt = " . time() . ", + scannedBy = {$this->user->id} + WHERE id = {$existing->id}"); + + $itemId = $existing->id; + $finalQuantity = $newQuantity; + $isUpdate = true; + } else { + // Create new entry + $db->query("INSERT INTO WarehouseStocktakeItem + (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`) + VALUES ({$stocktakeId}, {$articleId}, {$quantity}, + " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ", + " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ", + " . ($note ? "'{$db->escape($note)}'" : "NULL") . ", + " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")"); + + $itemId = $db->insert_id; + $finalQuantity = $quantity; + $isUpdate = false; + } + + // Update stocktake progress + $stocktake->updateProgress(); + + // Log the scan + WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [ + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'quantity' => $quantity, + 'totalQuantity' => $finalQuantity, + 'rack' => $rack, + 'shelf' => $shelf, + 'isUpdate' => $isUpdate, + ]); + + self::returnJson([ + 'success' => true, + 'message' => $isUpdate + ? "Menge für '{$article->title}' erhöht auf {$finalQuantity}" + : "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})", + 'item' => [ + 'id' => $itemId, + 'articleId' => $articleId, + 'articleNumber' => $article->articleNumber, + 'articleTitle' => $article->title, + 'countedQuantity' => $finalQuantity, + 'unit' => $article->unit ?? 'Stk.', + 'rack' => $rack, + 'shelf' => $shelf, + 'isUpdate' => $isUpdate, + ] + ]); + } + + /** + * Get recent scans for current user in a stocktake + */ + protected function getMyScansAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $db = FronkDB::singleton(); + $result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit + FROM WarehouseStocktakeItem si + JOIN WarehouseArticle wa ON wa.id = si.articleId + WHERE si.stocktakeId = {$stocktakeId} + AND si.scannedBy = {$this->user->id} + ORDER BY si.scannedAt DESC + LIMIT 50"); + + $items = []; + while ($row = $result->fetch_assoc()) { + $items[] = [ + 'id' => intval($row['id']), + 'articleId' => intval($row['articleId']), + 'articleNumber' => $row['articleNumber'], + 'articleTitle' => $row['articleTitle'], + 'countedQuantity' => floatval($row['countedQuantity']), + 'unit' => $row['unit'] ?? 'Stk.', + 'rack' => $row['rack'], + 'shelf' => $row['shelf'], + 'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null, + ]; + } + + self::returnJson(['success' => true, 'items' => $items]); + } + + /** + * Get progress stats + */ + protected function getProgressAction() { + $stocktakeId = intval($this->request->stocktakeId); + + if (!$stocktakeId) { + self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']); + return; + } + + $stocktake = WarehouseStocktakeModel::get($stocktakeId); + if (!$stocktake) { + self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']); + return; + } + + $db = FronkDB::singleton(); + + // Total scanned items + $totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}"); + $totalRow = $totalResult->fetch_assoc(); + $totalScanned = intval($totalRow['count']); + + // My scanned items + $myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}"); + $myRow = $myResult->fetch_assoc(); + $myScanned = intval($myRow['count']); + + self::returnJson([ + 'success' => true, + 'progress' => [ + 'totalScanned' => $totalScanned, + 'myScanned' => $myScanned, + 'status' => $stocktake->status, + ] + ]); + } +} diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php index 5e1e2ca56..fcfce3a08 100644 --- a/application/WorkorderBase/WorkorderBaseController.php +++ b/application/WorkorderBase/WorkorderBaseController.php @@ -161,17 +161,44 @@ class WorkorderBaseController extends TTCrud $networks = NetworkModel::search(['owner_id' => $config->addressId]); if (empty($networks)) continue; - $tenantCampaigns = array_map(fn($n) => $n->id, PreordercampaignModel::getAll(['network_id' => array_map(fn($n) => $n->id, $networks)])); + $networkIds = array_map(fn($n) => $n->id, $networks); + $tenantCampaigns = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds])); if (empty($tenantCampaigns)) continue; $filters['preordercampaign_id'] = $tenantCampaigns; $newPreorders = PreorderModel::searchActive($filters); foreach ($newPreorders as $preorder) { - if (!WorkorderModel::getFirst(['preorderId' => $preorder->id])) { + $existingWorkorder = (array) WorkorderModel::getFirst(['preorderId' => $preorder->id]); + + if ($existingWorkorder) { + if ($existingWorkorder['status'] === 'archived') { + $oldStatus = $existingWorkorder['status']; + $new = (array) $existingWorkorder; + + $new['status'] = 'new'; + $new['companyId'] = null; + $new['civilEngineeringCompanyId'] = null; + $new['deadlineDate'] = null; + $new['appointmentDate'] = null; + $new['clusterId'] = $preorder->preordercampaign_id; + WorkorderModel::update($new); + + WorkorderJournalModel::create([ + 'workorderId' => $existingWorkorder['id'], + 'text' => 'Arbeitsauftrag wurde automatisch reaktiviert, da die zugehörige Vorbestellung wieder den Kriterien entspricht.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'), + 'create' => time(), + 'createBy' => 1, + ]); + } + } else { WorkorderModel::create([ - 'preorderId' => $preorder->id, 'clusterId' => $preorder->preordercampaign_id, - 'status' => 'new', 'create' => time(), 'createBy' => 0 // System User + 'preorderId' => $preorder->id, + 'clusterId' => $preorder->preordercampaign_id, + 'status' => 'new', + 'create' => time(), + 'createBy' => 1 ]); } } @@ -202,22 +229,25 @@ class WorkorderBaseController extends TTCrud continue; } - $tenantCampaignIds = array_column(PreordercampaignModel::getAll(['network_id' => array_column($networks, 'id')]), 'id'); + $networkIds = array_map(fn($n) => $n->id, $networks); + $tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds])); if (empty($tenantCampaignIds)) { continue; } $activeFilters['preordercampaign_id'] = $tenantCampaignIds; - $activePreorderIds = array_column(PreorderModel::searchActive($activeFilters), 'id'); + $activePreorderIds = array_map(fn($p) => $p->id, PreorderModel::searchActive($activeFilters)); $activePreorderIdsSet = array_flip($activePreorderIds); $statusesToCheck = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved']; - $allTenantPreorders = PreorderModel::getAll(['preordercampaign_id' => $tenantCampaignIds]); + // Get ALL preorders for tenant (including deleted/cancelled) to ensure their workorders get archived + // Note: Not passing 'deleted' filter means all preorders are returned regardless of deleted status + $allTenantPreorders = PreorderModel::search(['preordercampaign_id' => $tenantCampaignIds]); if(empty($allTenantPreorders)) continue; - $allTenantPreorderIds = array_column($allTenantPreorders, 'id'); + $allTenantPreorderIds = array_map(fn($p) => $p->id, $allTenantPreorders); $workordersToCheck = WorkorderModel::getAll([ 'status' => $statusesToCheck, @@ -243,4 +273,4 @@ class WorkorderBaseController extends TTCrud file_put_contents($lockFile, time()); } //endregion -} \ No newline at end of file +} diff --git a/application/WorkorderMph/WorkorderMphModel.php b/application/WorkorderMph/WorkorderMphModel.php new file mode 100644 index 000000000..a2ffde2de --- /dev/null +++ b/application/WorkorderMph/WorkorderMphModel.php @@ -0,0 +1,22 @@ + 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']], + ['key' => 'netOwnerId', 'text' => 'Netzeigentümer', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false], 'required' => false], + ['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false]], + ['key' => 'rimoFcpName', 'text' => 'FCP', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'companyName', 'text' => 'Firma', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => true, 'filter' => 'numberRange']], + ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => false]], + ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], + ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], + ]; + + protected function prepareCrudConfig() + { + $hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key')); + array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]); + + // Handle netOwnerId column - only visible for admins + $netOwnerColIdx = array_search('netOwnerId', array_column($this->columns, 'key')); + if ($netOwnerColIdx !== false) { + if ($this->user->isAdmin()) { + $netOwners = Helper::getMphNetworkOwners(); + $this->columns[$netOwnerColIdx]['table']['filterOptions'] = array_map(fn($o) => ['value' => $o->id, 'text' => $o->company], $netOwners); + } else { + $this->columns[$netOwnerColIdx]['table'] = false; + } + } + + // Populate netzgebiet filter options + $netzgebietColIdx = array_search('netzgebietName', array_column($this->columns, 'key')); + if ($netzgebietColIdx !== false) { + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + + // Apply network ownership filtering + $netzgebietFilter = ""; + if (!$this->user->isAdmin()) { + $allowedNetzgebietIds = Helper::getADBNetworksFromUser($this->user); + if (!empty($allowedNetzgebietIds)) { + $escapedIds = array_map(fn($id) => $db->escape($id), $allowedNetzgebietIds); + $netzgebietFilter = " AND ng.id IN (" . implode(',', $escapedIds) . ")"; + } + } + + $fronkDbName = FRONKDB_DBNAME; + $sql = "SELECT DISTINCT ng.id, ng.name FROM Netzgebiet ng + INNER JOIN Hausnummer hn ON ng.id = hn.netzgebiet_id + INNER JOIN `$fronkDbName`.`WorkorderMph` wm ON wm.hausnummerId = hn.id + WHERE ng.name IS NOT NULL AND ng.name != '' + $netzgebietFilter + ORDER BY ng.name ASC"; + $result = $db->query($sql); + $netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $this->columns[$netzgebietColIdx]['table']['filterOptions'] = array_map(fn($ng) => ['value' => $ng['id'], 'text' => $ng['name']], $netzgebiete); + } + } + + public function indexAction() + { + // Note: Workorder creation is now handled by cronjob script: scripts/workorder-mph-create-from-hausnummer.php + parent::indexAction(); + } + + protected function getAction() + { + $pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10]; + $filters = $this->postData['filters'] ?? []; + $order = $this->postData['order'] ?? []; + + $db = FronkDB::singleton(); + $fronkDbName = FRONKDB_DBNAME; + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + + $whereClauses = "WHERE 1=1"; + + // Apply network ownership filtering (similar to WorkorderAdmin) + if (!$this->user->isAdmin()) { + $allowedNetzgebietIds = Helper::getADBNetworksFromUser($this->user); + if (!empty($allowedNetzgebietIds)) { + $escapedIds = array_map(fn($id) => $db->escape($id), $allowedNetzgebietIds); + $whereClauses .= " AND hn.netzgebiet_id IN (" . implode(',', $escapedIds) . ")"; + } else { + // User has no networks assigned, show no results + $whereClauses .= " AND 1=0"; + } + } + + if (empty($filters['status'])) { + $whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')"; + } else { + $whereClauses .= Helper::generateFilterCondition($filters['status'], 'w.status', true); + } + + if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true); + if (!empty($filters['netOwnerId'])) $whereClauses .= Helper::generateFilterCondition($filters['netOwnerId'], 'n.owner_id'); + if (!empty($filters['hausnummerInfo'])) { + $searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo"; + $whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns); + } + if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.id'); + if (!empty($filters['rimoFcpName'])) $whereClauses .= Helper::generateFilterCondition($filters['rimoFcpName'], 'hn.rimo_fcp_name'); + if (!empty($filters['companyName'])) $whereClauses .= Helper::generateFilterCondition($filters['companyName'], 'c.name'); + if (!empty($filters['wohneinheitCount'])) $whereClauses .= Helper::generateFilterCondition($filters['wohneinheitCount'], '(SELECT COUNT(*) FROM `' . $addressDbName . '`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id)', true); + if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate'); + if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo'); + + $sql = " + SELECT + w.id, w.status, w.deadlineDate, w.appointmentDate, w.companyId, w.additionalInfo, + IFNULL(c.name, 'Nicht zugewiesen') as companyName, + CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo, + str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city, + ng.id as netzgebietName, + n.owner_id as netOwnerId, + hn.rimo_fcp_name as rimoFcpName, + (SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount + FROM `$fronkDbName`.`WorkorderMph` w + LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id + LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = 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`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + LEFT JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id + $whereClauses + "; + + $orderBy = ""; + if (!empty($order['key'])) { + $sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate', 'wohneinheitCount']; + if (in_array($order['key'], $sortableColumns)) { + $sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC'; + $orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder; + } + } + if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC"; + + $sql .= $orderBy; + + // Get total count + $countSql = "SELECT COUNT(*) as count FROM `$fronkDbName`.`WorkorderMph` w + LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id + LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = 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`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + LEFT JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id + $whereClauses"; + $totalCount = (int)$db->query($countSql)->fetch_assoc()['count']; + + // Add pagination + if ($pagination['per_page'] !== null) { + $sql .= " LIMIT " . intval($pagination['per_page']) . " OFFSET " . intval(($pagination['page'] - 1) * $pagination['per_page']); + } + + $result = $db->query($sql); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + self::returnJson([ + 'rows' => $rows, + 'pagination' => [ + 'page' => (int)$pagination['page'], + 'per_page' => (int)$pagination['per_page'], + 'total_rows' => $totalCount, + 'total_pages' => (int)ceil($totalCount / $pagination['per_page']), + 'filtered_available' => $totalCount + ] + ]); + } + + protected function getWorkorderByIdAction() + { + if (empty($this->request->id)) self::sendError("ID fehlt."); + $workorder = WorkorderMphModel::get($this->request->id); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + self::returnJson((array)$workorder); + } + + protected function getCompaniesAction() + { + $companies = WorkorderCompanyModel::getAll(); + self::returnJson(array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies)); + } + + protected function assignWorkorderAction() + { + if (empty($this->postData['workorderId']) || empty($this->postData['companyId'])) self::sendError("Erforderliche Felder fehlen."); + $deadline = !empty($this->postData['deadlineDate']) ? $this->postData['deadlineDate'] : strtotime('+6 weeks'); + + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldStatus = $workorder->status; + $oldCompanyId = $workorder->companyId; + + $workorder->companyId = $this->postData['companyId']; + $workorder->status = 'assigned'; + $workorder->assignmentDate = time(); + $workorder->deadlineDate = $deadline; + + WorkorderMphModel::update((array)$workorder); + + $company = WorkorderCompanyModel::get($this->postData['companyId']); + $statusChange = $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('assigned'); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Arbeitsauftrag zugewiesen an: " . ($company ? $company->name : "Firma ID " . $this->postData['companyId']), + 'statusChange' => $statusChange, + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich zugewiesen.']); + } + + protected function updateDeadlineAction() + { + if (empty($this->postData['workorderId']) || empty($this->postData['deadlineDate'])) self::sendError("Erforderliche Felder fehlen."); + + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $workorder->deadlineDate = $this->postData['deadlineDate']; + WorkorderMphModel::update((array)$workorder); + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Deadline geändert auf ' . date('d.m.Y', $this->postData['deadlineDate']) . '.', + 'create' => time(), + 'createBy' => $this->user->id + ]); + self::returnJson(['success' => true, 'message' => 'Deadline erfolgreich aktualisiert.']); + } + + protected function acceptDocumentationAction() + { + 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 !== 'documented') self::sendError("Die Dokumentation muss zuerst von der Firma als fertig markiert werden."); + + $oldStatus = $workorder->status; + $workorder->status = 'completed'; + WorkorderMphModel::update((array)$workorder); + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.']); + } + + /** + * Background task: Creates WorkorderMph from Hausnummer with >2 Wohneinheiten + * and RIMO state not in grossplaning/not2connect + */ + private function createWorkordersFromHausnummer() + { + $lockFile = TEMP_DIR . "/task_create_workorder_mph.lock"; + if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) { + return; // Run only every 5 minutes + } + + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + + // Build netzgebiet filter + $netzgebietIds = defined('TT_WORKORDER_MPH_NETZGEBIET_IDS') ? TT_WORKORDER_MPH_NETZGEBIET_IDS : []; + $netzgebietFilter = ''; + if (!empty($netzgebietIds)) { + $escapedIds = array_map(fn($id) => $db->escape($id), $netzgebietIds); + $netzgebietFilter = " AND hn.netzgebiet_id IN (" . implode(',', $escapedIds) . ")"; + } + + // Find Hausnummer with >2 Wohneinheiten and state not in grossplaning/not2connect + $sql = " + SELECT hn.id, hn.netzgebiet_id, COUNT(we.id) as we_count + FROM Hausnummer hn + LEFT JOIN Wohneinheit we ON hn.id = we.hausnummer_id + WHERE hn.rimo_ex_state NOT IN ('grossplaning', 'not2connect') + AND hn.rimo_op_state NOT IN ('grossplaning', 'not2connect') + $netzgebietFilter + GROUP BY hn.id + HAVING we_count > 2 + "; + + $result = $db->query($sql); + $hausnummern = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + // Get valid hausnummer IDs + $validHausnummerIds = array_column($hausnummern, 'id'); + + foreach ($hausnummern as $hn) { + // Check if WorkorderMph already exists + $existing = WorkorderMphModel::getFirst(['hausnummerId' => $hn['id']]); + + if (!$existing) { + // Create new WorkorderMph + WorkorderMphModel::create([ + 'hausnummerId' => $hn['id'], + 'status' => 'new', + 'create' => time(), + 'createBy' => 1 // System user + ]); + } elseif ($existing->status === 'archived') { + // Reactivate archived workorder + $existing->status = 'new'; + $existing->companyId = null; + $existing->deadlineDate = null; + $existing->appointmentDate = null; + WorkorderMphModel::update((array)$existing); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $existing->id, + 'text' => 'Arbeitsauftrag wurde automatisch reaktiviert.', + 'statusChange' => $this->getStatusText('archived') . " -> " . $this->getStatusText('new'), + 'create' => time(), + 'createBy' => 1, + ]); + } + } + + // Archive workorders for Hausnummer that are no longer in allowed netzgebiete or don't meet criteria + if (!empty($netzgebietIds)) { + $allWorkorders = WorkorderMphModel::getAll(['status' => ['new', 'assigned', 'scheduled', 'in_progress']]); + foreach ($allWorkorders as $workorder) { + if (!in_array($workorder->hausnummerId, $validHausnummerIds)) { + $workorder->status = 'archived'; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Arbeitsauftrag automatisch archiviert (Netzgebiet deaktiviert oder Kriterien nicht mehr erfüllt).', + 'statusChange' => 'active -> archived', + 'create' => time(), + 'createBy' => 1, + ]); + } + } + } + + file_put_contents($lockFile, time()); + } + + protected function cancelWorkorderAction() + { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldStatus = $workorder->status; + $workorder->status = 'cancelled'; + WorkorderMphModel::update((array)$workorder); + + $reason = !empty($this->postData['reason']) ? $this->postData['reason'] : 'Kein Grund angegeben'; + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Arbeitsauftrag storniert. Grund: " . $reason, + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('cancelled'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']); + } +} diff --git a/application/WorkorderMphBase/WorkorderMphBaseController.php b/application/WorkorderMphBase/WorkorderMphBaseController.php new file mode 100644 index 000000000..0c9c7e25e --- /dev/null +++ b/application/WorkorderMphBase/WorkorderMphBaseController.php @@ -0,0 +1,566 @@ + 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'sortable' => false, 'filterOptions' => [ + ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], + ['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'], + ['value' => 'scheduled', 'text' => 'Geplant', 'icon' => 'fas fa-calendar-check text-warning'], + ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'], + ['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'], + ['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-ban text-danger'], + ['value' => 'archived', 'text' => 'Archiviert', 'icon' => 'fas fa-archive text-muted'], + ]] + ]; + + protected array $additionalJS = ["js/pages/WorkorderMphBase/WorkorderMphBase.js"]; + protected array $additionalHead = [""]; + + protected function getStatusText(string $statusKey): string + { + $statusMap = array_column($this->statusColumn['table']['filterOptions'] ?? [], 'text', 'value'); + return $statusMap[$statusKey] ?? ucfirst(str_replace('_', ' ', $statusKey)); + } + + //region SHARED ACTIONS + /** + * Fetches documentation and journal entries for a given workorder. + */ + protected function getDocumentationAction() + { + if (empty($this->request->workorderMphId)) self::sendError("Arbeitsauftrags-ID fehlt."); + + $docs = WorkorderMphDocumentationModel::getAll(['workorderMphId' => intval($this->request->workorderMphId)], null, 0, ['key' => 'create', 'order' => 'ASC']); + $journals = WorkorderMphJournalModel::getAll(['workorderMphId' => intval($this->request->workorderMphId)], null, 0, ['key' => 'create', 'order' => 'DESC']); + + $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); + $newFilename = "{$documentTypeKey}_{$typeCounts[$documentTypeKey]}." . strtolower($extension); + + $responseDocs[] = [ + 'id' => $doc->id, + 'fileId' => $doc->fileId, + 'fileName' => $newFilename, + 'description' => $doc->description, + 'documentType' => $documentTypeKey, + 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt', + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc->create + ]; + } + + foreach ($journals as $journal) { + $journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt'; + } + + self::returnJson(['docs' => $responseDocs, 'journals' => $journals]); + } + + /** + * Upload documentation for the Workorder itself (not Wohneinheit). + */ + protected function uploadDocumentationAction() + { + if (empty($_FILES['files']) && empty($_FILES['file'])) self::sendError('Erforderliche Daten fehlen.'); + if (empty($_POST['workorderMphId'])) self::sendError('Workorder ID fehlt.'); + + $workorderMphId = intval($_POST['workorderMphId']); + $uploadedCount = 0; + + // Handle multiple files (files[]) + if (!empty($_FILES['files'])) { + foreach ($_FILES['files']['name'] as $index => $name) { + if ($_FILES['files']['error'][$index] === UPLOAD_ERR_OK) { + // Mock the $_FILES entry for handleFormUpload + $_FILES['single_upload_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] + ]; + try { + $uploaded = mfUpload::handleFormUpload("single_upload_file", false, "/WorkorderMph"); + WorkorderMphDocumentationModel::create([ + 'workorderMphId' => $workorderMphId, + 'fileId' => $uploaded->id, + 'description' => $_POST['description'] ?? '', + 'documentType' => $_POST['documentType'] ?? 'other', + 'create' => time(), + 'createBy' => $this->user->id + ]); + $uploadedCount++; + } catch (Exception $e) { + // Log error + } + } + } + } + // Handle single file (file) - fallback or primary if JS sends single + elseif (!empty($_FILES['file'])) { + try { + $uploaded = mfUpload::handleFormUpload("file", false, "/WorkorderMph"); + WorkorderMphDocumentationModel::create([ + 'workorderMphId' => $workorderMphId, + 'fileId' => $uploaded->id, + 'description' => $_POST['description'] ?? '', + 'documentType' => $_POST['documentType'] ?? 'other', + 'create' => time(), + 'createBy' => $this->user->id + ]); + $uploadedCount++; + } catch (Exception $e) { + self::sendError("Upload fehlgeschlagen: " . $e->getMessage()); + } + } + + if ($uploadedCount > 0) { + self::returnJson(['success' => true, 'message' => "$uploadedCount Datei(en) erfolgreich hochgeladen."]); + } else { + self::sendError("Keine Dateien wurden hochgeladen."); + } + } + + /** + * Delete Workorder documentation + */ + protected function deleteDocumentationAction() + { + if (empty($this->postData['documentationId'])) self::sendError("Dokumentations-ID fehlt."); + WorkorderMphDocumentationModel::delete($this->postData['documentationId']); + self::returnJson(['success' => true, 'message' => 'Dokument gelöscht.']); + } + + /** + * Adds a new entry to a workorder's journal. + */ + protected function addJournalAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderMphId']) || empty(trim($post['text']))) self::sendError("Arbeitsauftrags-ID und Text sind erforderlich."); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $post['workorderMphId'], + 'text' => $post['text'], + 'createBy' => $this->user->id, + 'create' => time() + ]); + + $journals = WorkorderMphJournalModel::getAll(['workorderMphId' => intval($post['workorderMphId'])], null, 0, ['key' => 'create', 'order' => 'DESC']); + foreach ($journals as $journal) { + $journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt'; + } + self::returnJson(['success' => true, 'message' => 'Journaleintrag hinzugefügt.', 'journals' => $journals]); + } + + /** + * Updates the additional info field for a workorder. + */ + protected function updateAdditionalInfoAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderMphModel::get($post['workorderMphId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldInfo = $workorder->additionalInfo; + $newInfo = $post['additionalInfo'] ?? null; + $workorder->additionalInfo = $newInfo; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'", + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Zusatzinfo aktualisiert.', 'newInfo' => $newInfo]); + } + + /** + * Get all Wohneinheiten for a specific workorder with their statuses and notes + */ + protected function getWohneinheitenAction() + { + if (empty($this->request->workorderMphId)) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorderMphId = intval($this->request->workorderMphId); + $workorder = WorkorderMphModel::get($workorderMphId); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + // Get all Wohneinheiten for this Hausnummer from addressdb + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $hausnummerId = $db->escape($workorder->hausnummerId); + + // Fetch statuses from addressdb + $statusSql = "SELECT id, code, name FROM Status WHERE type = 'wohneinheit' ORDER BY code ASC"; + $statusResult = $db->query($statusSql); + $statuses = $statusResult ? $statusResult->fetch_all(MYSQLI_ASSOC) : []; + + $statusOptions = array_map(function($s) { + return ['value' => intval($s['id']), 'text' => $s['code'] . ' - ' . $s['name'], 'code' => intval($s['code'])]; + }, $statuses); + + // Fetch Wohneinheiten directly + $sql = "SELECT w.id, w.zusatz, w.tuer, w.contact, w.oaid, w.note, w.status_id, w.splice_hak_completed + FROM Wohneinheit w + WHERE w.hausnummer_id = $hausnummerId + ORDER BY w.oaid ASC"; + $result = $db->query($sql); + $wohneinheiten = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + // Get Preorders for this Hausnummer to fallback contact info + $preorders = []; + if (class_exists('PreorderModel')) { + // Use searchActive to filter out canceled preorders (status_code = 20) + $preorderList = PreorderModel::searchActive(['adb_hausnummer_id' => $workorder->hausnummerId]); + foreach ($preorderList as $preorder) { + if ($preorder->adb_wohneinheit_id) { + $preorders[$preorder->adb_wohneinheit_id] = $preorder; + } + } + } + + // Merge data + $response = []; + foreach ($wohneinheiten as $we) { + // Contact info logic + $contact = $we['contact']; + $preorderContact = null; + $preorderUcode = null; + + if (isset($preorders[$we['id']])) { + $p = $preorders[$we['id']]; + $preorderUcode = $p->ucode; + $pContact = trim($p->firstname . ' ' . $p->lastname); + if ($p->phone) $pContact .= ' (' . $p->phone . ')'; + + $preorderContact = $pContact; + + // If address contact is empty, use preorder contact + if (empty($contact)) { + $contact = $pContact; + } + } + + // Get document count for this Wohneinheit + $docCountSql = "SELECT COUNT(*) as cnt FROM WohneinheitDocumentation WHERE wohneinheit_id = " . $db->escape($we['id']); + $docCountResult = $db->query($docCountSql); + $documentCount = 0; + if ($docCountResult) { + $docCountRow = $docCountResult->fetch_assoc(); + $documentCount = intval($docCountRow['cnt']); + } + + $response[] = [ + 'wohneinheitId' => intval($we['id']), + 'zusatz' => $we['zusatz'], + 'tuer' => $we['tuer'], + 'contact' => $contact, + 'preorderContact' => $preorderContact, + 'preorderUcode' => $preorderUcode, + 'oaid' => $we['oaid'], + 'status' => intval($we['status_id']), + 'spliceCompleted' => intval($we['splice_hak_completed'] ?? 0), + 'note' => $we['note'], + 'documentCount' => $documentCount, + ]; + } + + self::returnJson([ + 'wohneinheiten' => $response, + 'statusOptions' => $statusOptions, + 'hausnummerId' => $workorder->hausnummerId + ]); + } + + /** + * Update status and note for a specific Wohneinheit + */ + protected function updateWohneinheitAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderMphId']) || empty($post['wohneinheitId'])) { + self::sendError("Arbeitsauftrags-ID und Wohneinheit-ID sind erforderlich."); + } + + $workorderMphId = intval($post['workorderMphId']); + $wohneinheitId = intval($post['wohneinheitId']); + $newStatusId = intval($post['status'] ?? 1); + $spliceCompleted = isset($post['spliceCompleted']) ? intval($post['spliceCompleted']) : 0; + $tuer = $post['tuer'] ?? null; + $zusatz = $post['zusatz'] ?? null; + + // Validate that "Tür" field is not empty if it's being set + if ($tuer !== null && trim($tuer) === '') { + self::sendError("Das Feld 'Tür' darf nicht leer sein."); + } + + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $escapedWohneinheitId = $db->escape($wohneinheitId); + + // Fetch current state + $currentSql = "SELECT status_id, tuer, zusatz, splice_hak_completed FROM Wohneinheit WHERE id = $escapedWohneinheitId"; + $result = $db->query($currentSql); + $current = $result ? $result->fetch_assoc() : null; + + if (!$current) self::sendError("Wohneinheit nicht gefunden."); + + $oldStatusId = intval($current['status_id']); + $oldTuer = $current['tuer']; + $oldZusatz = $current['zusatz']; + $oldSplice = intval($current['splice_hak_completed'] ?? 0); + + // Update Wohneinheit + $escapedTuer = $tuer !== null ? "'" . $db->escape($tuer) . "'" : "NULL"; + $escapedZusatz = $zusatz !== null ? "'" . $db->escape($zusatz) . "'" : "NULL"; + $escapedStatusId = $db->escape($newStatusId); + $escapedSplice = $db->escape($spliceCompleted); + + $updateSql = "UPDATE Wohneinheit SET + status_id = $escapedStatusId, + tuer = $escapedTuer, + zusatz = $escapedZusatz, + splice_hak_completed = $escapedSplice + WHERE id = $escapedWohneinheitId"; + + $db->query($updateSql); + + // Journaling + $changes = []; + if ($oldStatusId !== $newStatusId) { + // Fetch status names for better logging + $statusNamesSql = "SELECT id, code, name FROM Status WHERE id IN ($oldStatusId, $newStatusId)"; + $statusRes = $db->query($statusNamesSql); + $statusMap = []; + if ($statusRes) { + while($row = $statusRes->fetch_assoc()) { + $statusMap[$row['id']] = $row['code'] . ' - ' . $row['name']; + } + } + $oldText = $statusMap[$oldStatusId] ?? "ID $oldStatusId"; + $newText = $statusMap[$newStatusId] ?? "ID $newStatusId"; + $changes[] = "Status: $oldText → $newText"; + } + + if ($oldSplice !== $spliceCompleted) { + $changes[] = "Spleiß: " . ($spliceCompleted ? 'Erledigt' : 'Nicht erledigt'); + } + + if ($oldTuer !== $tuer) { + $changes[] = "Tür aktualisiert: '$oldTuer' -> '$tuer'"; + } + if ($oldZusatz !== $zusatz) { + $changes[] = "Zusatz aktualisiert: '$oldZusatz' -> '$zusatz'"; + } + + if (!empty($changes)) { + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorderMphId, + 'text' => "Wohneinheit $wohneinheitId: " . implode(', ', $changes), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + } + + // Status flag logic for BEP MD (241) and ONT (300). Need to check codes for these IDs. + // Since we only have IDs, we need to check the code of the newStatusId. + $newStatusCodeSql = "SELECT code FROM Status WHERE id = $escapedStatusId"; + $resCode = $db->query($newStatusCodeSql); + $newStatusCode = $resCode ? intval($resCode->fetch_assoc()['code']) : 0; + + if (in_array($newStatusCode, [241, 300])) { // 241=BEP MD, 300=ONT + $this->setWohneinheitStatusflag($wohneinheitId, 200); + } + + self::returnJson(['success' => true, 'message' => 'Wohneinheit aktualisiert.']); + } + + /** + * Set statusflag on Wohneinheit in addressdb + */ + private function setWohneinheitStatusflag(int $wohneinheitId, int $statusflagId) + { + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $weId = $db->escape($wohneinheitId); + $sfId = $db->escape($statusflagId); + + // Check if statusflag already exists + $checkSql = "SELECT COUNT(*) as count FROM WohneinheitStatusflagValue WHERE wohneinheit_id = $weId AND statusflag_id = $sfId"; + $result = $db->query($checkSql); + $exists = $result->fetch_assoc()['count'] > 0; + + if (!$exists) { + $insertSql = "INSERT INTO WohneinheitStatusflagValue (wohneinheit_id, statusflag_id, create, createBy) + VALUES ($weId, $sfId, " . time() . ", " . $this->user->id . ")"; + $db->query($insertSql); + } + } + + /** + * Get documents for a specific Wohneinheit + */ + protected function getWohneinheitDocumentsAction() + { + if (empty($this->request->wohneinheitId)) self::sendError("Wohneinheit-ID fehlt."); + + $wohneinheitId = intval($this->request->wohneinheitId); + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + + $sql = "SELECT * FROM WohneinheitDocumentation WHERE wohneinheit_id = " . $db->escape($wohneinheitId) . " ORDER BY `create` ASC"; + $result = $db->query($sql); + $docs = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + $responseDocs = []; + foreach ($docs as $doc) { + $file = new File($doc['fileId']); + $responseDocs[] = [ + 'id' => $doc['id'], + 'fileId' => $doc['fileId'], + 'fileName' => $file->orig_filename ?? $file->filename, + 'description' => $doc['description'], + 'documentType' => $doc['documentType'], + 'userName' => UserModel::getOne($doc['createBy'])->name ?? 'Unbekannt', + 'mimetype' => $file->mimetype ?? 'application/octet-stream', + 'create' => $doc['create'] + ]; + } + + self::returnJson(['docs' => $responseDocs]); + } + + /** + * Upload document for a specific Wohneinheit + */ + protected function uploadWohneinheitDocumentAction() + { + if (empty($_FILES['file']) || empty($_POST['wohneinheitId'])) { + self::sendError("Datei und Wohneinheit-ID sind erforderlich."); + } + + $wohneinheitId = intval($_POST['wohneinheitId']); + $documentType = $_POST['documentType'] ?? 'photo'; + $description = $_POST['description'] ?? null; + + // Upload file using mfUpload handleFormUpload for proper handling + try { + $upload = mfUpload::handleFormUpload("file", false, "/WorkorderMph/Wohneinheit"); + $file = $upload; // handleFormUpload returns the File object + } catch (Exception $e) { + self::sendError("Datei-Upload fehlgeschlagen: " . $e->getMessage()); + return; + } + + // Insert into WohneinheitDocumentation table in addressdb + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $escapedWohneinheitId = $db->escape($wohneinheitId); + $escapedFileId = $db->escape($file->id); + $escapedDescription = $description ? "'" . $db->escape($description) . "'" : "NULL"; + $escapedDocumentType = "'" . $db->escape($documentType) . "'"; + $escapedCreateBy = $db->escape($this->user->id); + $escapedCreate = time(); + + $sql = "INSERT INTO WohneinheitDocumentation (wohneinheit_id, fileId, description, documentType, `create`, createBy) + VALUES ($escapedWohneinheitId, $escapedFileId, $escapedDescription, $escapedDocumentType, $escapedCreate, $escapedCreateBy)"; + $db->query($sql); + + self::returnJson(['success' => true, 'message' => 'Dokument erfolgreich hochgeladen.', 'fileId' => $file->id]); + } + + /** + * Delete document for a specific Wohneinheit + */ + protected function deleteWohneinheitDocumentAction() + { + if (empty($this->postData['documentationId'])) self::sendError("Dokumentations-ID fehlt."); + + $documentationId = intval($this->postData['documentationId']); + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + + $escapedId = $db->escape($documentationId); + $sql = "DELETE FROM WohneinheitDocumentation WHERE id = $escapedId"; + $db->query($sql); + + self::returnJson(['success' => true, 'message' => 'Dokument gelöscht.']); + } + + /** + * Update checkbox documentation fields + */ + protected function updateCheckboxesAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorder = WorkorderMphModel::get($post['workorderMphId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $changes = []; + $checkboxFields = [ + 'easement' => 'Leitungsrecht', + 'btb' => 'Bautechnische Begehung', + 'fttxLocationSupplied' => 'FTTx Location mit Leerrohr versorgt', + 'conduitToHuepLaid' => 'Leerrohr bis HÜP/HAK verlegt', + 'huepMounted' => 'HÜP/HAK montiert', + 'dropCableAvailable' => 'Dropkabel vorhanden', + 'spliceCompleted' => 'Spleiß abgeschlossen' + ]; + + $updateHausnummerStatus = false; + + foreach ($checkboxFields as $field => $fieldLabel) { + if (array_key_exists($field, $post)) { + $oldValue = $workorder->$field; + $newValue = $post[$field] ? 1 : 0; + if ($oldValue !== $newValue) { + $workorder->$field = $newValue; + // Only log changes where newValue is 'ja' or oldValue was 'ja' (changing from yes to no) + if ($newValue === 1 || $oldValue === 1) { + $changes[] = "$fieldLabel: " . ($newValue ? 'ja' : 'nein'); + } + + // Check for FTTx Location mit Leerrohr versorgt + if ($field === 'fttxLocationSupplied' && $newValue === 1) { + $updateHausnummerStatus = true; + } + } + } + } + + if (!empty($changes)) { + WorkorderMphModel::update((array)$workorder); + + if ($updateHausnummerStatus) { + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + // Find status ID for code 200 + $statusSql = "SELECT id FROM Status WHERE code = 200 AND type = 'hausnummer' LIMIT 1"; + $statusResult = $db->query($statusSql); + if ($statusResult && $row = $statusResult->fetch_assoc()) { + $statusId = $row['id']; + $hnId = $db->escape($workorder->hausnummerId); + $updateHnSql = "UPDATE Hausnummer SET status_id = $statusId WHERE id = $hnId"; + $db->query($updateHnSql); + } + } + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Dokumentation aktualisiert:\n" . implode("\n", $changes), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + } + + self::returnJson(['success' => true, 'message' => 'Dokumentation aktualisiert.']); + } + //endregion +} \ No newline at end of file diff --git a/application/WorkorderMphCompany/WorkorderMphCompanyController.php b/application/WorkorderMphCompany/WorkorderMphCompanyController.php new file mode 100644 index 000000000..6e52b88ca --- /dev/null +++ b/application/WorkorderMphCompany/WorkorderMphCompanyController.php @@ -0,0 +1,301 @@ + 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']], + ['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => false]], + ['key' => 'rimoFcpName', 'text' => 'FCP', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]], + ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => false]], + ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], + ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], + ]; + protected array $additionalJSVariables = ['COMPANY_ID' => '0', 'IS_COMPANY_VIEW' => true]; + + protected function prepareCrudConfig() + { + $hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key')); + array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]); + + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + $this->additionalJSVariables['COMPANY_ID'] = $company ? $company->id : 0; + + // Populate netzgebiet filter options for this company's workorders + $netzgebietColIdx = array_search('netzgebietName', array_column($this->columns, 'key')); + if ($netzgebietColIdx !== false && $company) { + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $fronkDbName = FRONKDB_DBNAME; + + $sql = "SELECT DISTINCT ng.id, ng.name FROM Netzgebiet ng + INNER JOIN Hausnummer hn ON ng.id = hn.netzgebiet_id + INNER JOIN `$fronkDbName`.`WorkorderMph` wm ON wm.hausnummerId = hn.id + WHERE ng.name IS NOT NULL AND ng.name != '' AND wm.companyId = " . intval($company->id) . " + ORDER BY ng.name ASC"; + $result = $db->query($sql); + $netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $this->columns[$netzgebietColIdx]['table']['filterOptions'] = array_map(fn($ng) => ['value' => $ng['id'], 'text' => $ng['name']], $netzgebiete); + } + } + + protected function getAction() + { + $pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10]; + $filters = $this->postData['filters'] ?? []; + $order = $this->postData['order'] ?? []; + + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company) { + self::returnJson(['rows' => [], 'pagination' => array_merge($pagination, ['total_rows' => 0, 'total_pages' => 0, 'filtered_available' => 0])]); + return; + } + + $db = FronkDB::singleton(); + $fronkDbName = FRONKDB_DBNAME; + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + + $whereClauses = "WHERE w.companyId = " . intval($company->id); + + if (empty($filters['status'])) { + $whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')"; + } else { + $whereClauses .= Helper::generateFilterCondition($filters['status'], 'w.status', true); + } + + if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true); + if (!empty($filters['hausnummerInfo'])) { + $searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo"; + $whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns); + } + if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.id'); + if (!empty($filters['rimoFcpName'])) $whereClauses .= Helper::generateFilterCondition($filters['rimoFcpName'], 'hn.rimo_fcp_name'); + if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate'); + if (!empty($filters['appointmentDate'])) $whereClauses .= Helper::generateFilterCondition($filters['appointmentDate'], 'w.appointmentDate'); + if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo'); + + $sql = " + SELECT + w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo, + CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo, + str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city, + ng.id as netzgebietName, + hn.rimo_fcp_name as rimoFcpName, + (SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount + FROM `$fronkDbName`.`WorkorderMph` w + LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = 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`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + $whereClauses + "; + + $orderBy = ""; + if (!empty($order['key'])) { + $sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate']; + if (in_array($order['key'], $sortableColumns)) { + $sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC'; + $orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder; + } + } + if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC"; + + $sql .= $orderBy; + + // Get total count + $countSql = "SELECT COUNT(*) as count FROM `$fronkDbName`.`WorkorderMph` w + LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = 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`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + $whereClauses"; + $totalCount = (int)$db->query($countSql)->fetch_assoc()['count']; + + // Add pagination + if ($pagination['per_page'] !== null) { + $sql .= " LIMIT " . intval($pagination['per_page']) . " OFFSET " . intval(($pagination['page'] - 1) * $pagination['per_page']); + } + + $result = $db->query($sql); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + + self::returnJson([ + 'rows' => $rows, + 'pagination' => [ + 'page' => (int)$pagination['page'], + 'per_page' => (int)$pagination['per_page'], + 'total_rows' => $totalCount, + 'total_pages' => (int)ceil($totalCount / $pagination['per_page']), + 'filtered_available' => $totalCount + ] + ]); + } + + public function getWorkorderByIdAction() + { + if (empty($this->request->id)) self::sendError("ID fehlt"); + $workorder = WorkorderMphModel::get($this->request->id); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden"); + self::returnJson((array)$workorder); + } + + protected function scheduleAppointmentAction() + { + if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate'])) self::sendError("Erforderliche Felder fehlen."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden"); + if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!"); + + $oldStatus = $workorder->status; + $workorder->appointmentDate = $this->postData['appointmentDate']; + $workorder->status = 'scheduled'; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $this->postData['appointmentDate']), + 'statusChange' => $oldStatus !== 'scheduled' ? $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('scheduled') : null, + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']); + } + + protected function rescheduleAppointmentAction() + { + if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate']) || empty($this->postData['reason'])) self::sendError("Erforderliche Felder fehlen."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!"); + + $oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A'; + $newDateFormatted = date('d.m.Y H:i', $this->postData['appointmentDate']); + $workorder->appointmentDate = $this->postData['appointmentDate']; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Termin verschoben von {$oldDateFormatted} auf {$newDateFormatted}. Grund: " . $this->postData['reason'], + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']); + } + + protected function startWorkAction() + { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldStatus = $workorder->status; + $workorder->status = 'in_progress'; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Arbeit begonnen.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('in_progress'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Arbeit wurde gestartet.']); + } + + protected function completeWorkorderAction() + { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderMphModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $oldStatus = $workorder->status; + $workorder->status = 'documented'; + WorkorderMphModel::update((array)$workorder); + + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => 'Arbeitsauftrag abgeschlossen und dokumentiert.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich abgeschlossen.']); + } + + protected function uploadDocumentationAction() + { + if (empty($_FILES['file']) || empty($_POST['workorderMphId'])) self::sendError("Datei und Arbeitsauftrags-ID sind erforderlich."); + + $workorderMphId = intval($_POST['workorderMphId']); + $workorder = WorkorderMphModel::get($workorderMphId); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $documentType = $_POST['documentType'] ?? 'photo'; + $description = $_POST['description'] ?? null; + + // Upload file using mfUpload + $upload = new mfUpload($_FILES['file']); + if (!$upload->upload()) { + self::sendError("Datei-Upload fehlgeschlagen."); + } + + $file = $upload->getFile(); + + WorkorderMphDocumentationModel::create([ + 'workorderMphId' => $workorderMphId, + 'fileId' => $file->id, + 'description' => $description, + 'documentType' => $documentType, + 'create' => time(), + 'createBy' => $this->user->id + ]); + + self::returnJson(['success' => true, 'message' => 'Dokument erfolgreich hochgeladen.', 'fileId' => $file->id]); + } + + protected function deleteDocumentationAction() + { + if (empty($this->postData['documentationId'])) self::sendError("Dokumentations-ID fehlt."); + + $doc = WorkorderMphDocumentationModel::get($this->postData['documentationId']); + if (!$doc) self::sendError("Dokumentation nicht gefunden."); + + WorkorderMphDocumentationModel::delete($doc->id); + self::returnJson(['success' => true, 'message' => 'Dokumentation gelöscht.']); + } + + protected function updateAdditionalInfoAction() + { + if (empty($this->postData['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + + $workorder = WorkorderMphModel::get($this->postData['workorderMphId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + // Verify company access + $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if (!$company || $workorder->companyId != $company->id) { + self::sendError("Keine Berechtigung für diesen Arbeitsauftrag."); + } + + $oldInfo = $workorder->additionalInfo; + $newInfo = $this->postData['additionalInfo'] ?? ''; + $workorder->additionalInfo = $newInfo; + WorkorderMphModel::update((array)$workorder); + + if ($oldInfo !== $newInfo) { + WorkorderMphJournalModel::create([ + 'workorderMphId' => $workorder->id, + 'text' => "Notiz geändert: " . ($newInfo ?: '(leer)'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + } + + self::returnJson(['success' => true, 'message' => 'Notiz aktualisiert.', 'newInfo' => $newInfo]); + } +} diff --git a/application/WorkorderMphDocumentation/WorkorderMphDocumentationModel.php b/application/WorkorderMphDocumentation/WorkorderMphDocumentationModel.php new file mode 100644 index 000000000..2e0c9460b --- /dev/null +++ b/application/WorkorderMphDocumentation/WorkorderMphDocumentationModel.php @@ -0,0 +1,12 @@ +table("ConstructionConsentOwner"); $cco->changeColumn("status", "enum", ["null" => true, "default" => null, "values" => "new,requested,answered"]); $cco->changeColumn("result", "enum", ["null" => true, "default" => null, "values" => "success,failure"]); + $cco->update(); $this->execute("UPDATE ConstructionConsentOwner SET status=NULL, result=NULL"); $cco->changeColumn("status", "enum", ["null" => true, "default" => null, "values" => "new,sent,returned,outstanding"]); diff --git a/db/migrations/20250131150000_warehouse_modify_10.php b/db/migrations/20250131150000_warehouse_modify_10.php index 15ee86a77..7dc8b59a3 100644 --- a/db/migrations/20250131150000_warehouse_modify_10.php +++ b/db/migrations/20250131150000_warehouse_modify_10.php @@ -5,7 +5,12 @@ use Phinx\Migration\AbstractMigration; final class WarehouseModify10 extends AbstractMigration { public function up(): void { if ($this->getEnvironment() == "thetool") { - // Drop the existing tables + // Remove foreign keys and drop the existing tables + $this->table("WarehouseOrderItem") + ->dropForeignKey("orderId") + ->dropForeignKey("articleId") + ->update(); + $this->table("WarehouseOrder")->drop()->save(); $this->table("WarehouseOrderItem")->drop()->save(); diff --git a/db/migrations/20250612124000_add_new_indexes_adb.php b/db/migrations/20250612124000_add_new_indexes_adb.php index 5fce29b7e..4c810a0b2 100644 --- a/db/migrations/20250612124000_add_new_indexes_adb.php +++ b/db/migrations/20250612124000_add_new_indexes_adb.php @@ -14,7 +14,7 @@ final class AddNewIndexesAdb extends AbstractMigration ->save(); $wohneinheit->removeIndexByName('extref') - ->addIndex('extref', ['name' => 'idx_extref_full']) + ->addIndex('extref', ['name' => 'idx_extref_full', 'limit' => 32]) ->save(); } diff --git a/db/migrations/20250715110000_warehouse_offer_versioning.php b/db/migrations/20250715110000_warehouse_offer_versioning.php index 81871b356..b1f7e59cb 100644 --- a/db/migrations/20250715110000_warehouse_offer_versioning.php +++ b/db/migrations/20250715110000_warehouse_offer_versioning.php @@ -49,6 +49,7 @@ final class WarehouseOfferVersioning extends AbstractMigration // Use Phinx schema builder to add columns to the WarehouseOffer table $warehouseOffer = $this->table('WarehouseOffer'); $warehouseOffer + ->addColumn('contactPerson', 'string', ['limit' => 255, 'null' => true, "after" => 'customerName']) ->addColumn('contactPersonEmail', 'string', ['limit' => 255, 'null' => true, 'after' => 'contactPerson']) ->addColumn('lastSentDate', 'integer', ['null' => true, 'after' => 'status']) ->addColumn('version', 'integer', ['default' => 1, 'after' => 'id']) diff --git a/db/migrations/20251111171837_termination_resize_code.php b/db/migrations/20251111171837_termination_resize_code.php new file mode 100644 index 000000000..6507c09d6 --- /dev/null +++ b/db/migrations/20251111171837_termination_resize_code.php @@ -0,0 +1,36 @@ +getEnvironment() == "thetool") { + $table = $this->table("Termination"); + $table->changeColumn("code", "string", ["length" => 64, "null" => true, "default" => null]); + $table->update(); + + $table = $this->table("Building"); + $table->changeColumn("code", "string", ["length" => 64, "null" => true, "default" => null]); + $table->changeColumn("oaid", "string", ["length" => 255, "null" => true, "default" => null]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20251111201335_billing_voicenumber_add_zone_id.php b/db/migrations/20251111201335_billing_voicenumber_add_zone_id.php new file mode 100644 index 000000000..611f2108f --- /dev/null +++ b/db/migrations/20251111201335_billing_voicenumber_add_zone_id.php @@ -0,0 +1,31 @@ +getEnvironment() == "thetool") { + $table = $this->table("BillingVoicenumber"); + $table->addColumn("zone_id", "integer", ["null" => true, "default" => null, "after" => "voiceplan"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("BillingVoicenumber")->removeColumn("zone_id")->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20251116120000_create_workorder_mph_tables.php b/db/migrations/20251116120000_create_workorder_mph_tables.php new file mode 100644 index 000000000..372d96bd2 --- /dev/null +++ b/db/migrations/20251116120000_create_workorder_mph_tables.php @@ -0,0 +1,94 @@ +getEnvironment() == "thetool") { + $table = $this->table("WorkerPermission"); + $table->addColumn("canWorkorderMphAdmin", "enum", [ + "null" => false, + "values" => ['false', 'true'], + "default" => "false", + ]); + $table->update(); + + $workorderMph = $this->table('WorkorderMph', ['id' => 'id', 'primary_key' => 'id']); + $workorderMph + ->addColumn('hausnummerId', 'integer', ['null' => false]) + ->addColumn('companyId', 'integer', ['null' => true]) + ->addColumn('status', 'string', ['limit' => 50, 'null' => false, 'default' => 'new']) + ->addColumn('assignmentDate', 'integer', ['null' => true]) + ->addColumn('deadlineDate', 'integer', ['null' => true]) + ->addColumn('appointmentDate', 'integer', ['null' => true]) + ->addColumn('additionalInfo', 'text', ['null' => true]) + ->addColumn('easement', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Leitungsrecht']) + ->addColumn('btb', 'boolean', ['null' => true, 'default' => null]) + ->addColumn('fttxLocationSupplied', 'boolean', ['null' => true, 'default' => null, 'comment' => 'FTTx Location mit Leerrohr versorgt']) + ->addColumn('conduitToHuepLaid', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Leerrohr bis HÜP/HAK verlegt']) + ->addColumn('huepMounted', 'boolean', ['null' => true, 'default' => null, 'comment' => 'HÜP/HAK montiert']) + ->addColumn('dropCableAvailable', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Dropkabel vorhanden']) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['hausnummerId'], ['name' => 'hausnummerId_idx']) + ->addIndex(['companyId'], ['name' => 'companyId_mph_idx']) + ->addIndex(['status'], ['name' => 'status_mph_idx']) + ->create(); + + $workorderMphWohneinheit = $this->table('WorkorderMphWohneinheit', ['id' => 'id', 'primary_key' => 'id']); + $workorderMphWohneinheit + ->addColumn('workorderMphId', 'integer', ['null' => false]) + ->addColumn('wohneinheitId', 'integer', ['null' => false]) + ->addColumn('status', 'integer', ['null' => false, 'default' => 1, 'comment' => '1=new, 12=241 BEP MD, 13=242 Inhouse, 18=243 Stairwell, 14=244 BEP SD, 15=245 Approved, 16=300 ONT']) + ->addColumn('note', 'text', ['null' => true]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addColumn('edit', 'integer', ['null' => true]) + ->addColumn('editBy', 'integer', ['null' => true]) + ->addIndex(['workorderMphId'], ['name' => 'workorderMphId_idx']) + ->addIndex(['wohneinheitId'], ['name' => 'wohneinheitId_idx']) + ->addIndex(['workorderMphId', 'wohneinheitId'], ['unique' => true, 'name' => 'workorder_wohneinheit_unique']) + ->create(); + + $workorderMphJournal = $this->table('WorkorderMphJournal', ['id' => false, 'primary_key' => ['id']]); + $workorderMphJournal + ->addColumn('id', 'integer', ['identity' => true, 'signed' => true]) + ->addColumn('workorderMphId', 'integer', ['null' => false]) + ->addColumn('text', 'text', ['null' => true]) + ->addColumn('fileIds', 'json', ['null' => true]) + ->addColumn('statusChange', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['workorderMphId'], ['name' => 'workorderMphId_idx']) + ->create(); + + $workorderMphDocumentation = $this->table('WorkorderMphDocumentation', ['id' => 'id', 'primary_key' => 'id']); + $workorderMphDocumentation + ->addColumn('workorderMphId', 'integer', ['null' => false]) + ->addColumn('fileId', 'integer', ['null' => false]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('documentType', 'string', ['limit' => 100, 'null' => false]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['workorderMphId'], ['name' => 'workorderMphId_idx']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WorkorderMphDocumentation')->drop()->save(); + $this->table('WorkorderMphJournal')->drop()->save(); + $this->table('WorkorderMphWohneinheit')->drop()->save(); + $this->table('WorkorderMph')->drop()->save(); + + $table = $this->table("WorkerPermission"); + $table->removeColumn("canWorkorderMphAdmin"); + $table->save(); + } + } +} diff --git a/db/migrations/20251117145123_add_splice_completed_to_workorder_mph.php b/db/migrations/20251117145123_add_splice_completed_to_workorder_mph.php new file mode 100644 index 000000000..8cda96ea5 --- /dev/null +++ b/db/migrations/20251117145123_add_splice_completed_to_workorder_mph.php @@ -0,0 +1,30 @@ +getEnvironment() == "thetool") { + $table = $this->table('WorkorderMph'); + $table->addColumn('spliceCompleted', 'boolean', [ + 'null' => true, + 'default' => null, + 'comment' => 'Spleiß im HÜP/HAK erledigt', + 'after' => 'dropCableAvailable' + ]); + $table->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WorkorderMph'); + $table->removeColumn('spliceCompleted'); + $table->save(); + } + } +} diff --git a/db/migrations/20251125100000_warehouse_offer_add_validity.php b/db/migrations/20251125100000_warehouse_offer_add_validity.php new file mode 100644 index 000000000..0113dec77 --- /dev/null +++ b/db/migrations/20251125100000_warehouse_offer_add_validity.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + $table = $this->table('WarehouseOffer'); + if (!$table->hasColumn('validity')) { + $table->addColumn('validity', 'integer', [ + 'null' => true, + 'default' => null, + 'after' => 'status', + ]); + $table->update(); + } + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WarehouseOffer'); + if ($table->hasColumn('validity')) { + $table->removeColumn('validity'); + $table->update(); + } + } + } +} diff --git a/db/migrations/20251125110000_create_warehouse_project.php b/db/migrations/20251125110000_create_warehouse_project.php new file mode 100644 index 000000000..0fc46c5f4 --- /dev/null +++ b/db/migrations/20251125110000_create_warehouse_project.php @@ -0,0 +1,61 @@ +getEnvironment() == "thetool") { + // Main Project Table + $projects = $this->table('WarehouseProject'); + $projects->addColumn('projectNumber', 'string', ['limit' => 20, 'comment' => 'XP-YYYY-NNNN']) + ->addColumn('title', 'string', ['limit' => 255]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('startDate', 'integer', ['null' => true]) + ->addColumn('endDate', 'integer', ['null' => true]) + ->addColumn('status', 'enum', ['values' => ['new', 'wip', 'finished', 'cancelled'], 'default' => 'new']) + ->addColumn('financials', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => 0.00]) + ->addColumn('storageLocation', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('externalTeam', 'text', ['null' => true, 'comment' => 'Free text for external participants']) + ->addColumn('createdFromOrderId', 'integer', ['null' => true, 'signed' => false]) // FIX: Likely needs to match Order ID + ->addColumn('createBy', 'integer', ['signed' => false]) // FIX: Likely references a User ID + ->addColumn('create', 'integer') + ->addIndex(['projectNumber'], ['unique' => true]) + ->create(); + + // Tasks Table + $tasks = $this->table('WarehouseProjectTask'); + $tasks->addColumn('projectId', 'integer', ['signed' => false]) // <--- FIX HERE + ->addColumn('parentTaskId', 'integer', ['null' => true, 'signed' => false]) // FIX: Good practice for self-referencing FKs + ->addColumn('title', 'string', ['limit' => 255]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('status', 'enum', ['values' => ['todo', 'in_progress', 'done'], 'default' => 'todo']) + ->addColumn('assignedUserId', 'integer', ['null' => true, 'signed' => false]) // FIX: Matches User ID + ->addColumn('order', 'integer', ['default' => 0]) + ->addColumn('createBy', 'integer', ['signed' => false]) // FIX + ->addColumn('create', 'integer') + ->addForeignKey('projectId', 'WarehouseProject', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + + // Project Members (Internal Team) + $members = $this->table('WarehouseProjectMember'); + $members->addColumn('projectId', 'integer', ['signed' => false]) // <--- FIX HERE + ->addColumn('userId', 'integer', ['signed' => false]) // FIX + ->addColumn('role', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('create', 'integer') + ->addForeignKey('projectId', 'WarehouseProject', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + + // Link table for Order Requests + $orderRequests = $this->table('WarehouseProjectOrderRequest'); + $orderRequests->addColumn('projectId', 'integer', ['signed' => false]) // <--- FIX HERE + ->addColumn('orderRequestId', 'integer', ['signed' => false]) // FIX + ->addColumn('create', 'integer') + ->addForeignKey('projectId', 'WarehouseProject', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addForeignKey('orderRequestId', 'WarehouseOrderRequest', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + } + } +} diff --git a/db/migrations/20251125120000_create_warehouse_project_journal.php b/db/migrations/20251125120000_create_warehouse_project_journal.php new file mode 100644 index 000000000..6cbe1d8ae --- /dev/null +++ b/db/migrations/20251125120000_create_warehouse_project_journal.php @@ -0,0 +1,21 @@ +getEnvironment() == "thetool") { + $table = $this->table('WarehouseProjectJournal'); + $table->addColumn('projectId', 'integer', ['signed' => false]) // Fixed: Matches unsigned ID + ->addColumn('text', 'text', ['null' => true]) + ->addColumn('data', 'json', ['null' => true, 'comment' => 'Stores changes/diffs']) + ->addColumn('createBy', 'integer', ['signed' => false]) // Fixed: Best practice for User IDs + ->addColumn('create', 'integer') + ->addForeignKey('projectId', 'WarehouseProject', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->create(); + } + } +} diff --git a/db/migrations/20251126090000_add_splice_completed_to_workorder_mph_wohneinheit.php b/db/migrations/20251126090000_add_splice_completed_to_workorder_mph_wohneinheit.php new file mode 100644 index 000000000..cec24c5af --- /dev/null +++ b/db/migrations/20251126090000_add_splice_completed_to_workorder_mph_wohneinheit.php @@ -0,0 +1,36 @@ +getEnvironment() == "thetool") { + $table = $this->table('WorkorderMphWohneinheit'); + if (!$table->hasColumn('spliceCompleted')) { + $table->addColumn('spliceCompleted', 'boolean', [ + 'null' => true, + 'default' => 0, + 'comment' => 'Spleiß im HÜP/HAK erledigt', + 'after' => 'status' + ]); + $table->update(); + } + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WorkorderMphWohneinheit'); + if ($table->hasColumn('spliceCompleted')) { + $table->removeColumn('spliceCompleted'); + $table->save(); + } + } + } +} + +//SQL: ALTER TABLE `WorkorderMphWohneinheit` ADD COLUMN `spliceCompleted` TINYINT(1) NULL DEFAULT 0 COMMENT 'Spleiß im HÜP/HAK erledigt' AFTER `status`; \ No newline at end of file diff --git a/db/migrations/20251126100000_refactor_workorder_mph_wohneinheit.php b/db/migrations/20251126100000_refactor_workorder_mph_wohneinheit.php new file mode 100644 index 000000000..ed686d5f4 --- /dev/null +++ b/db/migrations/20251126100000_refactor_workorder_mph_wohneinheit.php @@ -0,0 +1,60 @@ +getEnvironment() == "addressdb") { + $table = $this->table('Wohneinheit'); + if (!$table->hasColumn('splice_hak_completed')) { + $table->addColumn('splice_hak_completed', 'boolean', [ + 'null' => true, + 'default' => 0, + 'after' => 'external_data' + ]); + $table->update(); + } + } + + // TheTool modifications + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WorkorderMphWohneinheit'); + if ($table->exists()) { + $table->drop()->save(); + } + } + } + + public function down(): void + { + // AddressDB modifications + if ($this->getEnvironment() == "addressdb") { + $table = $this->table('Wohneinheit'); + if ($table->hasColumn('splice_hak_completed')) { + $table->removeColumn('splice_hak_completed'); + $table->update(); + } + } + + // TheTool modifications + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WorkorderMphWohneinheit'); + if (!$table->exists()) { + $table->addColumn('workorderMphId', 'integer') + ->addColumn('wohneinheitId', 'integer') + ->addColumn('status', 'integer') + ->addColumn('spliceCompleted', 'boolean', ['null' => true, 'default' => 0]) + ->addColumn('note', 'text', ['null' => true]) + ->addColumn('create', 'integer') + ->addColumn('createBy', 'integer') + ->addColumn('edit', 'integer', ['null' => true]) + ->addColumn('editBy', 'integer', ['null' => true]) + ->create(); + } + } + } +} \ No newline at end of file diff --git a/db/migrations/20251201120000_create_manual_invoice_tables.php b/db/migrations/20251201120000_create_manual_invoice_tables.php new file mode 100644 index 000000000..22d47919d --- /dev/null +++ b/db/migrations/20251201120000_create_manual_invoice_tables.php @@ -0,0 +1,110 @@ +getEnvironment() == "thetool") { + // Create ManualInvoice table + $manualInvoice = $this->table("ManualInvoice"); + + $manualInvoice->addColumn("invoice_number", "string", ["null" => true, "default" => null]); + $manualInvoice->addColumn("invoice_date", "integer", ["default" => 0]); + $manualInvoice->addColumn("owner_id", "integer", ["null" => false]); + $manualInvoice->addColumn("billingaddress_id", "integer", ["null" => false]); + $manualInvoice->addColumn("customer_number", "integer", ["null" => false]); + $manualInvoice->addColumn("fibu_account_number", "integer", ["null" => true, "default" => null]); + $manualInvoice->addColumn("fibu_payment_due", "integer", ["null" => true, "default" => null]); + $manualInvoice->addColumn("fibu_payment_skonto", "integer", ["null" => false, "default" => 0]); + $manualInvoice->addColumn("fibu_payment_skonto_rate", "integer", ["null" => false, "default" => 0]); + $manualInvoice->addColumn("sepa_date", "date", ["null" => true, "default" => null]); + $manualInvoice->addColumn("sepa_id", "string", ["null" => true, "default" => null, "length" => 255]); + $manualInvoice->addColumn("sepa_last_date", "date", ["null" => true, "default" => null]); + $manualInvoice->addColumn("fibu_cost_area", "string", ["null" => true, "default" => null, "length" => 255]); + $manualInvoice->addColumn("fibu_cost_account", "integer", ["null" => true, "default" => null]); + $manualInvoice->addColumn("fibu_cost_account_legacy", "integer", ["null" => true, "default" => null]); + $manualInvoice->addColumn("fibu_taxcode", "integer", ["null" => true, "default" => null]); + $manualInvoice->addColumn("tax_text", "string", ["null" => true, "default" => null, "length" => 255]); + $manualInvoice->addColumn("company", "string", ["null" => true, "default" => null, "length" => 1024]); + $manualInvoice->addColumn("firstname", "string", ["null" => true, "default" => null, "length" => 1024]); + $manualInvoice->addColumn("lastname", "string", ["null" => true, "default" => null, "length" => 1024]); + $manualInvoice->addColumn("street", "string", ["null" => false, "length" => 1024]); + $manualInvoice->addColumn("zip", "string", ["null" => false, "length" => 1024]); + $manualInvoice->addColumn("city", "string", ["null" => false, "length" => 1024]); + $manualInvoice->addColumn("country", "string", ["null" => true, "default" => null, "length" => 1024]); + $manualInvoice->addColumn("email", "string", ["null" => true, "default" => null, "length" => 1024]); + $manualInvoice->addColumn("uid", "string", ["null" => true, "default" => null, "length" => 1024]); + $manualInvoice->addColumn("billing_type", "enum", ["null" => false, "values" => "invoice,sepa"]); + $manualInvoice->addColumn("billing_delivery", "enum", ["null" => false, "values" => "email,paper"]); + $manualInvoice->addColumn("bank_account_bank", "string", ["null" => true, "default" => null, "length" => 255]); + $manualInvoice->addColumn("bank_account_owner", "string", ["null" => true, "default" => null, "length" => 255]); + $manualInvoice->addColumn("bank_account_iban", "string", ["null" => true, "default" => null, "length" => 255]); + $manualInvoice->addColumn("bank_account_bic", "string", ["null" => true, "default" => null, "length" => 255]); + $manualInvoice->addColumn("total", "decimal", ["null" => false, "precision" => 14, "scale" => 4]); + $manualInvoice->addColumn("total_gross", "decimal", ["null" => false, "precision" => 14, "scale" => 4]); + $manualInvoice->addColumn("vatgroup_id", "integer", ["null" => false]); + $manualInvoice->addColumn("bmd_export_date", "integer", ["null" => true, "default" => null]); + $manualInvoice->addColumn("date_delivered", "integer", ["null" => true, "default" => null]); + $manualInvoice->addColumn("create_by", "integer", ["null" => false]); + $manualInvoice->addColumn("edit_by", "integer", ["null" => false]); + $manualInvoice->addColumn("create", "integer", ["null" => false]); + $manualInvoice->addColumn("edit", "integer", ["null" => false]); + + $manualInvoice->addIndex(["invoice_number"], ["name" => "invoice_number"]); + $manualInvoice->addIndex(["invoice_date"], ["name" => "invoice_date"]); + $manualInvoice->addIndex(["owner_id"], ["name" => "owner_id"]); + $manualInvoice->addIndex(["billingaddress_id"], ["name" => "billingaddress_id"]); + $manualInvoice->addIndex(["customer_number"], ["name" => "customer_number"]); + + $manualInvoice->create(); + + // Create ManualInvoiceposition table + $manualInvoicePosition = $this->table("ManualInvoiceposition"); + + $manualInvoicePosition->addColumn("manualinvoice_id", "integer", ["null" => true, "default" => null]); + $manualInvoicePosition->addColumn("billing_id", "integer", ["null" => true, "default" => null]); + $manualInvoicePosition->addColumn("contract_id", "integer", ["null" => false]); + $manualInvoicePosition->addColumn("start_date", "date", ["null" => false]); + $manualInvoicePosition->addColumn("end_date", "date", ["null" => true, "default" => null]); + $manualInvoicePosition->addColumn("matchcode", "string", ["null" => true, "default" => null, "length" => 255]); + $manualInvoicePosition->addColumn("product_id", "integer", ["null" => false]); + $manualInvoicePosition->addColumn("product_name", "string", ["null" => false, "length" => 255]); + $manualInvoicePosition->addColumn("product_info", "text", ["null" => true, "default" => null]); + $manualInvoicePosition->addColumn("amount", "decimal", ["null" => false, "precision" => 9, "scale" => 6]); + $manualInvoicePosition->addColumn("price", "decimal", ["null" => false, "precision" => 14, "scale" => 4]); + $manualInvoicePosition->addColumn("price_total", "decimal", ["null" => false, "precision" => 14, "scale" => 4]); + $manualInvoicePosition->addColumn("price_gross", "decimal", ["null" => false, "precision" => 14, "scale" => 4]); + $manualInvoicePosition->addColumn("vatrate", "decimal", ["null" => false, "default" => 0, "precision" => 6, "scale" => 2]); + $manualInvoicePosition->addColumn("fibu_cost_account", "integer", ["null" => true, "default" => null]); + $manualInvoicePosition->addColumn("fibu_cost_account_legacy", "integer", ["null" => true, "default" => null]); + $manualInvoicePosition->addColumn("fibu_taxcode", "integer", ["null" => true, "default" => null]); + $manualInvoicePosition->addColumn("billing_period", "integer", ["null" => false, "default" => 0]); + $manualInvoicePosition->addColumn("options", "text", ["null" => true, "default" => null, "limit" => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG]); + $manualInvoicePosition->addColumn("create_by", "integer", ["null" => false]); + $manualInvoicePosition->addColumn("edit_by", "integer", ["null" => false]); + $manualInvoicePosition->addColumn("create", "integer", ["null" => false]); + $manualInvoicePosition->addColumn("edit", "integer", ["null" => false]); + + $manualInvoicePosition->create(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("ManualInvoiceposition")->drop()->save(); + $this->table("ManualInvoice")->drop()->save(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20251202071102_add_address_price_type.php b/db/migrations/20251202071102_add_address_price_type.php new file mode 100644 index 000000000..02a52bc2f --- /dev/null +++ b/db/migrations/20251202071102_add_address_price_type.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + $table = $this->table("AddressPriceType", ["signed" => true]); + $table->addColumn("address_id", "integer", ["null" => false]); + $table->addColumn("priceType_id", "integer", ["null" => false]); + $table->addColumn("create", "integer", ["null" => false]); + $table->addColumn("createBy", "integer", ["null" => false]); + $table->addIndex("address_id", ["unique" => true]); + $table->save(); + } + + if ($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table("AddressPriceType")->drop()->save(); + } + if ($this->getEnvironment() == "addressdb") { + } + } +} +?> diff --git a/db/migrations/20251202105729_add_manual_invoice_status_and_credit_fields.php b/db/migrations/20251202105729_add_manual_invoice_status_and_credit_fields.php new file mode 100644 index 000000000..4419ed98b --- /dev/null +++ b/db/migrations/20251202105729_add_manual_invoice_status_and_credit_fields.php @@ -0,0 +1,50 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoice"); + + $table->addColumn("status", "enum", [ + "values" => ["draft", "finalized", "exported"], + "default" => "draft", + "null" => false, + "after" => "date_delivered" + ]); + + $table->addColumn("credit_for_invoice_id", "integer", [ + "null" => true, + "default" => null, + "after" => "status" + ]); + + $table->addIndex(["credit_for_invoice_id"], ["name" => "credit_for_invoice_id"]); + + $table->update(); + } + + if ($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table("ManualInvoice"); + $table->removeColumn("status"); + $table->removeColumn("credit_for_invoice_id"); + $table->update(); + } + + if ($this->getEnvironment() == "addressdb") { + + } + } +} +?> diff --git a/db/migrations/20251203080600_add_wohneinheit_documentation.php b/db/migrations/20251203080600_add_wohneinheit_documentation.php new file mode 100644 index 000000000..cfe5c637d --- /dev/null +++ b/db/migrations/20251203080600_add_wohneinheit_documentation.php @@ -0,0 +1,38 @@ +getEnvironment() == "addressdb") { + $table = $this->table('WohneinheitDocumentation', ['id' => 'id', 'primary_key' => 'id']); + if (!$table->exists()) { + $table + ->addColumn('wohneinheit_id', 'integer', ['null' => false]) + ->addColumn('fileId', 'integer', ['null' => false]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('documentType', 'string', ['limit' => 100, 'null' => false, 'default' => 'photo']) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['wohneinheit_id'], ['name' => 'wohneinheit_id_idx']) + ->addIndex(['fileId'], ['name' => 'fileId_idx']) + ->create(); + } + } + } + + public function down(): void + { + // AddressDB modifications + if ($this->getEnvironment() == "addressdb") { + $table = $this->table('WohneinheitDocumentation'); + if ($table->exists()) { + $table->drop()->save(); + } + } + } +} diff --git a/db/migrations/20251204000000_add_manualinvoice_additional_fields.php b/db/migrations/20251204000000_add_manualinvoice_additional_fields.php new file mode 100644 index 000000000..ad3eb9e42 --- /dev/null +++ b/db/migrations/20251204000000_add_manualinvoice_additional_fields.php @@ -0,0 +1,36 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoice"); + $table->addColumn("leistungszeitraum", "string", ["null" => true, "default" => null, "length" => 255, "after" => "invoice_date"]); + $table->addColumn("einleitender_text", "text", ["null" => true, "default" => null, "after" => "leistungszeitraum"]); + $table->addColumn("externe_referenz", "string", ["null" => true, "default" => null, "length" => 255, "after" => "einleitender_text"]); + $table->addColumn("gesamtrabatt", "decimal", ["null" => false, "default" => 0, "precision" => 6, "scale" => 2, "after" => "externe_referenz"]); + $table->save(); + + $positionTable = $this->table("ManualInvoiceposition"); + $positionTable->addColumn("discount", "decimal", ["null" => false, "default" => 0, "precision" => 6, "scale" => 2, "after" => "price"]); + $positionTable->save(); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("ManualInvoiceposition")->removeColumn("discount")->save(); + $this->table("ManualInvoice") + ->removeColumn("leistungszeitraum") + ->removeColumn("einleitender_text") + ->removeColumn("externe_referenz") + ->removeColumn("gesamtrabatt") + ->save(); + } + } +} diff --git a/db/migrations/20251204000001_update_manualinvoiceposition_structure.php b/db/migrations/20251204000001_update_manualinvoiceposition_structure.php new file mode 100644 index 000000000..678f3f3aa --- /dev/null +++ b/db/migrations/20251204000001_update_manualinvoiceposition_structure.php @@ -0,0 +1,37 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoiceposition"); + + $table->addColumn("position_group", "string", ["null" => true, "default" => null, "length" => 255, "after" => "manualinvoice_id"]); + + $table->removeColumn("start_date"); + $table->removeColumn("end_date"); + $table->removeColumn("billing_period"); + + $table->save(); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $table = $this->table("ManualInvoiceposition"); + + $table->addColumn("start_date", "date", ["null" => false, "after" => "contract_id"]); + $table->addColumn("end_date", "date", ["null" => true, "default" => null, "after" => "start_date"]); + $table->addColumn("billing_period", "integer", ["null" => false, "default" => 0, "after" => "fibu_taxcode"]); + + $table->removeColumn("position_group"); + + $table->save(); + } + } +} diff --git a/db/migrations/20251204000002_add_unit_to_manualinvoiceposition.php b/db/migrations/20251204000002_add_unit_to_manualinvoiceposition.php new file mode 100644 index 000000000..dd3dc877e --- /dev/null +++ b/db/migrations/20251204000002_add_unit_to_manualinvoiceposition.php @@ -0,0 +1,30 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoiceposition"); + + $table->addColumn("unit", "string", [ + "null" => false, + "default" => "Stk.", + "length" => 10, + "after" => "amount" + ]); + + $table->save(); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("ManualInvoiceposition")->removeColumn("unit")->save(); + } + } +} diff --git a/db/migrations/20251204000003_create_manual_invoice_journal.php b/db/migrations/20251204000003_create_manual_invoice_journal.php new file mode 100644 index 000000000..0a9260967 --- /dev/null +++ b/db/migrations/20251204000003_create_manual_invoice_journal.php @@ -0,0 +1,24 @@ +getEnvironment() == "thetool") { + $table = $this->table('ManualInvoiceJournal'); + $table->addColumn('manualinvoiceId', 'integer', ['signed' => false]) + ->addColumn('text', 'text', ['null' => true]) + ->addColumn('data', 'json', ['null' => true]) + ->addColumn('statusChange', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('fileIds', 'json', ['null' => true]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addForeignKey('manualinvoiceId', 'ManualInvoice', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addIndex(['manualinvoiceId'], ['name' => 'manualinvoiceId_idx']) + ->create(); + } + } +} diff --git a/db/migrations/20251204000004_update_manual_invoice_status_values.php b/db/migrations/20251204000004_update_manual_invoice_status_values.php new file mode 100644 index 000000000..47725e8bc --- /dev/null +++ b/db/migrations/20251204000004_update_manual_invoice_status_values.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + // Change status column from enum to varchar to support new values + $this->execute("ALTER TABLE ManualInvoice MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'erstellt'"); + + // Update existing values to new ones + $this->execute("UPDATE ManualInvoice SET status = 'erstellt' WHERE status = 'draft'"); + $this->execute("UPDATE ManualInvoice SET status = 'gesendet' WHERE status = 'finalized'"); + $this->execute("UPDATE ManualInvoice SET status = 'exportiert' WHERE status = 'exported'"); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + // Revert values back + $this->execute("UPDATE ManualInvoice SET status = 'draft' WHERE status = 'erstellt'"); + $this->execute("UPDATE ManualInvoice SET status = 'finalized' WHERE status = 'gesendet'"); + $this->execute("UPDATE ManualInvoice SET status = 'exported' WHERE status = 'exportiert'"); + + // Change back to enum + $this->execute("ALTER TABLE ManualInvoice MODIFY COLUMN status ENUM('draft', 'finalized', 'exported') NOT NULL DEFAULT 'draft'"); + } + } +} diff --git a/db/migrations/20251210120000_add_workorder_mph_permissions.php b/db/migrations/20251210120000_add_workorder_mph_permissions.php new file mode 100644 index 000000000..e02e9b5da --- /dev/null +++ b/db/migrations/20251210120000_add_workorder_mph_permissions.php @@ -0,0 +1,31 @@ +getEnvironment() == "thetool") { + $table = $this->table("WorkerPermission"); + $table->addColumn("canWorkorderMph", "enum", ["values" => 'false,true', "default" => "false", "after" => "canWorkorderMphAdmin"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("WorkerPermission")->removeColumn("canWorkorderMph")->save(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/db/migrations/20251210130000_add_workorder_tenant_config_module_flags.php b/db/migrations/20251210130000_add_workorder_tenant_config_module_flags.php new file mode 100644 index 000000000..6933ce5d9 --- /dev/null +++ b/db/migrations/20251210130000_add_workorder_tenant_config_module_flags.php @@ -0,0 +1,40 @@ +getEnvironment() == "thetool") { + $table = $this->table('WorkorderTenantConfig'); + + $table->addColumn('enableWorkorder', 'boolean', [ + 'default' => true, + 'null' => false, + 'after' => 'requireCableType', + 'comment' => 'Enable Workorder module for this tenant' + ]); + + $table->addColumn('enableWorkorderMph', 'boolean', [ + 'default' => true, + 'null' => false, + 'after' => 'enableWorkorder', + 'comment' => 'Enable WorkorderMPH module for this tenant' + ]); + + $table->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WorkorderTenantConfig') + ->removeColumn('enableWorkorder') + ->removeColumn('enableWorkorderMph') + ->save(); + } + } +} \ No newline at end of file diff --git a/db/migrations/20251214150000_create_journal_table.php b/db/migrations/20251214150000_create_journal_table.php new file mode 100644 index 000000000..da704f5e9 --- /dev/null +++ b/db/migrations/20251214150000_create_journal_table.php @@ -0,0 +1,34 @@ +getEnvironment() == "thetool") { + $this->table('Journal') + ->addColumn('user_id', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('model', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('record_id', 'integer', ['null' => false]) + ->addColumn('action', 'enum', ['values' => ['create', 'update', 'delete'], 'null' => false]) + ->addColumn('field', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('old_value', 'text', ['null' => true]) + ->addColumn('new_value', 'text', ['null' => true]) + ->addColumn('timestamp', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['model', 'record_id'], ['name' => 'idx_model_record']) + ->addIndex(['user_id'], ['name' => 'idx_user']) + ->addIndex(['action'], ['name' => 'idx_action']) + ->addIndex(['timestamp'], ['name' => 'idx_timestamp']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('Journal')->drop()->save(); + } + } +} diff --git a/db/migrations/20251215120000_create_warehouse_stocktake_tables.php b/db/migrations/20251215120000_create_warehouse_stocktake_tables.php new file mode 100644 index 000000000..c8e6f6faa --- /dev/null +++ b/db/migrations/20251215120000_create_warehouse_stocktake_tables.php @@ -0,0 +1,70 @@ +getEnvironment() == "thetool") { + // 1. Main Stocktake Session Table + $stocktake = $this->table('WarehouseStocktake'); + $stocktake->addColumn('stocktakeNumber', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('title', 'string', ['limit' => 255]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('warehouseLocationId', 'integer', ['signed' => true]) + ->addColumn('status', 'enum', ['values' => ['planned', 'in_progress', 'completed', 'cancelled'], 'default' => 'planned']) + ->addColumn('startedAt', 'integer', ['null' => true]) + ->addColumn('completedAt', 'integer', ['null' => true]) + ->addColumn('startedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('completedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('totalItems', 'integer', ['default' => 0]) + ->addColumn('totalScannedItems', 'integer', ['default' => 0]) + ->addColumn('notes', 'text', ['null' => true]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeNumber'], ['unique' => true]) + ->addIndex(['status']) + ->addIndex(['warehouseLocationId']) + ->create(); + + // 2. Individual Stocktake Items + $stocktakeItem = $this->table('WarehouseStocktakeItem'); + $stocktakeItem->addColumn('stocktakeId', 'integer', ['signed' => true]) + ->addColumn('articleId', 'integer', ['signed' => false]) + ->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('countedQuantity', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => 0]) + ->addColumn('rack', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('shelf', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('note', 'text', ['null' => true]) + ->addColumn('scannedAt', 'integer', ['null' => true]) + ->addColumn('scannedBy', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeId']) + ->addIndex(['articleId']) + ->create(); + + // 3. Activity Log + $stocktakeLog = $this->table('WarehouseStocktakeLog'); + $stocktakeLog->addColumn('stocktakeId', 'integer', ['signed' => true]) + ->addColumn('stocktakeItemId', 'integer', ['null' => true, 'signed' => true]) + ->addColumn('action', 'string', ['limit' => 50]) + ->addColumn('details', 'text', ['null' => true]) + ->addColumn('userId', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addIndex(['stocktakeId']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('WarehouseStocktakeLog')->drop()->save(); + $this->table('WarehouseStocktakeItem')->drop()->save(); + $this->table('WarehouseStocktake')->drop()->save(); + } + } +} \ No newline at end of file diff --git a/db/migrations/20251215150000_warehouse_category_set_prefixes.php b/db/migrations/20251215150000_warehouse_category_set_prefixes.php new file mode 100644 index 000000000..bbba8f3c4 --- /dev/null +++ b/db/migrations/20251215150000_warehouse_category_set_prefixes.php @@ -0,0 +1,55 @@ +table('WarehouseCategory'); + if (!$table->hasColumn('articleNumberPrefix')) { + $table->addColumn('articleNumberPrefix', 'string', ['limit' => 4, 'null' => true, 'after' => 'description']) + ->update(); + } + + if ($this->getEnvironment() == "thetool") { + $prefixes = [ + 1 => '1901', // Dienstleistungen + 3 => '9980', // EStmk Shop + 4 => '1400', // GPON OLTs und Bridges + 21 => '9990', // Import nicht erfolgreich + 5 => '1700', // Kabel-TV und Zubehör + 6 => '0700', // Kupferverkabelung und Schränke + 7 => '0400', // LWL Aussen- und Universalkabel + 8 => '0600', // LWL Boxen, Muffen und Gehäuse + 9 => '0900', // LWL Leitungsbau + 10 => '0500', // LWL Pigtails und Kupplungen + 11 => '0800', // LWL Splitter, Filter und Dämpfer + 12 => '1600', // Netzteile, USV, Akkus + 13 => '0300', // Patchkabel Kupfer + 14 => '0200', // Patchkabel LWL Multimode + 15 => '0100', // Patchkabel LWL Singlemode + 16 => '1000', // Richtfunk und WLAN + 17 => '1100', // Router und Zubehör + 18 => '1300', // SFP und Konverter + 19 => '1200', // Switches und Zubehör + 20 => '1500', // Telefonie und Zubehör + 2 => '1800', // Elektromaterial etc. (no articles, assign next free) + ]; + + foreach ($prefixes as $categoryId => $prefix) { + $this->execute("UPDATE WarehouseCategory SET articleNumberPrefix = '{$prefix}' WHERE id = {$categoryId}"); + } + } + } + + public function down(): void + { + $table = $this->table('WarehouseCategory'); + + if ($table->hasColumn('articleNumberPrefix')) { + $table->removeColumn('articleNumberPrefix')->update(); + } + } +} diff --git a/db/migrations/20251217120000_warehouse_stocktake_item_add_overwritten.php b/db/migrations/20251217120000_warehouse_stocktake_item_add_overwritten.php new file mode 100644 index 000000000..9d99da9a4 --- /dev/null +++ b/db/migrations/20251217120000_warehouse_stocktake_item_add_overwritten.php @@ -0,0 +1,25 @@ +getEnvironment() == "thetool") { + $table = $this->table('WarehouseStocktakeItem'); + $table->addColumn('overwrittenById', 'integer', ['null' => true, 'signed' => true, 'after' => 'scannedBy']) + ->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $table = $this->table('WarehouseStocktakeItem'); + $table->removeColumn('overwrittenById') + ->update(); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ab2d14586..2748ee516 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - "80:80" volumes: - ./docker/php/apache.conf:/etc/apache2/sites-available/000-default.conf - - ./docker/php/custom.ini:/etc/php/8.2/apache2/conf.d/99-custom.ini + - ./docker/php/custom.ini:/etc/php/8.4/apache2/conf.d/99-custom.ini - ./docker/php/logs:/var/log/apache2 - ./:/var/www/html - vendor:/var/www/html/vendor diff --git a/lib/Citycom/OanApiClient.php b/lib/Citycom/OanApiClient.php index 24cac04c8..c5f61c546 100644 --- a/lib/Citycom/OanApiClient.php +++ b/lib/Citycom/OanApiClient.php @@ -266,6 +266,122 @@ class Citycom_OanApiClient { return $types; } + public function getOntStatus($oan_id) { + if(!$this->token) { + $this->getAuthToken(); + if(!$this->token) { + return false; + } + } + + $url = $this->baseurl.CITYCOM_OAN_API_EP_GET_ONT_STATUS; + $url = str_replace("{oan_id}", $oan_id, $url); + $ctx_options = [ + "http" => [ + "ignore_errors" => true, + "method" => "GET", + "header" => [ + "Accept: application/json", + "Authorization: Bearer ".$this->token, + ], + ] + ]; + + $ctx = stream_context_create($ctx_options); + $output = file_get_contents($url, false, $ctx); + + $resp = json_decode($output); + if(!is_object($resp)) { + return false; + } + if(!property_exists($resp, "success") || !$resp->success || !property_exists($resp, "data")) { + return false; + } + + return $resp->data; + } + + public function getOntPortStatus($oan_id) { + if(!$this->token) { + $this->getAuthToken(); + if(!$this->token) { + return false; + } + } + + $url = $this->baseurl.CITYCOM_OAN_API_EP_GET_ONT_PORTSTATUS; + $url = str_replace("{oan_id}", $oan_id, $url); + $ctx_options = [ + "http" => [ + "ignore_errors" => true, + "method" => "GET", + "header" => [ + "Accept: application/json", + "Authorization: Bearer ".$this->token, + ], + ] + ]; + + $ctx = stream_context_create($ctx_options); + $output = file_get_contents($url, false, $ctx); + + $resp = json_decode($output); + if(!is_object($resp)) { + return false; + } + if(!property_exists($resp, "success") || !$resp->success || !property_exists($resp, "data")) { + return false; + } + + return $resp->data; + } + + public function getOntMacAddresses($oan_id) { + if(!$this->token) { + $this->getAuthToken(); + if(!$this->token) { + return false; + } + } + + $url = $this->baseurl.CITYCOM_OAN_API_EP_GET_ONT_MACADDRESSES; + $url = str_replace("{oan_id}", $oan_id, $url); + $ctx_options = [ + "http" => [ + "ignore_errors" => true, + "method" => "GET", + "header" => [ + "Accept: application/json", + "Authorization: Bearer ".$this->token, + ], + ] + ]; + + $ctx = stream_context_create($ctx_options); + $output = file_get_contents($url, false, $ctx); + + $resp = json_decode($output); + if(!is_object($resp)) { + return false; + } + if(!property_exists($resp, "success") || !$resp->success || !property_exists($resp, "data")) { + return false; + } + return $resp->data; + } + + public function getOntStatusDetail($oan_id) { + $ont_status = $this->getOntStatus($oan_id); + $ont_port_status = $this->getOntPortStatus($oan_id); + $ont_mac_addresses = $this->getOntMacAddresses($oan_id); + + return [ + $ont_status, + $ont_port_status, + $ont_mac_addresses + ]; + } + private function getAuthToken() { $token = new mfConfig("adb.import.citycom.auth.token"); if($token && $token->value()) { diff --git a/lib/Citycom/OanApiHelper.php b/lib/Citycom/OanApiHelper.php index 87404fe7d..ccd3bacc7 100644 --- a/lib/Citycom/OanApiHelper.php +++ b/lib/Citycom/OanApiHelper.php @@ -94,6 +94,8 @@ class Citycom_OanApiHelper { $execution_date = date("Y-m-d"); } + + if(array_key_exists("ctag_range_search", $data) && $data["ctag_range_search"]) { $ctag_range_search = $data["ctag_range_search"]; } @@ -116,10 +118,12 @@ class Citycom_OanApiHelper { $this->log->debug(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, CITYCOM_OAN_API_SERVICES_FOR_ORDER)) continue; - $ctag_service_type = array_flip(CITYCOM_OAN_API_SERVICES_FOR_ORDER)[$stype->name]; + if(!in_array($stype->name, $allowed_service_types)) continue; + $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]); @@ -131,9 +135,9 @@ class Citycom_OanApiHelper { $new_services = []; if($ctag_range_search) { - $ctags = $preorder->getFreeCtagsInSet($ctag_range_search); + list($ctags, $mgmt_ctag) = $preorder->getFreeCtagsInSet($ctag_range_search); } else { - $ctags = $preorder->getNextFreeCtags(); + list($ctags, $mgmt_ctag) = $preorder->getNextFreeCtags(); } $this->log->debug(print_r($ctags, true)); @@ -148,6 +152,8 @@ class Citycom_OanApiHelper { return false; } + + $preorder_ctag_data = [ "preorder_id" => $preorder->id, "network" => "citycom-oan", @@ -159,8 +165,13 @@ class Citycom_OanApiHelper { // was this service type requested if(!in_array($stype->name, $want_services)) continue; - $ctag = $ctags[$service_count]; - $ctag_service_type = array_flip(CITYCOM_OAN_API_SERVICES_FOR_ORDER)[$stype->name]; + // ensure mgmt_ctag is always known (currently last in range) + if($mgmt_ctag && $stype->name == $allowed_service_types["mgmt"]) { + $ctag = $mgmt_ctag; + } else { + $ctag = $ctags[$service_count]; + } + $ctag_service_type = array_flip($allowed_service_types)[$stype->name]; if(!$ctag_service_type) { $this->log->error(__METHOD__.": Cannot create Service ".$stype->name." for preorder ".$preorder->id." because no ctag service type defined"); return false; diff --git a/lib/GenieACS/GenieACS.php b/lib/GenieACS/GenieACS.php new file mode 100644 index 000000000..540026e90 --- /dev/null +++ b/lib/GenieACS/GenieACS.php @@ -0,0 +1,304 @@ +log = mfLoghandler::singleton(); + + $this->baseurl = rtrim($baseurl, '/'); + $this->username = $username; + $this->password = $password; + } + + private function _authenticate() { + $session_key = "genieacs.{$this->baseurl}.jwt"; + $session = new mfConfig($session_key); + + if ($session->value() && (time() - $session->edit) < 3600) { + $this->jwt_token = $session->value(); + $this->log->debug("GenieACS: Using cached JWT token."); + return true; + } + + $this->log->debug("GenieACS: Authenticating to get new JWT token."); + $ctx = stream_context_create([ + "http" => [ + "ignore_errors" => true, + "method" => "POST", + "header" => ["Content-Type: application/json"], + "content" => json_encode(["username" => $this->username, "password" => $this->password]), + ] + ]); + + $response = file_get_contents($this->baseurl . '/login', false, $ctx); + + if (isset($http_response_header)) { + foreach ($http_response_header as $header) { + if (preg_match('/genieacs-ui-jwt=([^;]+)/', $header, $matches)) { + $this->jwt_token = $matches[1]; + $session->value($this->jwt_token); + $session->save(); + $this->log->debug("GenieACS: Successfully retrieved and cached new JWT token."); + return true; + } + } + } + $this->log->debug("GenieACS: Failed to retrieve JWT token."); + return false; + } + + private function _request($method, $endpoint, $data = null) { + if (!$this->jwt_token && !$this->_authenticate()) { + throw new Exception("GenieACS Authentication failed."); + } + + $this->log->debug("GenieACS: Making API request", ['method' => $method, 'endpoint' => $endpoint]); + $opts = [ + 'http' => [ + 'ignore_errors' => true, + 'method' => $method, + 'header' => ['Cookie: genieacs-ui-jwt=' . $this->jwt_token, 'Content-Type: application/json'], + ] + ]; + if ($data) $opts['http']['content'] = json_encode($data); + + $ctx = stream_context_create($opts); + $response = @file_get_contents($this->baseurl . $endpoint, false, $ctx); + + // Re-auth on 401 + if (isset($http_response_header)) { + foreach ($http_response_header as $header) { + if (strpos($header, '401') !== false) { + $this->log->debug("GenieACS: 401 Unauthorized, re-authenticating."); + $this->jwt_token = null; + if ($this->_authenticate()) { + return $this->_request($method, $endpoint, $data); + } else { + throw new Exception("GenieACS Re-authentication failed."); + } + } + } + } + + if ($response === false || $response === '') { + // 200-204 check + if (isset($http_response_header)) { + foreach ($http_response_header as $header) { + if (strpos($header, 'HTTP/') === 0 && (strpos($header, '200') !== false || strpos($header, '202') !== false || strpos($header, '204') !== false)) { + return ['success' => true]; + } + } + } + } + + $decoded = json_decode($response, true); + // If request was GET /devices/ID, the response IS the device object. + // If request was GET /devices, it is an array of objects. + return $decoded; + } + + public function getDevices() { + return $this->_request('GET', '/api/devices'); + } + + public function getDevice($deviceId) { + return $this->_request('GET', '/api/devices/' . rawurlencode($deviceId)); + } + + public function rebootDevice($deviceId) { + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [['name' => 'reboot']]); + } + + public function ping($ip) { + return $this->_request('GET', '/api/ping/' . $ip); + } + + public function getParameterValues($deviceId, $parameterNames) { + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [[ + "name" => "getParameterValues", "parameterNames" => $parameterNames + ]]); + } + + public function setParameterValues($deviceId, $parameterValues) { + $formattedParams = []; + foreach ($parameterValues as $name => $value) { + $type = 'xsd:string'; + if (is_bool($value)) { $type = 'xsd:boolean'; $value = $value ? true : false; } + elseif (is_int($value)) $type = 'xsd:int'; + elseif (is_float($value)) $type = 'xsd:double'; + $formattedParams[] = [$name, $value, $type]; + } + return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [[ + "name" => "setParameterValues", "parameterValues" => $formattedParams + ]]); + } + + public function getSpeedtestResult($deviceId) { + $param = 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'; + $this->getParameterValues($deviceId, [$param]); + usleep(500000); + $device = $this->getDevice($deviceId); + return self::getParam($device, $param); + } + + public function createRemoteUser($deviceId, $forceRecreate = false) { + $this->log->debug("GenieACS: createRemoteUser called", ['deviceId' => $deviceId, 'forceRecreate' => $forceRecreate]); + $cacheKey = "remote_user_" . $deviceId; + if (!$forceRecreate && $cached = $this->getCache($cacheKey)) { + $this->log->debug("GenieACS: Using cached credentials"); + return $cached; + } + + $password = $this->generatePassword(12); + $timestamp = (string)time(); + + $userParamsToRefresh = [ + 'InternetGatewayDevice.User.1.Enable', + 'InternetGatewayDevice.User.1.Password', + 'InternetGatewayDevice.User.1.RemoteAccessCapable', + 'InternetGatewayDevice.User.1.Username' + ]; + $this->getParameterValues($deviceId, $userParamsToRefresh); + sleep(2); + + $this->setParameterValues($deviceId, [ + 'InternetGatewayDevice.User.1.Enable' => true, + 'InternetGatewayDevice.User.1.Password' => $password, + 'InternetGatewayDevice.User.1.RemoteAccessCapable' => true, + 'InternetGatewayDevice.User.1.Username' => $timestamp + ]); + + // Poll for Username + $username = null; + $maxAttempts = 15; + $paramName = 'InternetGatewayDevice.User.1.Username'; + + for ($i = 0; $i < $maxAttempts; $i++) { + sleep(1); + $this->getParameterValues($deviceId, [$paramName]); + usleep(500000); + + $device = $this->getDevice($deviceId); + + // Access property using flat dot-notation key + $val = self::getParam($device, $paramName); + $this->log->debug("GenieACS: Poll attempt " . ($i + 1) . " value: " . json_encode($val)); + + if ($val && strpos($val, 'TR069-') === 0) { + $username = $val; + break; + } + } + + if (!$username) { + $this->log->debug("GenieACS: Failed to retrieve TR069 username."); + return null; + } + + $ip = self::getExternalIP($this->getDevice($deviceId)); + if (!$ip) { + $this->log->debug("GenieACS: Could not get external IP."); + return null; + } + + $result = [ + 'username' => $username, + 'password' => $password, + 'ip' => $ip, + 'link' => "https://" . $ip . ":9090" + ]; + + $this->setCache($cacheKey, $result); + return $result; + } + + private function getCache($key) { + $file = TEMP_DIR . "/RadiusCache/" . md5($key) . ".json"; + if (file_exists($file)) { + if (filemtime($file) < (time() - 1800)) { + @unlink($file); + return null; + } + return json_decode(file_get_contents($file), true); + } + return null; + } + + private function setCache($key, $data) { + $dir = TEMP_DIR . "/RadiusCache/"; + if (!is_dir($dir)) @mkdir($dir, 0777, true); + file_put_contents($dir . md5($key) . ".json", json_encode($data)); + } + + private function generatePassword($length) { + $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return substr(str_shuffle(str_repeat($chars, ceil($length/strlen($chars)))), 1, $length); + } + + // Helpers to safely access device parameters from flat JSON structure + private static function getParam($deviceData, $key) { + if (!is_array($deviceData)) return null; + if (isset($deviceData[$key]['value'][0])) { + return $deviceData[$key]['value'][0]; + } + return null; + } + + public static function getExternalIP($deviceData) { + // Try typical WAN paths + $paths = [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress' + ]; + foreach ($paths as $path) { + $val = self::getParam($deviceData, $path); + if ($val) return $val; + } + return null; + } + + public static function getDeviceId($deviceData) { + return self::getParam($deviceData, 'DeviceID.ID'); + } + + public static function getDeviceInfo($deviceData) { + return [ + 'serialNumber' => self::getParam($deviceData, 'DeviceID.SerialNumber'), + 'hardwareVersion' => self::getParam($deviceData, 'InternetGatewayDevice.DeviceInfo.HardwareVersion'), + 'softwareVersion' => self::getParam($deviceData, 'InternetGatewayDevice.DeviceInfo.SoftwareVersion'), + ]; + } + + public static function getManagementIP($deviceData) { + // Return any valid IP found, prioritizing private IPs if possible + $ip1 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress'); + $ip2 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress'); + + if ($ip1 && self::isPrivateIP($ip1)) return $ip1; + if ($ip2 && self::isPrivateIP($ip2)) return $ip2; + return $ip1 ?: $ip2; + } + + public static function getMacAddress($deviceData) { + $mac1 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress'); + if ($mac1) return $mac1; + return self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.MACAddress'); + } + + private static function isPrivateIP($ip) { + $parts = explode('.', $ip); + if (count($parts) !== 4) return false; + $first = (int)$parts[0]; + $second = (int)$parts[1]; + if ($first === 10) return true; + if ($first === 172 && $second >= 16 && $second <= 31) return true; + if ($first === 192 && $second === 168) return true; + return false; + } +} \ No newline at end of file diff --git a/lib/Helper/Helper.php b/lib/Helper/Helper.php index cf013a7ce..1bc28fc37 100644 --- a/lib/Helper/Helper.php +++ b/lib/Helper/Helper.php @@ -165,6 +165,33 @@ class Helper { $controller->layout()->setTemplate("VueViews/Vue"); } + /** + * Displays Vue 3 component with the given header title. + * Uses TT-Core component library instead of legacy Vue 2 components. + * + * @param mfBaseController $controller The controller instance to generate $JSGlobals for. + * @param string $pageName The name of the Vue component to render. + * @param string $headerTitle The title to display in the header. + * @param array $additionalGlobals Additional global variables to pass to the Vue component. + */ + public static function renderVue3(mfBaseController $controller, string $pageName, string $headerTitle, array $additionalGlobals = []) { + $JSGlobals = ["BASE_URL" => $controller::getUrl($pageName), + "MF_URL" => $controller::getUrl(""), + "DASHBOARD_URL" => $controller::getUrl("Dashboard"), + "MF_APP_NAME" => MFAPPNAME_SLUG, + "BASE_PATH" => $controller::getUrl(""), + "PAGE_TITLE" => $headerTitle, + "PATH" => [["text" => MFAPPNAME_SLUG, "href" => $controller::getUrl("Dashboard")], + ["text" => $headerTitle, "href" => $controller::getUrl($pageName)]],]; + + $JSGlobals = array_merge($JSGlobals, $additionalGlobals); + + $controller->layout()->set("vueViewName", $pageName); + $controller->layout()->set("JSGlobals", $JSGlobals); + $controller->layout()->set("useVue3", true); // Flag to indicate Vue 3 mode + $controller->layout()->setTemplate("VueViews/Vue3"); + } + /** * Converts an array of objects to a CSV file. * @param array $rows The array of objects to convert to CSV. @@ -225,4 +252,63 @@ class Helper { return array_map(fn($owner) => new Address($owner['id']), $results); } + + /** + * Get AddressDB Netzgebiet IDs that a user has access to based on their Network ownership + * @param User $user The user to get networks for + * @return array Array of addressdb netzgebiet IDs + */ + public static function getADBNetworksFromUser($user): array { + if ($user->isAdmin()) { + // Admin has access to all networks + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $sql = "SELECT id FROM Netzgebiet WHERE id IS NOT NULL"; + $result = $db->query($sql); + $netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + return array_column($netzgebiete, 'id'); + } + + // Get networks where user's address is the owner + $networks = NetworkModel::search(['owner_id' => $user->address_id]); + + // Also check user flags for additional networks + $flagNetworkIds = json_decode($user->getFlag("workordermph_networks")->value() ?: '[]', true); + if (!empty($flagNetworkIds)) { + $additionalNetworks = NetworkModel::search(['id' => $flagNetworkIds]); + $networks = array_merge($networks, $additionalNetworks); + } + + // Extract adb_netzgebiet_id from networks + $netzgebietIds = []; + foreach ($networks as $network) { + if ($network->adb_netzgebiet_id) { + $netzgebietIds[] = $network->adb_netzgebiet_id; + } + } + + return array_unique(array_filter($netzgebietIds)); + } + + /** + * Get network owners that have WorkorderMph entries (based on Netzgebiet) + * @return array Array of Address objects representing network owners + */ + public static function getMphNetworkOwners(): array { + $db = FronkDB::singleton(); + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + $fronkDbName = FRONKDB_DBNAME; + + $sql = "SELECT DISTINCT a.id, a.company, a.lastname, a.firstname + FROM `$fronkDbName`.`WorkorderMph` wm + INNER JOIN `$addressDbName`.`Hausnummer` hn ON wm.hausnummerId = hn.id + INNER JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id + INNER JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id + INNER JOIN `$fronkDbName`.`Address` a ON n.owner_id = a.id + WHERE a.id IS NOT NULL + ORDER BY a.company, a.lastname, a.firstname"; + + $results = $db->fetch_all_assoc($db->query($sql)) ?? []; + + return array_map(fn($owner) => new Address($owner['id']), $results); + } } \ No newline at end of file diff --git a/lib/Rimoapi/Rimoapi.php b/lib/Rimoapi/Rimoapi.php index e58de712a..f50c1fe2c 100644 --- a/lib/Rimoapi/Rimoapi.php +++ b/lib/Rimoapi/Rimoapi.php @@ -52,6 +52,45 @@ class Rimoapi { return true; } + public static function changeHomeSubAddress($apikey, $home_external_id, $subAddress) { + if(!$apikey) return false; + if(!$home_external_id) return false; + + $log = mfLoghandler::singleton(); + + // send request to Rimo Api + $params = []; + $params['apiKey'] = $apikey; + $params['homeId'] = $home_external_id; + $params['subAddress'] = $subAddress; + + $ctx_opts = [ + 'http' => [ + 'method' => 'PUT', + 'header' => 'accept: application/json' + ] + ]; + + $qs = http_build_query($params); + + $changeSubAddressEp = RIMO_API_JSON_URL.RIMO_API_JSON_EP_CHANGE_HOME_SUBADDRESS; + $put_url = $changeSubAddressEp."?".$qs; + $ctx = stream_context_create($ctx_opts); + $log->debug(__METHOD__.": SubAddress Change in Rimo: $put_url"); + $response = file_get_contents($put_url, false, $ctx); + $log->debug(__METHOD__.": response: ".print_r($response,true)); + + if($response === false) { + $log->error("Fehler beim Update der SubAddress in RIMO ".$home_external_id); + return false; + } + + $resp_data = json_decode($response); + if(!$resp_data->id) return false; + + return true; + } + public static function getFtuData($oaid, $home_external_id) { //$oaid = $oaid; $log = mfLoghandler::singleton(); diff --git a/lib/TTCrudBaseModel/TTCrudBaseModel.php b/lib/TTCrudBaseModel/TTCrudBaseModel.php index c2a97b024..d97b36ac1 100644 --- a/lib/TTCrudBaseModel/TTCrudBaseModel.php +++ b/lib/TTCrudBaseModel/TTCrudBaseModel.php @@ -227,4 +227,36 @@ class TTCrudBaseModel { return new static($result->fetch_assoc()); } + public function save() { + $data = []; + + $reflection = new ReflectionClass(get_called_class()); + foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + $field = $property->getName(); + if (property_exists($this, $field)) { + $data[$field] = $this->$field; + } + } + + // If we have an ID, update; otherwise, create + if (isset($this->id) && $this->id > 0) { + return self::update($data); + } else { + $newId = self::create($data); + $this->id = $newId; + return $newId; + } + } + + public function deleteInstance() { + if (!isset($this->id)) { + throw new Exception("Cannot delete model without ID"); + } + return self::delete($this->id); + } + + public static function search($filter = [], $limit = null, $order = ["key" => null]): array { + return self::getAll($filter, $limit, 0, $order); + } + } \ No newline at end of file diff --git a/lib/XinonProject/XinonProject.php b/lib/XinonProject/XinonProject.php index f32895350..5d4c93936 100644 --- a/lib/XinonProject/XinonProject.php +++ b/lib/XinonProject/XinonProject.php @@ -66,8 +66,10 @@ class XinonProject { if (!is_null($overrideQueryParams)) $queryParams = $overrideQueryParams; + $url = $baseUrl . '?' . http_build_query($queryParams); + curl_setopt_array($curl, array( - CURLOPT_URL => $baseUrl . '?' . http_build_query($queryParams), + CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, @@ -84,7 +86,7 @@ class XinonProject { $json = json_decode($response, true); - return $json['_embedded']['elements']; + return $json['_embedded']['elements'] ?? []; } } ?> diff --git a/lib/mfBaseModelV2/README.md b/lib/mfBaseModelV2/README.md new file mode 100644 index 000000000..c5dee0c0f --- /dev/null +++ b/lib/mfBaseModelV2/README.md @@ -0,0 +1,155 @@ +# mfBaseModelV2 + +Modern PHP 8+ base model with typed properties and automatic journaling. + +## Basic Usage + +```php +class ADBNetzgebiet extends mfBaseModelV2 { + protected static string $__tableName = 'Netzgebiet'; + protected static string $__primaryKey = 'id'; // default + + public int $id; + public ?string $name = null; + public ?string $extref = null; + public int $create; + public int $edit; +} +``` + +## Custom Database + +```php +protected static ?array $__databaseConfig = [ + 'host' => ADDRESSDB_DBHOST, + 'user' => ADDRESSDB_DBUSER, + 'pass' => ADDRESSDB_DBPASS, + 'name' => ADDRESSDB_DBNAME +]; +``` + +## Static Methods + +```php +$model = MyModel::get(123); // by ID, returns ?static +$model = MyModel::getFirst(['name' => 'foo']); // first match +$all = MyModel::getAll($filter, $limit, $offset, $order); +$all = MyModel::search($filter); // alias for getAll +$count = MyModel::count($filter); +``` + +## Filter Operators + +| Prefix | SQL | Example | +|--------|-----|---------| +| (none) | LIKE %...% | `['name' => 'foo']` | +| `=` | = exact | `['=name' => 'foo']` | +| `!` | != / NOT IN | `['!status' => 'deleted']` | +| `>` `<` `>=` `<=` | comparison | `['>create' => $timestamp]` | + +**Special values:** +```php +['status' => null] // IS NULL +['!status' => null] // IS NOT NULL +['id' => [1, 2, 3]] // IN (1, 2, 3) +['!id' => [1, 2, 3]] // NOT IN (1, 2, 3) +``` + +## Ordering + +```php +MyModel::getAll([], null, 0, ['column' => 'name', 'dir' => 'ASC']); +``` + +## Instance Methods + +```php +$model->save(); // insert or update +$model->delete(); +$model->isLoaded(); +$model->getId(); +$model->toArray(); +$model->toJson(); +$model->getJournalHistory(); // returns change history +``` + +## Hooks + +```php +public function validate(): array { + $errors = []; + if (empty($this->name)) $errors[] = 'Name required'; + return $errors; // empty = valid +} + +protected function beforeSave(bool $isInsert): bool { + return true; // false cancels save +} + +protected function afterSave(bool $isInsert, array $changes): void { + // $changes = ['field' => ['old' => x, 'new' => y]] +} +``` + +## Journaling + +Automatic change tracking to `Journal` table. Configure field labels: + +```php +protected static array $__journalFieldMap = [ + 'name' => 'Name', + 'extref' => 'External Reference', +]; +``` + +Disable per model: +```php +protected static bool $__enableJournaling = false; +``` + +## Auto-timestamps + +If properties exist, they're set automatically on save: +- `create`, `create_by` - on insert +- `edit`, `edit_by` - on insert/update + +## Magic Properties with Intellisense + +Use `@property-read` for lazy-loaded relations: + +```php +/** + * @property-read ADBNetzgebietRelations $relations + */ +class ADBNetzgebiet extends mfBaseModelV2 { + private ?ADBNetzgebietRelations $__relations = null; + + public function __get(string $name) { + if ($name === 'relations') { + return $this->__relations ??= $this->loadRelations(); + } + return null; + } + + public function loadRelations(): ADBNetzgebietRelations { + // ... + } +} +``` + +Typed relation class for IDE support: + +```php +class ADBNetzgebietRelations { + /** @var array{id: int, name: string}[] */ + public array $networks = []; + /** @var array{id: int, name: string}[] */ + public array $campaigns = []; +} +``` + +Usage with full autocomplete: +```php +$model = ADBNetzgebiet::get(1); +$model->relations->networks; // IDE knows this is array{id: int, name: string}[] +``` diff --git a/lib/mfBaseModelV2/mfBaseModelV2.php b/lib/mfBaseModelV2/mfBaseModelV2.php new file mode 100644 index 000000000..8cd7fa192 --- /dev/null +++ b/lib/mfBaseModelV2/mfBaseModelV2.php @@ -0,0 +1,373 @@ +, <, >=, <= + * Array values become IN/NOT IN clauses, null checks IS NULL/IS NOT NULL + */ +abstract class mfBaseModelV2 { + + protected static string $__tableName = ''; + protected static string $__primaryKey = 'id'; + protected static ?array $__databaseConfig = null; + protected static array $__journalFieldMap = []; + protected static bool $__enableJournaling = true; + + private static array $__db_instances = []; + protected ?FronkDB $__db = null; + protected ?mfLoghandler $__log = null; + private ?stdClass $__originalData = null; + private bool $__isLoaded = false; + + public function __construct(int|string $id = null) { + static::__init_db(); + $this->__db = self::$__db_instances[static::class]; + $this->__log = mfLoghandler::singleton(); + $this->__originalData = new stdClass(); + + if ($id !== null) $this->__load($id); + } + + protected static function __init_db(): void { + if (isset(self::$__db_instances[static::class])) return; + + if (empty(static::$__tableName)) { + throw new Exception('$__tableName must be set in ' . get_called_class()); + } + + self::$__db_instances[static::class] = static::$__databaseConfig !== null + ? FronkDB::singleton( + static::$__databaseConfig['host'], + static::$__databaseConfig['user'], + static::$__databaseConfig['pass'], + static::$__databaseConfig['name'] + ) + : FronkDB::singleton(); + } + + protected static function __getDb(): FronkDB { + static::__init_db(); + return self::$__db_instances[static::class]; + } + + public static function get(int|string $id): ?static { + static::__init_db(); + $model = new static(); + return $model->__load($id) ? $model : null; + } + + public static function getFirst(array $filter = [], array $order = []): ?static { + $results = static::getAll($filter, 1, 0, $order); + return $results[0] ?? null; + } + + public static function getAll(array $filter = [], int $limit = null, int $offset = 0, array $order = []): array { + static::__init_db(); + $db = self::$__db_instances[static::class]; + $table = static::$__tableName; + $whereSql = static::__buildFilterSql($filter); + + $orderSql = ""; + if (!empty($order['column'])) { + $dir = (strtoupper($order['dir'] ?? '') === 'DESC') ? 'DESC' : 'ASC'; + $orderSql = "ORDER BY `" . $db->escape($order['column']) . "` $dir"; + } + + $limitSql = $limit !== null ? "LIMIT " . (int)$offset . ", " . (int)$limit : ""; + $res = $db->query("SELECT * FROM `$table` $whereSql $orderSql $limitSql"); + + $items = []; + if ($db->num_rows($res)) { + while ($data = $db->fetch_object($res)) { + $model = new static(); + $model->__populate($data); + $model->__isLoaded = true; + $items[] = $model; + } + } + return $items; + } + + public static function search(array $filter = [], int $limit = null, int $offset = 0, array $order = []): array { + return static::getAll($filter, $limit, $offset, $order); + } + + public static function count(array $filter = []): int { + static::__init_db(); + $db = self::$__db_instances[static::class]; + $whereSql = static::__buildFilterSql($filter); + $res = $db->query("SELECT COUNT(*) as cnt FROM `" . static::$__tableName . "` $whereSql"); + return $db->num_rows($res) ? (int)$db->fetch_object($res)->cnt : 0; + } + + public function isLoaded(): bool { return $this->__isLoaded; } + + public function getId(): int|string|null { + return $this->{static::$__primaryKey} ?? null; + } + + public function save(): bool { + try { + $isInsert = !$this->__isLoaded; + $userId = $this->__getUserId(); + $now = time(); + + if (property_exists($this, 'edit')) $this->edit = $now; + if (property_exists($this, 'edit_by')) $this->edit_by = $userId; + + if ($isInsert) { + if (property_exists($this, 'create')) $this->create = $now; + if (property_exists($this, 'create_by')) $this->create_by = $userId; + } + + $errors = $this->validate(); + if (!empty($errors)) { + $this->__log->warn('Validation failed: ' . implode(', ', $errors)); + return false; + } + + if (!$this->beforeSave($isInsert)) return false; + + $data = $this->__getPublicData(); + $changes = $this->__getChangedFields($data); + $pk = static::$__primaryKey; + + if (!$isInsert && empty($changes)) return true; + + if ($isInsert) { + if (array_key_exists($pk, $data) && $data[$pk] === null) unset($data[$pk]); + if (!$this->__db->insert(static::$__tableName, $data)) { + throw new Exception("INSERT failed: " . $this->__db->getLastError()); + } + $this->{$pk} = $this->__db->insert_id; + $this->__isLoaded = true; + } else { + $pkValue = $this->{$pk}; + $updateData = []; + foreach ($changes as $field => $change) $updateData[$field] = $change['new']; + + if (!empty($updateData)) { + if (!$this->__db->update(static::$__tableName, $updateData, "`$pk` = '" . $this->__db->escape($pkValue) . "'")) { + throw new Exception("UPDATE failed: " . $this->__db->getLastError()); + } + } + } + + $this->__originalData = (object)$this->__getPublicData(); + $this->afterSave($isInsert, $changes); + + if (static::$__enableJournaling) $this->__writeToJournal($changes, $isInsert); + + return true; + } catch (Exception $e) { + $this->__log->error("mfBaseModelV2 save() error: " . $e->getMessage()); + return false; + } + } + + public function delete(): bool { + if (!$this->__isLoaded) return false; + + $pk = static::$__primaryKey; + $pkValue = $this->{$pk}; + + if ($this->__db->delete(static::$__tableName, "`$pk` = '" . $this->__db->escape($pkValue) . "'")) { + if (static::$__enableJournaling) $this->__writeToJournal([], false, true); + $this->__isLoaded = false; + return true; + } + return false; + } + + public function getJournalHistory(): array { + $journalDb = FronkDB::singleton(); + $pkValue = $this->{static::$__primaryKey} ?? null; + if ($pkValue === null) return []; + + $modelName = $journalDb->escape(get_called_class()); + $recordId = $journalDb->escape($pkValue); + + $res = $journalDb->query("SELECT * FROM `Journal` WHERE `model` = '$modelName' AND `record_id` = '$recordId' ORDER BY `timestamp` DESC"); + $history = []; + + if ($journalDb->num_rows($res)) { + while ($row = $journalDb->fetch_object($res)) { + if ($row->field && isset(static::$__journalFieldMap[$row->field])) { + $row->field_readable = static::$__journalFieldMap[$row->field]; + } + $history[] = $row; + } + } + return $history; + } + + public function toArray(): array { return $this->__getPublicData(); } + public function toJson(): string { return json_encode($this->toArray()); } + + // Hooks + public function validate(): array { return []; } + protected function beforeSave(bool $isInsert): bool { return true; } + protected function afterSave(bool $isInsert, array $changes): void {} + + // Internal methods + private function __load(int|string $id): bool { + $pk = static::$__primaryKey; + $res = $this->__db->select(static::$__tableName, "*", "`$pk` = '" . $this->__db->escape($id) . "' LIMIT 1"); + + if ($this->__db->num_rows($res)) { + $this->__populate($this->__db->fetch_object($res)); + $this->__isLoaded = true; + return true; + } + return false; + } + + private function __populate(stdClass $data): void { + $reflector = new ReflectionClass($this); + foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) { + $name = $prop->getName(); + if (!property_exists($data, $name)) continue; + + $type = $prop->getType()?->getName(); + $value = $data->{$name}; + + $this->{$name} = $value === null ? null : match ($type) { + 'int' => (int)$value, + 'float' => (float)$value, + 'bool' => (bool)$value, + 'string' => (string)$value, + default => $value, + }; + } + $this->__originalData = (object)$this->__getPublicData(); + } + + private function __getPublicData(): array { + $data = []; + $reflector = new ReflectionClass($this); + foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) { + $name = $prop->getName(); + $data[$name] = $prop->isInitialized($this) + ? $this->{$name} + : ($prop->hasDefaultValue() ? $prop->getDefaultValue() : null); + } + return $data; + } + + private function __getChangedFields(array $currentData): array { + $changes = []; + if (!$this->__isLoaded || !$this->__originalData) { + foreach ($currentData as $key => $value) $changes[$key] = ['old' => null, 'new' => $value]; + return $changes; + } + + foreach ($currentData as $key => $value) { + if (!property_exists($this->__originalData, $key) || $this->__originalData->{$key} != $value) { + $changes[$key] = ['old' => $this->__originalData->{$key} ?? null, 'new' => $value]; + } + } + return $changes; + } + + private function __writeToJournal(array $changes, bool $isInsert, bool $isDelete = false): void { + try { + $journalDb = FronkDB::singleton(); + $baseData = [ + 'user_id' => $this->__getUserId(), + 'model' => get_called_class(), + 'record_id' => $this->{static::$__primaryKey}, + ]; + + if ($isDelete) { + $journalDb->insert('Journal', $baseData + ['action' => 'delete']); + } elseif ($isInsert) { + $journalDb->insert('Journal', $baseData + ['action' => 'create']); + } else { + foreach ($changes as $field => $change) { + $journalDb->insert('Journal', $baseData + [ + 'action' => 'update', + 'field' => $field, + 'old_value' => is_array($change['old']) || is_object($change['old']) ? json_encode($change['old']) : $change['old'], + 'new_value' => is_array($change['new']) || is_object($change['new']) ? json_encode($change['new']) : $change['new'], + ]); + } + } + } catch (Exception $e) { + $this->__log->error("Journal write failed: " . $e->getMessage()); + } + } + + private function __getUserId(): ?int { + try { + $me = new User(); + $me->loadMe(); + return $me->id ?? null; + } catch (Exception) { + return null; + } + } + + /** + * Builds WHERE clause from filter array. + * Operators: =exact, !not, >, <, >=, <= + * Arrays become IN/NOT IN, null becomes IS NULL/IS NOT NULL + */ + private static function __buildFilterSql(array $filter): string { + $whereClauses = ["1=1"]; + $db = self::$__db_instances[static::class]; + $reflector = new ReflectionClass(static::class); + + foreach ($filter as $key => $value) { + $column = $key; + $operator = '='; + $forceExact = false; + + // Parse operator from key prefix + if (str_starts_with($key, '>=')) { $operator = '>='; $column = substr($key, 2); } + elseif (str_starts_with($key, '<=')) { $operator = '<='; $column = substr($key, 2); } + elseif (str_starts_with($key, '!')) { $operator = '!='; $column = substr($key, 1); } + elseif (str_starts_with($key, '>')) { $operator = '>'; $column = substr($key, 1); } + elseif (str_starts_with($key, '<')) { $operator = '<'; $column = substr($key, 1); } + elseif (str_starts_with($key, '=')) { $operator = '='; $column = substr($key, 1); $forceExact = true; } + + if (!$reflector->hasProperty($column)) { + mfLoghandler::singleton()->warn("Filter: Unknown property '$column' on " . static::class); + continue; + } + + // NULL handling + if ($value === null) { + $whereClauses[] = "`$column` " . ($operator === '!=' ? 'IS NOT NULL' : 'IS NULL'); + continue; + } + + // Array = IN/NOT IN + if (is_array($value)) { + $op = ($operator === '!=') ? 'NOT IN' : 'IN'; + if (empty($value)) { + $whereClauses[] = ($op === 'IN') ? "0=1" : "1=1"; + continue; + } + $escaped = array_map(fn($v) => "'" . $db->escape($v) . "'", $value); + $whereClauses[] = "`$column` $op (" . implode(',', $escaped) . ")"; + continue; + } + + // String lazy search vs exact/numeric + $prop = $reflector->getProperty($column); + $type = $prop->getType()?->getName() ?? 'string'; + + if ($type === 'string' && $operator === '=' && !$forceExact) { + foreach (explode(' ', (string)$value) as $term) { + if (empty($term)) continue; + $whereClauses[] = "`$column` LIKE '%" . $db->escape($term) . "%'"; + } + } else { + $whereClauses[] = "`$column` $operator '" . $db->escape($value) . "'"; + } + } + + return "WHERE " . implode(" AND ", $whereClauses); + } +} diff --git a/lib/mvcfronk/mfBase/mfBaseController.php b/lib/mvcfronk/mfBase/mfBaseController.php index a9cece530..573febfb5 100644 --- a/lib/mvcfronk/mfBase/mfBaseController.php +++ b/lib/mvcfronk/mfBase/mfBaseController.php @@ -260,7 +260,8 @@ class mfBaseController if ($params) { if (is_array($params) && count($params)) { - $url .= (MFUSEFANCYURLS) ? "/?" : "&"; + $qs = http_build_query($params); + /*$url .= (MFUSEFANCYURLS) ? "/?" : "&"; foreach ($params as $k => $v) { $v = urlencode($v); @@ -273,10 +274,21 @@ class mfBaseController } $url = preg_replace('/&$/', '', $url); + */ } else { + $qs = $params; + /* $url .= (MFUSEFANCYURLS) ? "/?" : "&"; $url .= $params; + */ } + $url = rtrim($url, "&?"); + if(MFUSEFANCYURLS) { + $url .= "/?".$qs; + } else { + $url .= "&".substr($qs, 1); + } + } if ($anker) { $url .= "#$anker"; diff --git a/lib/mvcfronk/mfValuecache/mfValuecache.php b/lib/mvcfronk/mfValuecache/mfValuecache.php index e2ee3501b..58db17e6e 100644 --- a/lib/mvcfronk/mfValuecache/mfValuecache.php +++ b/lib/mvcfronk/mfValuecache/mfValuecache.php @@ -59,7 +59,8 @@ class mfValuecache { $object = $this->get("mfObjectmodel-$objectname-".$id); if(!$object) { $object = new $objectname($id); - if($object->id) { + $a = new ADBNetzgebiet(1); + if((method_exists($object, "isLoaded") && !$object->isLoaded()) || !$object->id) { $this->set("mfObjectmodel-$objectname-".$id, $object); } } diff --git a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css new file mode 100644 index 000000000..579e4069b --- /dev/null +++ b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css @@ -0,0 +1,530 @@ +/** + * ADBNetzgebiet - Netzgebietverwaltung Styles + * Optimized for ~1720px width (50% of 21:9 1440p) + */ + +/* ===== Container ===== */ +.tt-scope.netzgebiet-container { + background: transparent; + color: var(--tt-text); + display: grid; + gap: 16px; + max-width: 90vw; + margin: 24px auto 0; + padding: 20px 0; +} + +.tt-scope.netzgebiet-container .card { + margin: 0; + border-radius: var(--tt-radius, 10px); + border: none; + box-shadow: var(--tt-shadow, 0 8px 24px rgba(0, 83, 132, .08)); +} + +/* ===== Header ===== */ +.tt-scope.netzgebiet-container .pane-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + background: linear-gradient(135deg, #f8fbff 0%, #f0f7fd 100%); + padding: 16px 20px; + margin: -14px -14px 14px -14px; + border-radius: var(--tt-radius, 10px) var(--tt-radius, 10px) 0 0; + border-bottom: 2px solid #e3f0f8; +} + +.tt-scope.netzgebiet-container .pane-header .title { + display: flex; + align-items: center; + gap: 12px; + font-weight: 800; + letter-spacing: .4px; + font-size: 22px; + user-select: none; + color: var(--tt-accent, #005384); + text-shadow: 0 1px 2px rgba(0,83,132,.1); +} + +.tt-scope.netzgebiet-container .logo-dot { + width: 14px; + height: 14px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #37d26b, #0f9d58 70%); + box-shadow: 0 0 0 3px rgba(15,157,88,.15); + display: inline-block; +} + +.tt-scope.netzgebiet-container .content-divider { + border: none; + height: 1px; + background-color: var(--tt-border); + margin: 16px 0; +} + +/* ===== Filter Bar ===== */ +.tt-scope.netzgebiet-container .filter-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 14px 20px; + background: #f8fafc; + border-bottom: 1px solid var(--tt-border); +} + +.tt-scope.netzgebiet-container .filter-center { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: center; +} + +.tt-scope.netzgebiet-container .filter-main { width: 200px; } +.tt-scope.netzgebiet-container .filter-md { width: 140px; } +.tt-scope.netzgebiet-container .filter-sm { width: 120px; } + +.tt-scope.netzgebiet-container .filter-bar .ri, +.tt-scope.netzgebiet-container .filter-bar select { + height: 34px; + font-size: 13px; +} + +.tt-scope.netzgebiet-container .filter-bar .ri { + padding: 6px 10px 6px 34px; +} + +.tt-scope.netzgebiet-container .filter-bar .select select { + padding: 6px 28px 6px 10px; +} + +.tt-scope.netzgebiet-container .filter-bar .input-icon { + font-size: 13px; + left: 11px; +} + +/* ===== Table Container ===== */ +.tt-scope .table-container { + overflow-x: auto; + overflow-y: auto; + max-height: calc(100vh - 200px); +} + +/* ===== Table ===== */ +.tt-scope .netzgebiet-table { + width: 100%; + min-width: 900px; + table-layout: fixed; + border-collapse: collapse; + font-size: 13px; +} + +.tt-scope .netzgebiet-table th, +.tt-scope .netzgebiet-table td { + padding: 10px 12px; + vertical-align: top; + border-bottom: 1px solid #eef1f5; +} + +.tt-scope .netzgebiet-table thead th { + position: sticky; + top: 0; + background: #f6f9fc; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #667085; + z-index: 10; + vertical-align: middle; +} + +.tt-scope .netzgebiet-table tbody tr:hover { + background: #fafbfc; +} + +/* Column Widths - optimized for 1720px */ +.tt-scope .col-name { width: 20%; } +.tt-scope .col-source { width: 14%; } +.tt-scope .col-freigabe { width: 10%; } +.tt-scope .col-network { width: 18%; } +.tt-scope .col-campaign { width: 16%; } +.tt-scope .col-consent { width: 16%; } +.tt-scope .col-actions { width: 6%; text-align: right; } + +/* ===== Name Cell ===== */ +.tt-scope .name-link { + font-weight: 600; + color: var(--tt-accent); +} + +.tt-scope .name-link:hover { + text-decoration: underline; +} + +.tt-scope .sub-text { + font-size: 11px; + color: var(--tt-muted); + margin-top: 2px; +} + +.tt-scope .truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + display: block; +} + +/* ===== Source Badge ===== */ +.tt-scope .source-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + background: #e8f0f6; + color: #3a5a70; +} + +/* ===== Freigabe Badges ===== */ +.tt-scope .freigabe-badges { + display: flex; + gap: 4px; +} + +.tt-scope .freigabe-badge { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + border-radius: 4px; + color: #fff; + cursor: default; +} + +.tt-scope .freigabe-badge.f-interest { background: #1565c0; } +.tt-scope .freigabe-badge.f-provision { background: #e65100; } +.tt-scope .freigabe-badge.f-order { background: #2e7d32; } +.tt-scope .freigabe-badge.f-reorder { background: #7b1fa2; } + +/* ===== Related Links ===== */ +.tt-scope .related-link { + display: block; + font-size: 12px; + color: var(--tt-accent); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + line-height: 1.5; +} + +.tt-scope .related-link:hover { + text-decoration: underline; +} + +.tt-scope .more-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + padding: 1px 5px; + border-radius: 3px; + background: var(--tt-accent); + color: #fff; + margin-left: 4px; +} + +.tt-scope .create-link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--tt-muted); + text-decoration: none; + opacity: 0.7; + transition: opacity 0.15s, color 0.15s; +} + +.tt-scope .create-link:hover { + opacity: 1; + color: var(--tt-accent); +} + +.tt-scope .create-link i { + font-size: 11px; +} + +/* ===== Action Buttons ===== */ +.tt-scope .col-actions { + white-space: nowrap; +} + +.tt-scope .col-actions .icon-btn { + padding: 5px 7px; +} + +/* ===== Pagination Bar ===== */ +.tt-scope .pagination-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + background: #f8fafc; + border-top: 1px solid var(--tt-border); + font-size: 13px; +} + +.tt-scope .pagination-info { + color: var(--tt-muted); +} + +.tt-scope .pagination-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.tt-scope .page-size-select { + height: 30px; + font-size: 12px; + padding: 4px 24px 4px 8px; + border-radius: 6px; + border: 1px solid var(--tt-border); + background: #fff; +} + +.tt-scope .page-indicator { + font-size: 12px; + color: var(--tt-muted); + min-width: 60px; + text-align: center; +} + +/* ===== Table Placeholder ===== */ +.tt-scope .table-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 60px 20px; + color: var(--tt-muted); + font-size: 14px; +} + +.tt-scope .table-placeholder.compact { + padding: 30px 16px; +} + +.tt-scope .table-placeholder i { + font-size: 28px; + color: var(--tt-brand-blue); + opacity: 0.5; +} + +/* ===== Modal Form ===== */ +.tt-scope .modal-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.tt-scope .form-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.tt-scope .form-grid .field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.tt-scope .form-grid .span-2 { + grid-column: span 2; +} + +.tt-scope .form-grid label, +.tt-scope .form-section label:not(.checkbox-field) { + font-size: 11px; + font-weight: 600; + color: var(--tt-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.tt-scope .form-section { + border-top: 1px solid var(--tt-border); + padding-top: 16px; +} + +.tt-scope .section-label { + display: block; + font-size: 13px; + font-weight: 700; + margin-bottom: 12px; + color: var(--tt-text); +} + +/* ===== Checkbox Fields ===== */ +.tt-scope .checkbox-row { + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.tt-scope .checkbox-field { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; +} + +.tt-scope .checkbox-field input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--tt-brand-blue); +} + +.tt-scope .options-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +/* ===== History ===== */ +.tt-scope .history-container { + max-height: 60vh; + overflow-y: auto; +} + +.tt-scope .history-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.tt-scope .history-entry { + display: flex; + gap: 10px; + padding: 8px 12px; + background: #fff; + border-radius: 6px; + border: 1px solid var(--tt-border); + border-left-width: 3px; +} + +.tt-scope .history-entry.action-update { border-left-color: #f59f0b; } +.tt-scope .history-entry.action-create { border-left-color: var(--tt-ok); } +.tt-scope .history-entry.action-delete { border-left-color: var(--tt-bad); } + +.tt-scope .history-icon { + font-size: 12px; + width: 18px; + text-align: center; + padding-top: 1px; + color: var(--tt-muted); +} + +.tt-scope .history-entry.action-update .history-icon { color: #f59f0b; } +.tt-scope .history-entry.action-create .history-icon { color: var(--tt-ok); } +.tt-scope .history-entry.action-delete .history-icon { color: var(--tt-bad); } + +.tt-scope .history-content { + flex: 1; + min-width: 0; +} + +.tt-scope .history-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + flex-wrap: wrap; +} + +.tt-scope .history-header strong { + font-weight: 600; +} + +.tt-scope .history-header .field-label { + background: #f1f3f5; + padding: 1px 6px; + border-radius: 3px; + font-family: var(--tt-mono); + font-size: 10px; +} + +.tt-scope .history-meta { + margin-left: auto; + font-size: 10px; + color: var(--tt-muted); +} + +.tt-scope .history-diff { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + font-size: 11px; +} + +.tt-scope .history-diff i { + color: var(--tt-muted); + font-size: 9px; +} + +.tt-scope .history-diff .diff-old, +.tt-scope .history-diff .diff-new { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-family: var(--tt-mono); + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: all 0.15s ease; +} + +.tt-scope .history-diff .diff-old.expandable, +.tt-scope .history-diff .diff-new.expandable { + cursor: pointer; +} + +.tt-scope .history-diff .diff-old.expandable:hover, +.tt-scope .history-diff .diff-new.expandable:hover { + filter: brightness(0.97); +} + +.tt-scope .history-diff .diff-old.expanded, +.tt-scope .history-diff .diff-new.expanded { + max-width: 400px; + white-space: normal; + word-break: break-word; +} + +.tt-scope .history-diff .diff-old { + background: #fff5f5; + color: #c92a2a; + border: 1px solid #ffc9c9; +} + +.tt-scope .history-diff .diff-new { + background: #eaf7ef; + color: #15803d; + border: 1px solid #c9e6d8; +} + +/* ===== Utilities ===== */ +.tt-scope .mono { font-family: var(--tt-mono); } +.tt-scope .muted { color: var(--tt-muted); } +.tt-scope .mt-3 { margin-top: 12px; } diff --git a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js new file mode 100644 index 000000000..ecc100c17 --- /dev/null +++ b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js @@ -0,0 +1,489 @@ +/** + * ADBNetzgebiet - Netzgebietverwaltung (Vue 3 + TT-Core) + */ + +const ADBNetzgebiet = { + name: 'ADBNetzgebiet', + template: ` +
+
+ +
+
+ + Netzgebietverwaltung +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
Name / ExtRefQuelleFreigabenNetzwerkKampagneZustimmung
+ {{ item.netzgebiet.name || '(Ohne Name)' }} +
{{ item.netzgebiet.extref }}
+
+ {{ item.netzgebiet.source || '—' }} +
{{ item.netzgebiet.source_id }}
+
+
+ {{ f.charAt(0).toUpperCase() }} + +
+
+ + + Erstellen + + + + + Erstellen + + + + + +
+ + +
+ + Lade Netzgebiete... +
+ + +
+ + Keine Netzgebiete gefunden. +
+
+ + +
+
+ {{ paginationStart }}–{{ paginationEnd }} von {{ filteredNetzgebiete.length }} Netzgebieten +
+
+ + + {{ currentPage }} / {{ totalPages }} + +
+
+
+ + + + + + + + + + +
+
+ +
+
+ + Kein Verlauf vorhanden. +
+
+
+
+ + + +
+
+
+ {{ translateAction(entry.action) }} + {{ translateField(entry.field) }} + {{ entry.user_name || 'System' }} · {{ formatTimestamp(entry.timestamp) }} +
+
+ {{ formatValue(entry.field, entry.old_value) }} + + {{ formatValue(entry.field, entry.new_value) }} +
+
+
+
+
+
+
+ `, + + data() { + return { + window: window, + isLoading: true, + isSaving: false, + netzgebiete: [], + currentPage: 1, + pageSize: 50, + filters: { name: '', extref: '', source: '', hasNetwork: '', hasCampaign: '', hasConsent: '' }, + filterDebounce: null, + showEditModal: false, + editItem: null, + showHistoryModal: false, + historyLoading: false, + historyItems: [], + historyTitle: 'Verlauf', + expandedIds: {}, + freigabeLabels: { interest: 'Interest', provision: 'Provision', order: 'Order', reorder: 'Reorder' }, + freigabeOptions: [ + { key: 'interest', label: 'Interest' }, + { key: 'provision', label: 'Provision' }, + { key: 'order', label: 'Order' }, + { key: 'reorder', label: 'Reorder' } + ], + optionsConfig: [ + { key: 'create_address_parts', label: 'create_address_parts', tooltip: 'Neue Straßen/PLZ/Ort anlegen' }, + { key: 'update_freigabe', label: 'update_freigabe', tooltip: 'Setzt Freigabe auf Basis Netzgebiet' }, + { key: 'update_address', label: 'update_address', tooltip: 'Straßennamen ändern' }, + { key: 'hausnummer_dont_overwrite_netzgebiet', label: 'dont_overwrite_netzgebiet', tooltip: 'Netzgebiete nicht überschreiben' }, + { key: 'create_preorder', label: 'create_preorder', tooltip: 'Bestellungen erstellen (SBIDI)' }, + { key: 'preorder_only_oaid', label: 'preorder_only_oaid', tooltip: 'SBIDI OAID aus RIMO' }, + { key: 'wo_ignore_status', label: 'wo_ignore_status', tooltip: 'Status ignorieren' }, + { key: 'delete_units', label: 'delete_units', tooltip: 'Homes löschen die nicht in RIMO sind' }, + { key: 'unit_create_oaid', label: 'unit_create_oaid', tooltip: 'OAID bei Unit erstellen' } + ], + defaultOptions: { + 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 + } + }; + }, + + computed: { + availableSources() { + const sources = new Set(); + this.netzgebiete.forEach(item => { + if (item.netzgebiet?.source) sources.add(item.netzgebiet.source); + }); + return Array.from(sources).sort(); + }, + hasActiveFilters() { + return Object.values(this.filters).some(v => v); + }, + filteredNetzgebiete() { + return this.netzgebiete.filter(item => { + const n = item.netzgebiet; + if (!n) return false; + if (this.filters.name && !n.name?.toLowerCase().includes(this.filters.name.toLowerCase())) return false; + if (this.filters.extref && !n.extref?.toLowerCase().includes(this.filters.extref.toLowerCase())) return false; + if (this.filters.source && n.source !== this.filters.source) return false; + const hasNetwork = item.related?.networks?.length > 0; + const hasCampaign = item.related?.campaigns?.length > 0; + const hasConsent = item.related?.consent_projects?.length > 0; + if (this.filters.hasNetwork === 'yes' && !hasNetwork) return false; + if (this.filters.hasNetwork === 'no' && hasNetwork) return false; + if (this.filters.hasCampaign === 'yes' && !hasCampaign) return false; + if (this.filters.hasCampaign === 'no' && hasCampaign) return false; + if (this.filters.hasConsent === 'yes' && !hasConsent) return false; + if (this.filters.hasConsent === 'no' && hasConsent) return false; + return true; + }); + }, + totalPages() { return Math.ceil(this.filteredNetzgebiete.length / this.pageSize) || 1; }, + paginatedItems() { + const start = (this.currentPage - 1) * this.pageSize; + return this.filteredNetzgebiete.slice(start, start + this.pageSize); + }, + paginationStart() { return this.filteredNetzgebiete.length ? (this.currentPage - 1) * this.pageSize + 1 : 0; }, + paginationEnd() { return Math.min(this.currentPage * this.pageSize, this.filteredNetzgebiete.length); }, + filteredHistory() { + return this.historyItems.filter(e => !['edit', 'create'].includes(e.field)); + } + }, + + watch: { + filteredNetzgebiete() { if (this.currentPage > this.totalPages) this.currentPage = 1; } + }, + + async mounted() { await this.fetchNetzgebiete(); }, + + methods: { + debouncedFilter() { + clearTimeout(this.filterDebounce); + this.filterDebounce = setTimeout(() => this.currentPage = 1, 300); + }, + applyFilter() { this.currentPage = 1; }, + clearFilters() { + this.filters = { name: '', extref: '', source: '', hasNetwork: '', hasCampaign: '', hasConsent: '' }; + this.currentPage = 1; + }, + async fetchNetzgebiete() { + this.isLoading = true; + try { + const response = await axios.get(window.TT_CONFIG.GET_URL); + this.netzgebiete = response.data.success ? (response.data.data || []) : (response.data || []); + } catch (error) { + console.error('Fehler:', error); + window.notify?.('error', 'Netzgebiete konnten nicht geladen werden.'); + } finally { + this.isLoading = false; + } + }, + parsedFreigabe(json) { + try { return JSON.parse(json || '[]') || []; } + catch { return []; } + }, + openCreateModal() { + this.editItem = { + id: null, name: '', extref: '', source: '', source_id: '', + freigabe: { interest: true, provision: true, order: true, reorder: true }, + options: { ...this.defaultOptions } + }; + this.showEditModal = true; + }, + openEditModal(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: n.id, name: n.name || '', extref: n.extref || '', + source: n.source || '', source_id: n.source_id || '', + freigabe: freigabeObj, + options: { ...this.defaultOptions, ...options } + }; + this.showEditModal = true; + }, + async saveNetzgebiet() { + if (!this.editItem?.name) return; + this.isSaving = true; + const freigabeArray = Object.keys(this.editItem.freigabe).filter(k => this.editItem.freigabe[k]); + const payload = { + id: this.editItem.id, name: this.editItem.name, extref: this.editItem.extref, + source: this.editItem.source, source_id: this.editItem.source_id, + freigabe: freigabeArray, options: this.editItem.options + }; + try { + const response = await axios.post(window.TT_CONFIG.SAVE_URL, payload); + if (response.data.success) { + window.notify?.('success', response.data.message); + this.showEditModal = false; + await this.fetchNetzgebiete(); + } else { + window.notify?.('error', response.data.message || 'Fehler beim Speichern.'); + } + } catch { window.notify?.('error', 'Netzwerkfehler.'); } + finally { this.isSaving = false; } + }, + async openHistoryModal(item) { + this.historyTitle = `Verlauf: ${item.netzgebiet.name}`; + this.showHistoryModal = true; + this.historyLoading = true; + this.historyItems = []; + try { + const response = await axios.get(window.TT_CONFIG.HISTORY_URL + '?id=' + item.netzgebiet.id); + this.historyItems = response.data.success ? (response.data.data || []) : (response.data || []); + } catch { window.notify?.('error', 'Verlauf konnte nicht geladen werden.'); } + finally { this.historyLoading = false; } + }, + 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', + freigabe: 'Freigaben', options: 'Optionen', unit_counts: 'Einheiten' }[field] || field; + }, + formatTimestamp(ts) { + if (!ts) return '—'; + try { return new Date(ts.replace(' ', 'T')).toLocaleString('de-AT'); } + catch { return ts; } + }, + formatValue(field, value) { + if (value === null || value === undefined || value === '') return '—'; + if (['freigabe', 'options'].includes(field)) { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (field === 'freigabe' && Array.isArray(parsed)) return parsed.join(', ') || '—'; + if (field === 'options' && typeof parsed === 'object') { + const entries = Object.entries(parsed).filter(([,v]) => v !== 0 && v !== '0'); + return entries.map(([k,v]) => `${k}: ${v}`).join(', ') || '—'; + } + } catch {} + } + return String(value); + }, + isLongValue(field, value) { + return this.formatValue(field, value).length > 40; + }, + toggleExpand(id) { + this.expandedIds[id] = !this.expandedIds[id]; + } + } +}; + +if (window.VueApp) { + window.VueApp.component('a-d-b-netzgebiet', ADBNetzgebiet); +} diff --git a/public/js/pages/AddressTickets/AddressTickets.js b/public/js/pages/AddressTickets/AddressTickets.js index fafa8f562..983931ac1 100644 --- a/public/js/pages/AddressTickets/AddressTickets.js +++ b/public/js/pages/AddressTickets/AddressTickets.js @@ -1,13 +1,15 @@ Vue.component('AddressTickets', { template: `
- -

Tickets

-
+ +

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

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

Lieferscheine

-
+
+ Keine Lieferscheine gefunden. +
+
Kundennummer Erstellt am Betreff Letztes Update
{{ ticket.customField7 }} {{ formatDate(ticket.createdAt) }} {{ ticket.subject }} {{ formatDate(ticket.updatedAt) }}
@@ -65,10 +69,15 @@ Vue.component('AddressTickets', { return {window: window}; }, computed: { + customerName() { + return this.window.TT_CONFIG?.CUSTOMER_NAME || ''; + }, + customerNumber() { + return this.window.TT_CONFIG?.CUSTOMER_NUMBER || ''; + }, tickets() { return (this.window.TT_CONFIG?.TICKETS || []).map(t => ({ id: t.id, - customField7: t.customField7, createdAt: t.createdAt, subject: t.subject, updatedAt: t.updatedAt, diff --git a/public/js/pages/AssetManagement/AssetManagement.js b/public/js/pages/AssetManagement/AssetManagement.js index 0049a9a54..a26bd81d1 100644 --- a/public/js/pages/AssetManagement/AssetManagement.js +++ b/public/js/pages/AssetManagement/AssetManagement.js @@ -1,10 +1,22 @@ window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"] = [ + { + "key": "openHistory", + "title": "Historie", + "class": "fas fa-history text-info", + "condition": (row) => window.TT_CONFIG.ASSET_ADMIN === '1', + }, { "key": "reserve", "title": "Reservieren", - "class": "fas fa-calendar-alt btn-outline-warning", + "class": "fas fa-calendar-alt text-warning", "condition": (row) => window.TT_CONFIG.ASSET_ADMIN === '1', }, + { + "key": "print", + "title": "Label drucken", + "class": "fas fa-print text-secondary", + "condition": (row) => window.TT_CONFIG.ASSET_ADMIN === '1', + } ]; // ================================================================================= // Main Asset Management Component @@ -24,6 +36,10 @@ Vue.component('asset-management', { v-if="reservationModalAsset" :asset="reservationModalAsset" @close="reservationModalAsset = null; $refs.table.$refs.table.refreshTable()"/> + @@ -31,7 +47,9 @@ Vue.component('asset-management', { ref="table" emit-edit @edit="modalId = $event.id" + @openHistory="journalModalAssetId = $event.id" @reserve="reservationModalAsset = $event" + @print="printModalAsset = $event" :crud-config="crudConfig"> - -