Merge remote-tracking branch 'origin/spidev' into spidev

This commit is contained in:
Daniel Spitzer
2025-12-27 19:37:27 +01:00
212 changed files with 24018 additions and 2711 deletions

View File

@@ -13,7 +13,7 @@
<div class="page-title-right">
<ol class="breadcrumb m-0">
<li class="breadcrumb-item"><a href="<?=self::getUrl("Dashboard")?>"><?=MFAPPNAME_SLUG?></a></li>
<?php if(is_array($filter['addresstype']) && count($filter['addresstype'])): ?>
<?php if(array_key_exists('addresstype', $filter) && is_array($filter['addresstype']) && count($filter['addresstype'])): ?>
<li class="breadcrumb-item"><a href="<?=self::getUrl("Address")?>">Personen & Firmen</a></li>
<li class="breadcrumb-item active">
<?php foreach($filter['addresstype'] as $type) { $types[] = __($type); } ?>
@@ -233,7 +233,7 @@
<td><?=$address->phone?></td>
<td><?=$address->email?></td>
<td style="text-align: left; letter-spacing: 4px; font-size: 1.1em;">
<a href="<?=self::getUrl("User", "Index", ["filter" => ["address_id" => $address->id]])?>" title="Benutzer anzeigen"><i class="fas fa-users"></i></a>
<a href="#" onclick="openPriceTypeModal(<?=$address->id?>, '<?=$address->getCompanyOrName()?>')" title="Kunden-Preistyp auswählen"><i class="fas fa-tag"></i></a>
<a href="<?=self::getUrl("Address", "view", ["id" => $address->id, 's' => $pagination['start']])?>"><i class="far fa-eyes" title="Anzeigen"></i></a>
<a href="<?=self::getUrl("Address", "sendServicePin", ["id" => $address->id])?>" onclick="if(!confirm('Soll der Service-PIN an den Kunden gesendet werden?')) return false;"><i class="fas fa-paper-plane" title="Service PIN als PDF per Email an Kunde"></i></a>
<a href="#" onclick="openCreateTicketModal(`<?=$address->getCompanyOrName()?>`, '<?=$address->customer_number?>', '<?=$address->street . ', ' . $address->zip . ' ' . $address->city?>', '<?=$address->phone?>', '<?=$address->email?>', '<?=$address->spin?>')" title="Störungs-Ticket erstellen" class="text-warning"><i class="fas fa-exclamation-triangle"></i></a>
@@ -254,6 +254,79 @@
</div>
</div>
<!-- PRICE TYPE MODAL START -->
<div class="modal fade" id="priceTypeModal" tabindex="-1" role="dialog" aria-labelledby="priceTypeModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="priceTypeModalLabel">Kunden-Preistyp auswählen</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form id="priceTypeForm">
<input type="hidden" name="address_id" id="priceType_address_id" />
<div class="form-group">
<label class="form-label" for="priceType_type_id">Preistyp für <strong id="priceType_customer_name"></strong></label>
<select class="form-control" name="priceType_type_id" id="priceType_type_id">
<option value="">Kein Preistyp (Standard)</option>
<?php
require_once(dirname(__FILE__)."/../../../application/WarehouseArticlePriceType/WarehouseArticlePriceTypeModel.php");
$priceTypes = WarehouseArticlePriceTypeModel::getAll();
foreach($priceTypes as $priceType):
?>
<option value="<?=$priceType->id?>"><?=$priceType->title?> (Faktor: <?=$priceType->defaultPriceFactor?>)</option>
<?php endforeach; ?>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick="savePriceType()">Speichern</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
const priceTypeGetUrl = '<?=self::getUrl("AddressPriceType", "get")?>';
const priceTypeSaveUrl = '<?=self::getUrl("AddressPriceType", "save")?>';
function openPriceTypeModal(addressId, customerName) {
$("#priceType_address_id").val(addressId);
$("#priceType_customer_name").text(customerName);
$("#priceType_type_id").val('');
$.get(priceTypeGetUrl, { address_id: addressId }, function(response) {
if(response && response.priceType_id) {
$("#priceType_type_id").val(response.priceType_id);
}
}, 'json');
$("#priceTypeModal").modal("show");
}
function savePriceType() {
const addressId = $("#priceType_address_id").val();
const priceTypeId = $("#priceType_type_id").val();
$.post(priceTypeSaveUrl, {
address_id: addressId,
priceType_id: priceTypeId
}, function(response) {
if(response && response.success) {
$("#priceTypeModal").modal("hide");
window.notify('success', response.message || 'Preistyp erfolgreich gespeichert');
} else {
window.notify('error', response.message || 'Fehler beim Speichern des Preistyps');
}
}, 'json');
}
</script>
<!-- PRICE TYPE MODAL END -->
<!-- CREATE TICKET MODAL START -->
<!--add a bootstrap modal here and below add a new <script> which creates a form for self::getUrl("Address", "createTicket") with post parameters

View File

@@ -189,6 +189,11 @@
<?php if($me->can("ADBExtended") || $me->isAdmin()): ?>
<a class="btn btn-outline-secondary ml-2" href="<?=self::getUrl("ADBWohneinheit", "duplicate")?>"><i class="fas fa-fw fa-copy"></i> Doppelte Homes</a>
<?php endif; ?>
<?php if($me->isAdmin()): ?>
<button type="submit" name="rimoAddressUpdate" value="1" class="btn btn-purple ml-2">
<i class="fas fa-cloud-arrow-up "></i> in Rimo Updaten
</button>
<?php endif; ?>
</div>
</div>
@@ -330,6 +335,10 @@
<a href="<?=self::getUrl("AddressDB", "view", ["id" => $address->id])?>"><i class="far fa-fw fa-eye" title="Adresse Anzeigen"></i></a>
<a href="<?=self::getUrl("AddressDB", "edit", ["id" => $address->id])?>" class="pl-1"><i class="far fa-fw fa-edit" title="Adresse Bearbeiten"></i></a>
<a href="<?=self::getUrl("AddressDB", "delete", ["id" => $address->id])?>" onclick="if(!confirm('Addresse und alle Wohneinheiten wirklich löschen?')) return false;"><i class="far fa-fw fa-trash-alt text-danger" title="Adresse Löschen"></i></a>
<?php if($me->is("Admin")): ?>
<a href="<?=self::getUrl("Building", "createFromAdb", ["adb_hausnummer_id" => $address->id])?>" target="_blank"><i class="far fa-fw fa-person-digging text-success ml-2" title="Adresse in Netzbau anlegen"></i></a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>

View File

@@ -180,7 +180,16 @@
<?php foreach($address->wohneinheiten as $unit): ?>
<tr>
<td>
<a href="#" data-home-id="<?=$unit->id?>" data-home-contact title="Kontakte bearbeiten"><i class="fas fa-users-cog text-primary"></i></a>
<?php
$contacts = $unit->contact ? json_decode($unit->contact, true) : [];
$contactCount = is_array($contacts) ? count($contacts) : 0;
?>
<a href="#" data-home-id="<?=$unit->id?>" data-home-contact title="Kontakte bearbeiten<?=$contactCount ? ' (' . $contactCount . ')' : ''?>" style="position: relative; display: inline-block;">
<i class="fas fa-users-cog <?=$contactCount ? 'text-success' : 'text-muted'?>"></i>
<?php if($contactCount): ?>
<span style="position: absolute; top: -8px; right: -8px; background: #28a745; color: white; border-radius: 50%; width: 16px; height: 16px; font-size: 10px; display: flex; align-items: center; justify-content: center; font-weight: bold; padding: 0; box-sizing: border-box;"><?=$contactCount?></span>
<?php endif; ?>
</a>
<a href="<?=self::getUrl("ADBWohneinheit", "edit", ["id" => $unit->id])?>"><i class="fas fa-edit"></i></a>
</td>
<td><?=$unit->id?></td>

View File

@@ -29,6 +29,9 @@
<div class="row col-12">
<div><a href="<?=self::getUrl("Admin", "customerStatistics")?>">Kundenstatistiken</a></div>
</div>
<div class="row col-12">
<div><a href="<?=self::getUrl("Admin", "downloadBusinessCustomers")?>">Businesskunden CSV herunterladen</a></div>
</div>
<div class="row col-12">
<div><a href="<?=self::getUrl("Admin", "RtrReporting")?>">RTR Reporting</a></div>
</div>

View File

@@ -0,0 +1,52 @@
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
margin: 0;
padding: 0;
}
.label-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.logo-25 {
max-height: 45px;
margin-bottom: 5px;
}
.logo-50 {
max-height: 70px;
margin-bottom: 10px;
}
.address {
font-size: 8px;
line-height: 1.2;
}
.address-size-50 { font-size: 16px }
.inv-number-25 {
font-size: 14px;
font-weight: bold;
margin-top: 5px;
}
.inv-number-50 {
font-size: 28px;
font-weight: bold;
margin-top: 10px;
}
</style>
<div class="label-container">
<img src="<?php echo BASEDIR ?>/public/assets/images/xinon-full.png" class="logo-<?php echo $size; ?>">
<div class="address address-size-<?php echo $size; ?>">
<?php echo $companyAddress; ?><br>
<?php echo $companyPhone; ?>
</div>
<div class="inv-number-<?php echo $size; ?>">
<?php echo $invNumber; ?>
</div>
</div>

View File

@@ -28,6 +28,9 @@
<div class="card-body">
<input type="hidden" name="id" value="<?=$building->id?>" />
<?php if(isset($adb_hausnummer_id)): ?>
<input type="hidden" name="adb_hausnummer_id" value="<?=$adb_hausnummer_id?>" />
<?php endif; ?>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="network_id">Netzgebiet *</label>
@@ -70,7 +73,11 @@
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="units">Nutzungseinheiten *</label>
<div class="col-lg-10">
<input type="number" class="form-control" name="units" id="units" value="<?=$building->units?>" />
<?php if($building->units == "from_adb"): ?>
<input type="text" class="form-control" name="units" id="units" value="(auto)" disabled="disabled" />
<?php else: ?>
<input type="number" class="form-control" name="units" id="units" value="<?=$building->units?>" />
<?php endif; ?>
</div>
</div>

View File

@@ -304,14 +304,6 @@ $pagination_entity_name = "Zustimmungserklärungen";
</div>
</div>
<?php } ?>
<!-- i have added this to stats and need to show a "traffic light kinda thing round etc" with a grid 2 row 2 col
"status_light_blue" => $status_light_blue,
"status_light_red" => $status_light_red,
"status_light_yellow" => $status_light_yellow,
"status_light_green" => $status_light_green
-->
<div class="card">
<div class="card-body p-0">
<div class="p-2" style="background-color: #f8f9fa">
@@ -344,6 +336,12 @@ $pagination_entity_name = "Zustimmungserklärungen";
<div style="width: 100%; height: 100%; background-color: #5cb85c; border-radius: 50%;"></div>
<span style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-weight: bold;"><?php echo $stats['status_light_green']; ?></span>
</div>
<!-- Gray -->
<div style="position: relative; width: 60px; height: 60px;">
<div style="width: 100%; height: 100%; background-color: #6c757d; border-radius: 50%;"></div>
<span style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-weight: bold;"><?php echo $stats['status_deferred']; ?></span>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
<?php //var_dump($project);exit; ?>
<?php if (!isset($project)) $project = null; ?>
<?php $prefillAdbNetzgebietId = $_GET['adb_netzgebiet_id'] ?? null; ?>
<!-- start page title -->
<div class="row">
<div class="col-12">
@@ -27,7 +28,7 @@
</h4>
<form class="form-horizontal" method="post" action="<?= self::getUrl("ConstructionConsentProject", "save") ?>">
<input type="hidden" name="id" value="<?=isset($project) ? $project->id : ""?>"/>
<input type="hidden" name="id" value="<?=$project ? $project->id : ""?>"/>
<div class="card">
<div class="card-body">
@@ -36,21 +37,21 @@
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="name">Projektname *</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="name" id="name" value="<?=$project->name?>" />
<input type="text" class="form-control" name="name" id="name" value="<?=$project ? $project->name : ""?>" />
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="email">Emailadresse *</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="email" id="email" value="<?=$project->email?>" />
<input type="text" class="form-control" name="email" id="email" value="<?=$project ? $project->email : ""?>" />
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="phone">Telefonnummer *</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="phone" id="phone" value="<?=$project->phone?>" />
<input type="text" class="form-control" name="phone" id="phone" value="<?=$project ? $project->phone : ""?>" />
</div>
</div>
@@ -58,8 +59,9 @@
<label class="col-lg-2 col-form-label" for="adb_network_id">Netzgebiete *</label>
<div class="col-lg-10">
<select class="form-control select2" name="adb_netzgebiet_id[]" id="adb_netzgebiet_id" multiple="multiple">
<?php $projectAdbNetworks = ($project && is_array($project->adb_networks)) ? $project->adb_networks : []; ?>
<?php foreach(ADBNetzgebietModel::getAll() as $net): ?>
<option value="<?=$net->id?>" <?=(is_array($project->adb_networks) && array_key_exists($net->id, $project->adb_networks)) ? "selected='selected'" : ""?> ><?=$net->name?></option>
<option value="<?=$net->id?>" <?=(array_key_exists($net->id, $projectAdbNetworks) || $prefillAdbNetzgebietId == $net->id) ? "selected='selected'" : ""?> ><?=$net->name?></option>
<?php endforeach; ?>
</select>
</div>
@@ -70,21 +72,21 @@
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="sender_name">Absendername *</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="sender_name" id="sender_name" value="<?=$project->sender_name?>" />
<input type="text" class="form-control" name="sender_name" id="sender_name" value="<?=$project ? $project->sender_name : ""?>" />
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="sender_email">Absender Emailadresse *</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="sender_email" id="sender_email" value="<?=$project->sender_email?>" />
<input type="text" class="form-control" name="sender_email" id="sender_email" value="<?=$project ? $project->sender_email : ""?>" />
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="sender_reply_to">Antworten an (Reply To)</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="sender_reply_to" id="sender_reply_toender_email" value="<?=$project->sender_reply_to?>" />
<input type="text" class="form-control" name="sender_reply_to" id="sender_reply_toender_email" value="<?=$project ? $project->sender_reply_to : ""?>" />
</div>
</div>
@@ -96,8 +98,9 @@
<label class="col-lg-2 col-form-label" for="sender_reply_to">Berechtigte Firmen</label>
<div class="col-lg-10">
<select class="form-control select2" name="address_id[]" id="adb_hausnummer_id" multiple="multiple">
<?php $projectAddresses = ($project && is_array($project->addresses)) ? $project->addresses : []; ?>
<?php foreach(AddressModel::search(["addresstype" => TT_NETWORK_ROLES_WITH_OWNER]) as $address): ?>
<option value="<?=$address->id?>" <?=(array_key_exists($address->id, $project->addresses)) ? "selected='selected'" : ""?>><?=$address->getCompanyOrName()?><?=($address->customer_number) ? " (".$address->customer_number.")" : ""?></option>
<option value="<?=$address->id?>" <?=(array_key_exists($address->id, $projectAddresses)) ? "selected='selected'" : ""?>><?=$address->getCompanyOrName()?><?=($address->customer_number) ? " (".$address->customer_number.")" : ""?></option>
<?php endforeach; ?>
</select>
</div>
@@ -108,7 +111,7 @@
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="note">Interne Notiz</label>
<div class="col-lg-10">
<textarea id="note" class="form-control" name="note" rows="5"><?=$project->note?></textarea>
<textarea id="note" class="form-control" name="note" rows="5"><?=$project ? $project->note : ""?></textarea>
</div>
</div>

View File

@@ -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';

View File

@@ -342,7 +342,8 @@ $pagination_entity_name = "Rechnungen";
<td><?=($invoice->billing_type == "sepa") ? "SEPA" : "Überweisung"?></td>
<td><?=($invoice->billing_delivery == "email") ? "Email" : "Papier"?></td>
<td>
<a href="<?=self::getUrl("Invoice", "downloadInvoiceCsv", ["id" => $invoice->id])?>" title="CSV-Download"><i class="fas fa-file-csv fa-fw"></i></a>
<a href="<?=self::getUrl("Invoice", "downloadInvoiceCsv", ["id" => $invoice->id])?>" title="CSV-Download"><i class="far fa-file-csv fa-fw"></i></a>
<a href="<?=self::getUrl("Invoice", "downloadInvoiceVoiceDetails", ["id" => $invoice->id])?>" title="Download EGN"><i class="far fa-square-phone-flip fa-fw ml-2"></i></a>
</td>
</tr>
<?php endforeach; ?>

View File

@@ -105,8 +105,8 @@
</select>
</div>
<div class="col-2">
<label class="form-label" for="filter_linework_doku_delay">Dokuaufschub</label>
<div class="col-1">
<label class="form-label" for="filter_linework_doku_delay">Dokuaufsch.</label>
<select name="filter[linework_doku_delay]" id="filter_linework_doku_delay" class="form-control">
<option value="0">Ausblenden</option>
<option value="1" <?=($filter['linework_doku_delay'] == 1) ? "selected='selected'" : ""?>>Anzeigen</option>
@@ -115,12 +115,17 @@
<div class="col-1">
<label class="form-label" for="filter_code">Objekt ID</label>
<input type="text" class="form-control" name="filter[code]" id="filter_code" value="<?=$filter['code']?>" />
<input type="text" class="form-control" name="filter[code]" id="filter_code" value="<?=$filter['code'] ?? ''?>" />
</div>
<div class="col-2">
<div class="col-1">
<label class="form-label" for="filter_building_street">Straße</label>
<input type="text" class="form-control" name="filter[building_street]" id="filter_building_street" value="<?=$filter['building_street']?>" />
<input type="text" class="form-control" name="filter[building_street]" id="filter_building_street" value="<?=$filter['building_street'] ?? ''?>" />
</div>
<div class="col-1">
<label class="form-label" for="filter_ap_name">AP-Name</label>
<input type="text" class="form-control" name="filter[ap_name]" id="filter_ap_name" value="<?=$filter['ap_name'] ?? ''?>" />
</div>
<div class="col-2">

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<title>Xinon Rechnung</title>
<meta charset="utf-8" />
</head>
<body style="border:0; margin: 0;font-family: sans-serif, Verdana;font-size: 11px;" onload="subst()">
<script>
function subst() {
var vars = {};
var query_strings_from_url = document.location.search.substring(1).split('&');
for (var query_string in query_strings_from_url) {
if (query_strings_from_url.hasOwnProperty(query_string)) {
var temp_var = query_strings_from_url[query_string].split('=', 2);
vars[temp_var[0]] = decodeURI(temp_var[1]);
}
}
var css_selector_classes = ['page', 'frompage', 'topage', 'webpage', 'section', 'subsection', 'date', 'isodate', 'time', 'title', 'doctitle', 'sitepage', 'sitepages'];
for (var css_class in css_selector_classes) {
if (css_selector_classes.hasOwnProperty(css_class)) {
var element = document.getElementsByClassName(css_selector_classes[css_class]);
for (var j = 0; j < element.length; ++j) {
element[j].textContent = vars[css_selector_classes[css_class]];
}
}
}
}
</script>
<div style="margin-bottom: 16px;height: 1px"></div>
<div style="color:grey;text-align: center;margin-bottom: 0">
<span>XINON GmbH | Fladnitz 150 | 8322 Studenzen</span><br>
<span>Tel.: +43 3115 40800 | E-Mail: office@xinon.at</span><br>
<span>UID: ATU68711968 | FN: 416556h | LG: Feldbach</span><br>
<span>IBAN: {{ bank_iban }} | BIC: {{ bank_bic }}</span><br>
</div>
<div style="text-align: right">Seite <span class="page"></span> von <span class="topage"></span></div>
<div style="margin-top: 16px;height: 1px"></div>
</body>
</html>

View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html>
<head>
<title>XINON Invoice Header</title>
<meta charset="utf-8" />
<style>
body {
border: 0;
margin: 0;
font-family: sans-serif, Verdana;
font-size: 12px;
}
.info-table {
border-collapse: collapse;
width: 100%;
}
.customer-details {
vertical-align: bottom;
font-size: 14px;
padding-left: 30pt;
width: 35%;
}
.invoice-details {
border: 2px solid #e1e1e1;
padding: 6px;
}
.invoice-details td {
text-align: left;
}
.invoice-details td:first-child {
text-align: right;
}
.separator {
margin-top: 24px;
height: 1px;
}
#topSpacer {
margin-bottom: 32px;
height: 20px;
}
</style>
</head>
<body>
<div id="topSpacer"></div>
<div style="height: 50px; margin-bottom: 8px">
<img alt="Xinon Logo" src="{{ basedir }}/public/assets/images/xinon-full.png" style="text-align:left;height: 85px;">
</div>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td class="customer-details">
<div>{{ addressLine_1 }}</div>
<div>{{ addressLine_2 }}</div>
<div>{{ addressLine_3 }}</div>
<div>{{ addressLine_4 }}</div>
<div>{{ addressLine_5 }}</div>
</td>
<td style="vertical-align: top; text-align: right;">
<table style="display: inline-table; vertical-align: top;">
<tr>
<td style="vertical-align: top; padding-right: 10px;">
<img alt="QR-Code" src="{{ qrCodeSrc }}" style="display: block; height: 100%; max-height: 3.5cm; width: auto;">
</td>
<td>
<table class="invoice-details">
<tr>
<td>Kundennummer:</td>
<td>{{ customerNumber }}</td>
</tr>
<tr>
<td>Verrechnungskonto:</td>
<td>{{ billingAccount }}</td>
</tr>
<tr>
<td>Rechnungsnummer:</td>
<td>{{ invoiceNumber }}</td>
</tr>
<tr>
<td>Belegdatum:</td>
<td>{{ invoiceDate }}</td>
</tr>
{{ leistungszeitraumHtml }}
{{ externeReferenzHtml }}
{{ vatHtml }}
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="separator"></div>
</body>
</html>

View File

@@ -0,0 +1,257 @@
<?php
/**
* @var string $ressourcePathPrefix
* @var ManualInvoice $invoice
* @var array $vat
*/
$net_total = $invoice->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"]);
?>
<!DOCTYPE html>
<html>
<head>
<title>Rechnung</title>
<meta charset="utf-8" />
<style>
body {
margin-top: 0;
/*padding-top: 20pt;*/
font-family: "Open Sans", sans-serif, Verdana;
font-size: 12px;
}
tr {
page-break-inside: avoid;
}
.uneven {
background-color: #ebebeb;
}
table tr td:last-child {
text-align: right;
}
.additionalRow td:first-child {
text-align: left;
padding-left: 20pt;
}
th {
height: 28px;
}
#invoiceTable tr *:nth-child(2),
#invoiceTable tr *:nth-child(4),
#invoiceTable tr *:nth-child(5),
#invoiceTable tr *:nth-child(6),
#invoiceTable tr *:nth-child(7) {
text-align: right;
}
#invoiceTable tr *:nth-child(3) {
text-align: center;
}
#invoiceTable tr *:not(:first-child) {
padding: 4px 0;
}
#invoiceTable tr td {
font-size: 11px;
}
tr.position td {
vertical-align: top;
}
tr.position td:first-child {
vertical-align: middle !important;
padding-left: 4pt;
}
#invoiceTable tr td:first-child {
max-width: 280pt;
}
</style>
</head>
<body>
<div>
<h2 style="text-align: center;color: #005384">Ihre Xinon <?=($is_credit) ? "Gutschrift" : "Rechnung"?> vom <?=date("d.m.Y",$invoice->invoice_date)?></h2>
<?php if($invoice->einleitender_text ?? ''): ?>
<p style="margin-top: 10pt; margin-bottom: 20pt; text-align: center; font-weight: bold;"><?=nl2br(htmlspecialchars($invoice->einleitender_text))?></p>
<?php endif; ?>
<table style="border-collapse: collapse; width: 100%;" id="invoiceTable">
<tr style="font-weight: bold; border-bottom: 1px solid black;" class="uneven">
<th style="text-align: center">Leistung / Produkt</th>
<th style="text-align: right">Preis</th>
<th style="text-align: center">Menge</th>
<?php if($hasDiscount): ?><th style="text-align: right">Rabatt %</th><?php endif; ?>
<th style="text-align: right">Netto €</th>
<th style="text-align: right">Ust. %</th>
<th style="text-align: right; padding-right: 4pt">Brutto €</th>
</tr>
<?php
$i = 0;
foreach($groupedPositions as $groupName => $positions):
?>
<!-- Group Header (only show if not default) -->
<?php if ($groupName !== '_default'): ?>
<tr style="background-color: #d9d9d9; font-weight: bold;">
<td colspan="<?=$hasDiscount ? '7' : '6'?>" style="padding: 6px 4pt; border-top: 1px solid black; text-align: left;">
<?=htmlspecialchars($groupName)?>
</td>
</tr>
<?php endif; ?>
<?php
foreach($positions as $p):
$amount = (float) number_format($p->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, ",",".");
?>
<tr class="position <?=($i%2 == 0) ? "even" : "uneven" ?>">
<td style="padding-left: 4pt; vertical-align: top;">
<?=htmlspecialchars($p->product_name ?? '')?>
<?php if(isset($p->product_info) && $p->product_info): ?>
<div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->product_info)?></div>
<?php endif; ?>
<?php if(isset($p->matchcode) && $p->matchcode): ?>
<div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->matchcode)?></div>
<?php endif; ?>
</td>
<td style="text-align: right; padding: 4px 0;"><?=$price?> €</td>
<td style="text-align: center; padding: 4px 0;"><?=$amount?> <?=$unit?></td>
<?php if($hasDiscount): ?><td style="text-align: right; padding: 4px 0;"><?=number_format($discount, 2, ",", ".")?>%</td><?php endif; ?>
<td style="text-align: right; padding: 4px 0;"><?=$price_total?> €</td>
<td style="text-align: right; padding: 4px 0;"><?=$vatrate?>%</td>
<td style="text-align: right; padding: 4px 0; padding-right: 4pt;"><?=$price_gross?> €</td>
</tr>
<?php
$i++;
endforeach;
endforeach;
?>
<?php if($gesamtrabatt > 0): ?>
<tr style="background-color: #ebebeb; border-top: 2px solid black;">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Zwischensumme:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($subtotal, 2, ",","."). " €"?></td>
</tr>
<tr style="background-color: #ebebeb; border-bottom: 1px solid #ccc;">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamtrabatt <?=number_format($gesamtrabatt, 2, ",", ".")?>%:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt; color: #d32f2f;">-<?=number_format($subtotal * ($gesamtrabatt / 100), 2, ",","."). " €"?></td>
</tr>
<?php endif; ?>
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;<?=($gesamtrabatt > 0) ? '' : 'border-top: 2px solid black;'?>">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamt Netto:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($net_total, 2, ",","."). " €"?></td>
</tr>
<?php foreach ($vat as $rate => $vat_total): ?>
<?php if($rate > 0): ?>
<tr style="font-size: 11px;border-bottom: 1px solid black;">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">USt. <?=number_format($rate, 0, ",", ".")?>%:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($vat_total, 2, ",","."). " €"?></td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<!-- double underline border on bottom -->
<tr style="font-weight: bold; border-bottom: 3px double black; background-color: #ebebeb;">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamt Brutto:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($gross_total, 2, ",","."). " €"?></td>
</tr>
</table>
<div style="margin-top: 20pt;">
<?php if($invoice->tax_text): ?>
<p style="font-weight: bold;"><?=$invoice->tax_text?></p>
<?php endif; ?>
<?php if($is_credit): ?>
<p style="color: #FF0000; font-weight: bold; text-align: center;">Gutschrift! Bitte nicht überweisen.</p>
<?php elseif($invoice->billing_type == "sepa"): ?>
<p style="color: #FF0000; font-weight: bold; text-align: center;">BITTE NICHT EINZAHLEN DER BETRAG WIRD AUTOMATISCH VON IHREM KONTO ABGEBUCHT!</p>
<?php else: ?>
<div style="border-top: 1px solid #ccc; padding-top: 10pt; margin-top: 10pt;">
<p style="margin-bottom: 8pt;">
<strong>Zahlungsinformationen:</strong>
</p>
<p style="margin-bottom: 4pt;">
Bitte überweisen Sie den Rechnungsbetrag bis zum <strong><?=(new DateTime("@".$invoice->invoice_date))->modify("+14 days")->format("d.m.Y")?></strong> auf folgendes Konto:
</p>
<table style="margin-left: 20pt; margin-bottom: 12pt;">
<tr><td style="width: 100pt;"><strong>IBAN:</strong></td><td><?=$bank_iban?></td></tr>
<tr><td><strong>BIC:</strong></td><td><?=$bank_bic?></td></tr>
<tr><td><strong>Bank:</strong></td><td><?=$bank_bank?></td></tr>
</table>
<div style="background-color: #f5f5f5; padding: 10pt; border-left: 3px solid #005384; margin-top: 12pt;">
<p style="margin: 0; margin-bottom: 4pt; font-size: 14px;">
<strong>Verwendungszweck: <?=$invoice->invoice_number ?? "VORSCHAU"?></strong>
</p>
<p style="margin: 0; font-size: 10px; color: #666;">
Wichtig: Bitte geben Sie den oben angeführten Verwendungszweck bei der Überweisung an, damit wir Ihre Zahlung eindeutig zuordnen können.
</p>
</div>
</div>
<?php endif; ?>
</div>
</body>
</html>

View File

@@ -1,4 +1,6 @@
<?php include(realpath(dirname(__FILE__)."/../../$mfLayoutPackage")."/header.php"); ?>
<?php if (!isset($network)) $network = null; ?>
<?php $prefillAdbNetzgebietId = $_GET['adb_netzgebiet_id'] ?? null; ?>
<!-- start page title -->
<div class="row">
@@ -8,7 +10,7 @@
<ol class="breadcrumb m-0">
<li class="breadcrumb-item"><a href="<?=self::getUrl("Dashboard")?>"><?=MFAPPNAME_SLUG?></a></li>
<li class="breadcrumb-item"><a href="<?=self::getUrl("Network")?>">Netzgebiete</a></li>
<li class="breadcrumb-item active"><?=($network->id) ? "bearbeiten" : "Neu" ?></li>
<li class="breadcrumb-item active"><?=($network && $network->id) ? "bearbeiten" : "Neu" ?></li>
</ol>
</div>
<h4 class="page-title">Netzgebiete</h4>
@@ -22,54 +24,54 @@
<div class="card">
<div class="card-body bg-">
<h4 class="header-title mb-2"><?=($network->id) ? "Netzbereich bearbeiten" : "Neuer Netzbereich"?></h4>
<h4 class="header-title mb-2"><?=($network && $network->id) ? "Netzbereich bearbeiten" : "Neuer Netzbereich"?></h4>
<form class="form-horizontal" method="post" action="<?=self::getUrl("Network", "save")?>">
<div class="card">
<div class="card-body">
<input type="hidden" name="id" value="<?=$network->id?>" />
<input type="hidden" name="id" value="<?=$network ? $network->id : ""?>" />
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="name">Name</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="name" id="name" value="<?=$network->name?>">
<input type="text" class="form-control" name="name" id="name" value="<?=$network ? $network->name : ""?>">
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="owner_id">Besitzer</label>
<div class="col-lg-10">
<select class="select2 form-control " name="owner_id" id="owner_id">
<option></option>
<?php foreach($owners as $owner): ?>
<option value="<?=$owner->id?>" <?=($network->owner_id == $owner->id) ? "selected='selected'" : ""?>><?=($owner->getCompanyOrName())?></option>
<option value="<?=$owner->id?>" <?=($network && $network->owner_id == $owner->id) ? "selected='selected'" : ""?>><?=($owner->getCompanyOrName())?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="sytemowner_action_status">Workorder Filter (Admins)</label>
<div class="col-lg-10">
<select class="form-control" name="sytemowner_action_status" id="sytemowner_action_status">
<option></option>
<option value="pipework_needed" <?=($network->sytemowner_action_status == "pipework_needed") ? "selected='selected'" : ""?>>Tiefbau ausständig</option>
<option value="building_connected" <?=($network->sytemowner_action_status == "building_connected") ? "selected='selected'" : ""?>>Tiefbau erledigt</option>
<option value="term_connected" <?=($network->sytemowner_action_status == "term_connected") ? "selected='selected'" : ""?>>Anschluss passiv erschlossen</option>
<option value="pipework_needed" <?=($network && $network->sytemowner_action_status == "pipework_needed") ? "selected='selected'" : ""?>>Tiefbau ausständig</option>
<option value="building_connected" <?=($network && $network->sytemowner_action_status == "building_connected") ? "selected='selected'" : ""?>>Tiefbau erledigt</option>
<option value="term_connected" <?=($network && $network->sytemowner_action_status == "term_connected") ? "selected='selected'" : ""?>>Anschluss passiv erschlossen</option>
</select>
</div>
</div>
<hr />
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="adb_netzgebiet_id">ADB Netzgebiet</label>
<div class="col-lg-10">
<select class="select2 form-control " name="adb_netzgebiet_id" id="adb_netzgebiet_id">
<option></option>
<?php foreach(ADBNetzgebietModel::getAll() as $adbn): ?>
<option value="<?=$adbn->id?>" <?=($network->adb_netzgebiet_id == $adbn->id) ? "selected='selected'" : ""?>><?=$adbn->name?></option>
<option value="<?=$adbn->id?>" <?=(($network && $network->adb_netzgebiet_id == $adbn->id) || $prefillAdbNetzgebietId == $adbn->id) ? "selected='selected'" : ""?>><?=$adbn->name?></option>
<?php endforeach; ?>
</select>
</div>
@@ -81,22 +83,22 @@
<label class="col-lg-2 col-form-label" for="opsystem"></label>
<div class="col-lg-10">
<label class="form-check-label">
<input type="checkbox" name="opsystem" class="form-check-input" value="snopp" id="opsystem" <?=($network->opsystem == "snopp") ? "checked='checked'" : ""?> />
<input type="checkbox" name="opsystem" class="form-check-input" value="snopp" id="opsystem" <?=($network && $network->opsystem == "snopp") ? "checked='checked'" : ""?> />
Für Betrieb in SNOPP freischalten
</label>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="note">Interne Notiz</label>
<div class="col-lg-10">
<textarea id="note" class="form-control" name="note" rows="5"><?=$network->note?></textarea>
<textarea id="note" class="form-control" name="note" rows="5"><?=$network ? $network->note : ""?></textarea>
</div>
</div>
</div>

View File

@@ -1860,7 +1860,7 @@
}
reader.readAsText(selectedFile);
reader.readAsArrayBuffer(selectedFile);
});

View File

@@ -1,9 +1,13 @@
<?php
$filter = $filter ?? [];
$voice_orders = $voice_orders ?? null;
$special_orders = $special_orders ?? null;
$showSpecial = $showSpecial ?? false;
$showVoice = $showVoice ?? false;
$pagination_baseurl = $this->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 @@
<?php if(is_array($fnet->sections) && count($fnet->sections)): ?>
<optgroup label="<?=$fnet->name?>">
<?php foreach($fnet->sections as $section): ?>
<option value="<?=$section->id?>" <?=($filter['building_networksection_id'] == $section->id) ? "selected='selected'" : ""?>><?=$section->name?></option>
<option value="<?=$section->id?>" <?=(($filter['building_networksection_id'] ?? null) == $section->id) ? "selected='selected'" : ""?>><?=$section->name?></option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
@@ -75,57 +79,65 @@
<label class="form-label" for="filter_status_id">Anschlussstatus</label>
<select name="filter[termination_status]" id="filter_building_status_id" class="form-control">
<option></option>
<option value="pipework_needed" <?=($filter['termination_status'] == "pipework_needed") ? "selected='selected'" : ""?>>Tiefbau ausständig</option>
<option value="building_connected" <?=($filter['termination_status'] == "building_connected") ? "selected='selected'" : ""?>>Tiefbau erledigt</option>
<option value="term_connected" <?=($filter['termination_status'] == "term_connected") ? "selected='selected'" : ""?>>Anschluss passiv erschlossen</option>
<?php if($me->is("Admin")): ?><option value="systemowner_action_status" <?=($filter['termination_status'] == "systemowner_action_status") ? "selected='selected'" : ""?>>Admin Workorder</option><?php endif; ?>
<option value="pipework_needed" <?=(($filter['termination_status'] ?? null) == "pipework_needed") ? "selected='selected'" : ""?>>Tiefbau ausständig</option>
<option value="building_connected" <?=(($filter['termination_status'] ?? null) == "building_connected") ? "selected='selected'" : ""?>>Tiefbau erledigt</option>
<option value="term_connected" <?=(($filter['termination_status'] ?? null) == "term_connected") ? "selected='selected'" : ""?>>Anschluss passiv erschlossen</option>
<?php if($me->is("Admin")): ?><option value="systemowner_action_status" <?=(($filter['termination_status'] ?? null) == "systemowner_action_status") ? "selected='selected'" : ""?>>Admin Workorder</option><?php endif; ?>
</select>
</div>
<div class="col-1">
<label class="form-label" for="filter_building_code">Objekt ID</label>
<input type="text" class="form-control" name="filter[building_code]" id="filter_building_code" value="<?=$filter['building_code']?>" />
<input type="text" class="form-control" name="filter[building_code]" id="filter_building_code" value="<?=$filter['building_code'] ?? ''?>" />
</div>
<div class="col-2">
<label class="form-label" for="filter_building_street">Straße (Anschluss)</label>
<input type="text" class="form-control" name="filter[building_street]" id="filter_building_street" value="<?=$filter['building_street']?>" />
<input type="text" class="form-control" name="filter[building_street]" id="filter_building_street" value="<?=$filter['building_street'] ?? ''?>" />
</div>
<div class="col-1">
<label class="form-label" for="filter_owner">Kunde</label>
<input type="text" class="form-control" name="filter[owner]" id="filter_owner" value="<?=$filter['owner']?>" />
<input type="text" class="form-control" name="filter[owner]" id="filter_owner" value="<?=$filter['owner'] ?? ''?>" />
</div>
<div class="col-2">
<label class="form-label" for="filter_owner">Straße (Kunde)</label>
<input type="text" class="form-control" name="filter[owner_address]" id="filter_owner_address" value="<?=$filter['owner_address']?>" />
<input type="text" class="form-control" name="filter[owner_address]" id="filter_owner_address" value="<?=$filter['owner_address'] ?? ''?>" />
</div>
<div class="col-1">
<label class="form-label" for="filter_partner_number">Partnernummer</label>
<input type="text" class="form-control" name="filter[partner_number]" id="filter_partner_number" value="<?=$filter['partner_number']?>" />
<input type="text" class="form-control" name="filter[partner_number]" id="filter_partner_number" value="<?=$filter['partner_number'] ?? ''?>" />
</div>
</div>
<div class="row mt-1">
<div class="col-2">
<label class="form-label" for="filter_finish_date">Bestellstatus</label>
<select name="filter[finish_date]" id="filter_finish_date" class="form-control">
<option></option>
<option value="0" <?=( (!is_array($filter) || (!array_key_exists("finish_date", $filter) || $filter["finish_date"] != "1")) ? 'selected="selected"' : "")?>>Offen</option>
<option value="1" <?=($filter["finish_date"] == "1" ? 'selected="selected"' : "")?>>Fertiggestellt</option>
<option value="waiting" <?=($filter["finish_date"] == "waiting") ? 'selected="selected"' : ""?>>Wartend / ausgeblendet</option>
<option value="0" <?=(($filter["finish_date"] ?? "0") != "1" ? 'selected="selected"' : "")?>>Offen</option>
<option value="1" <?=(($filter["finish_date"] ?? null) == "1" ? 'selected="selected"' : "")?>>Fertiggestellt</option>
<option value="waiting" <?=(($filter["finish_date"] ?? null) == "waiting") ? 'selected="selected"' : ""?>>Wartend / ausgeblendet</option>
</select>
</div>
<div class="col-1">
<label class="form-label" for="filter_customer_type">Kundentyp</label>
<select name="filter[customer_type]" id="filter_customer_type" class="form-control">
<option></option>
<option value="residential" <?=($filter['customer_type'] == "residential") ? 'selected="selected"' : ""?>>Residential</option>
<option value="business" <?=($filter['customer_type'] == "business") ? 'selected="selected"' : ""?>>Business</option>
<option value="residential" <?=(($filter['customer_type'] ?? null) == "residential") ? 'selected="selected"' : ""?>>Residential</option>
<option value="business" <?=(($filter['customer_type'] ?? null) == "business") ? 'selected="selected"' : ""?>>Business</option>
</select>
</div>
<div class="col-1">
<label class="form-label" for="filter_product_name">Produktname</label>
<input type="text" class="form-control" name="filter[product_name]" id="filter_product_name" value="<?=$filter['product_name'] ?? ''?>" />
</div>
</div>
<div class="row mt-2">
@@ -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;
}
}

View File

@@ -47,7 +47,7 @@
<select name="filter[network_id]" id="filter_network_id" class="form-control">
<option></option>
<?php foreach($mynetworks as $fnet): ?>
<option value="<?=$fnet->id?>" <?=($filter['network_id'] == $fnet->id) ? "selected='selected'" : ""?>><?=$fnet->name?></option>
<option value="<?=$fnet->id?>" <?=(($filter['network_id'] ?? null) == $fnet->id) ? "selected='selected'" : ""?>><?=$fnet->name?></option>
<?php endforeach; ?>
</select>
</div>
@@ -60,7 +60,7 @@
<?php if(is_array($fnet->sections) && count($fnet->sections)): ?>
<optgroup label="<?=$fnet->name?>">
<?php foreach($fnet->sections as $section): ?>
<option value="<?=$section->id?>" <?=($filter['networksection_id'] == $section->id) ? "selected='selected'" : ""?>><?=$section->name?></option>
<option value="<?=$section->id?>" <?=(($filter['networksection_id'] ?? null) == $section->id) ? "selected='selected'" : ""?>><?=$section->name?></option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
@@ -102,12 +102,17 @@
<div class="col-1">
<label class="form-label" for="filter_code">Objekt ID</label>
<input type="text" class="form-control" name="filter[code]" id="filter_code" value="<?=$filter['code']?>" />
<input type="text" class="form-control" name="filter[code]" id="filter_code" value="<?=$filter['code'] ?? ''?>" />
</div>
<div class="col-2">
<label class="form-label" for="filter_street">Straße</label>
<input type="text" class="form-control" name="filter[street]" id="filter_street" value="<?=$filter['street']?>" />
<input type="text" class="form-control" name="filter[street]" id="filter_street" value="<?=$filter['street'] ?? ''?>" />
</div>
<div class="col-1">
<label class="form-label" for="filter_ap_name">AP-Name</label>
<input type="text" class="form-control" name="filter[ap_name]" id="filter_ap_name" value="<?=$filter['ap_name'] ?? ''?>" />
</div>
<div class="col-2">

View File

@@ -856,6 +856,16 @@ $pagination_entity_name = "Vorbestellungen";
<?php endif; ?>
<div class="col-sm-12 col-md-1">
<label class="form-label" for="filter_tool_building_type">Gebäude Typ</label>
<select name="filter[tool_building_type]" id="filter_tool_building_type" class="form-control">
<option value="">Alle</option>
<option value="0" <?=(isset($filter) && array_key_exists("tool_building_type", $filter) && $filter['tool_building_type'] === "0") ? "selected='selected'" : ""?>>Unbekannt</option>
<option value="1" <?=(isset($filter) && array_key_exists("tool_building_type", $filter) && $filter['tool_building_type'] == "1") ? "selected='selected'" : ""?>>EFH</option>
<option value="2" <?=(isset($filter) && array_key_exists("tool_building_type", $filter) && $filter['tool_building_type'] == "2") ? "selected='selected'" : ""?>>MPH</option>
</select>
</div>
</div>
<div class="row mt-2">
@@ -1073,8 +1083,19 @@ $pagination_entity_name = "Vorbestellungen";
<td style="text-align: left; letter-spacing: 4px; font-size: 1.1em;">
<div class="preorder-campaign-table-actions">
<?php if(!$me->is(["preorderfront"]) && !$me->is("preorderreadonly")): ?>
<a href="#" data-home-id="<?=$preorder->adb_wohneinheit_id?>" data-home-contact title="Kontakte bearbeiten"><i class="fas fa-users-cog text-primary"></i></a>
<?php
$contacts = ($preorder->adb_wohneinheit_id && $preorder->adb_wohneinheit && $preorder->adb_wohneinheit->contact) ? json_decode($preorder->adb_wohneinheit->contact, true) : [];
$contactCount = is_array($contacts) ? count($contacts) : 0;
?>
<a href="#" data-home-id="<?=$preorder->adb_wohneinheit_id?>" data-home-contact title="Kontakte bearbeiten<?=$contactCount ? ' (' . $contactCount . ')' : ''?>" style="position: relative; display: inline-block;">
<i class="fas fa-users-cog <?=$contactCount ? 'text-success' : 'text-muted'?>"></i>
<?php if($contactCount): ?>
<span style="position: absolute; top: -8px; right: -8px; background: #28a745; color: white; border-radius: 50%; width: 16px; height: 16px; font-size: 10px; display: flex; align-items: center; justify-content: center; font-weight: bold; padding: 0; box-sizing: border-box;"><?=$contactCount?></span>
<?php endif; ?>
</a>
<a href="<?=self::getUrl("Preorder", "edit", ["id" => $preorder->id])?>"><i class="far fa-edit" title="Vorbestellung Bearbeiten"></i></a>
<?php endif; ?>
<?php if($me->isAdmin()): ?>
<a href="<?=self::getUrl("Preorder", "delete", ["id" => $preorder->id, "filter" => $filter])?>" class="text-danger" onclick="if(!confirm('Vorbestellung wirklich löschen?')) return false;" title="Vorbestellung Löschen"><i class="fas fa-trash"></i></a>
<?php endif; ?>
</div>
@@ -1337,18 +1358,30 @@ $pagination_entity_name = "Vorbestellungen";
/*
* Globals for map display
*/
var borderpoly = [];
<?php if(isset($campaign) && $campaign && $campaign->adb_netzgebiet): ?>
borderpoly = <?=($campaign->adb_netzgebiet->borderpoly) ? $campaign->adb_netzgebiet->borderpoly : "[]"?>;
<?php elseif($me->is("Admin")): ?>
borderpoly = [];
<?php foreach(ADBNetzgebietModel::search(["borderpoly" => true]) as $bp_netz): ?>
borderpoly.push(<?=$bp_netz->borderpoly?>);
<?php endforeach; ?>
var borderpolies = [];
<?php if($me->is("Admin")): ?>
<?php foreach(ADBNetzgebietModel::search(["borderpoly" => true]) as $bp_netz): ?>
borderpolies.push([<?=$bp_netz->borderpoly?>]);
<?php endforeach; ?>
<?php elseif(isset($campaign) && $campaign):
$adb_networks = [];
if(is_array($campaign->salesclusters) && count($campaign->salesclusters)) {
$adb_networks = $campaign->salesclusters;
} else {
$adb_networks = [$campaign->adb_netzgebiet];
}
if(count($adb_networks)): ?>
<?php foreach($adb_networks as $network): ?>
borderpoly = <?=($network->borderpoly) ?: "[]"?>;
borderpolies.push(borderpoly);
<?php endforeach; ?>
<?php endif; ?>
<?php endif; ?>
var preorderMap;
var preorders = [];
var fttxlocations = [];
var markers = [];
var markerState = true;
var mapCenterPos = [<?=TT_PLACEHOLDER_GPS_LAT?>, <?=TT_PLACEHOLDER_GPS_LONG?>];
@@ -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 = `<?php include(realpath(dirname(__FILE__))."/include/preorder_popup.php");?>`;
// popup fields
const preorder_view_url = `<?=self::getUrl("Preorder")?>/Index?filter[ucode]=${preorder.ucode}#preorder=${preorder.id}`;
var marker_popup_content = `<?php include(realpath(dirname(__FILE__))."/include/preorder_popup.php"); ?>`;
[
["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 + "<br />" + preorder.adb_plz + " " + preorder.adb_ort + "<br /><br />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;
<?php if($me->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 + "<br />" + loc.zip + " " + loc.city + "<br /><br />Execution State: " + loc.ex_state).addTo(preorderMap);
})
}
function centerMap() {
preorderMap.setView(mapCenterPos, 12);
}
@@ -1603,19 +1667,25 @@ $pagination_entity_name = "Vorbestellungen";
$.post('<?=self::getUrl("Preorder", "Api")?>', {
'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() {

View File

@@ -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;

View File

@@ -1,4 +1,6 @@
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
<?php if (!isset($campaign)) $campaign = null; ?>
<?php $prefillNetworkId = $_GET['network_id'] ?? null; ?>
<!-- start page title -->
<div class="row">
<div class="col-12">
@@ -28,7 +30,7 @@
<form class="form-horizontal" method="post"
action="<?= self::getUrl("Preordercampaign", "save") ?>">
<input type="hidden" name="id" value="<?= $campaign->id ?>"/>
<input type="hidden" name="id" value="<?= $campaign ? $campaign->id : "" ?>"/>
<div class="card">
<div class="card-body">
@@ -39,7 +41,7 @@
<select class="select2 form-control " name="network_id" id="network_id">
<option></option>
<?php foreach ($networks as $network): ?>
<option value="<?= $network->id ?>" <?= ($campaign->network_id == $network->id) ? "selected='selected'" : "" ?>><?= ($network->name) ?></option>
<option value="<?= $network->id ?>" <?= (($campaign && $campaign->network_id == $network->id) || $prefillNetworkId == $network->id) ? "selected='selected'" : "" ?>><?= ($network->name) ?></option>
<?php endforeach; ?>
</select>
</div>
@@ -49,7 +51,7 @@
<label class="col-lg-2 col-form-label" for="name">Name *</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="name" id="name"
value="<?= $campaign->name ?>"/>
value="<?= $campaign ? $campaign->name : "" ?>"/>
</div>
</div>
@@ -57,7 +59,7 @@
<label class="col-lg-2 col-form-label" for="description">Info</label>
<div class="col-lg-10">
<textarea class="form-control" style="height:120px;"
name="description"><?= $campaign->description ?></textarea>
name="description"><?= $campaign ? $campaign->description : "" ?></textarea>
</div>
</div>
@@ -65,7 +67,7 @@
<label class="col-lg-2 col-form-label" for="area">Gebiet *</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="area" id="area"
value="<?= $campaign->area ?>"/>
value="<?= $campaign ? $campaign->area : "" ?>"/>
</div>
</div>
@@ -73,7 +75,7 @@
<label class="col-lg-2 col-form-label" for="homes_total">Homes gesamt *</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="homes_total" id="homes_total"
value="<?= $campaign->homes_total ?>"/>
value="<?= $campaign ? $campaign->homes_total : "" ?>"/>
</div>
</div>
@@ -81,7 +83,7 @@
<label class="col-lg-2 col-form-label" for="from">Von</label>
<div class="col-lg-10">
<input type="text" class="form-control datepicker" name="from" id="from"
value="<?= ($campaign->from) ? date('d.m.Y', $campaign->from) : "" ?>"/>
value="<?= ($campaign && $campaign->from) ? date('d.m.Y', $campaign->from) : "" ?>"/>
</div>
</div>
@@ -89,7 +91,7 @@
<label class="col-lg-2 col-form-label" for="to">Bis</label>
<div class="col-lg-10">
<input type="text" class="form-control datepicker" name="to" id="to"
value="<?= ($campaign->to) ? date('d.m.Y', $campaign->to) : "" ?>"/>
value="<?= ($campaign && $campaign->to) ? date('d.m.Y', $campaign->to) : "" ?>"/>
</div>
</div>
@@ -100,30 +102,31 @@
<div class="col-lg-10">
<select class="form-control" name="product_type" id="product_type"
data-placeholder="Bitte auswählen ...">
<option value="all" <?= ($campaign->product_type == "all") ? "selected='selected'" : "" ?>>
<option value="all" <?= ($campaign && $campaign->product_type == "all") ? "selected='selected'" : "" ?>>
Alle Produkte im Netzgebiet
</option>
<option value="no_setup" <?= ($campaign->product_type == "no_setup") ? "selected='selected'" : "" ?>>
<option value="no_setup" <?= ($campaign && $campaign->product_type == "no_setup") ? "selected='selected'" : "" ?>>
Alle Produkte im Netzgebiet, ohne Herstellungsprodukt
</option>
<option value="setup_only" <?= ($campaign->product_type == "setup_only") ? "selected='selected'" : "" ?>>
<option value="setup_only" <?= ($campaign && $campaign->product_type == "setup_only") ? "selected='selected'" : "" ?>>
Nur Anschlussbestellung, keine Produkte
</option>
</select>
</div>
</div>
<?php $campaignTypes = ($campaign && is_array($campaign->types)) ? $campaign->types : []; ?>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="types">Erlaubte Vorbestellungstypen
*</label>
<div class="col-lg-10">
<select class="select2 form-control select2-multiple" name="types[]" id="types"
multiple="multiple" data-placeholder="Bitte auswählen ...">
<option value="interest" <?= (is_array($campaign->types) && array_key_exists("interest", $campaign->types)) ? "selected='selected'" : "" ?>><?= __("interest", "preorder") ?></option>
<option value="provision" <?= (is_array($campaign->types) && array_key_exists("provision", $campaign->types)) ? "selected='selected'" : "" ?>><?= __("provision", "preorder") ?></option>
<option value="order" <?= (is_array($campaign->types) && array_key_exists("order", $campaign->types)) ? "selected='selected'" : "" ?>><?= __("order", "preorder") ?></option>
<option value="reorder" <?= (is_array($campaign->types) && array_key_exists("reorder", $campaign->types)) ? "selected='selected'" : "" ?>><?= __("reorder", "preorder") ?></option>
<option value="legacytransfer" <?= (is_array($campaign->types) && array_key_exists("legacytransfer", $campaign->types)) ? "selected='selected'" : "" ?>><?= __("legacytransfer", "preorder") ?></option>
<option value="interest" <?= array_key_exists("interest", $campaignTypes) ? "selected='selected'" : "" ?>><?= __("interest", "preorder") ?></option>
<option value="provision" <?= array_key_exists("provision", $campaignTypes) ? "selected='selected'" : "" ?>><?= __("provision", "preorder") ?></option>
<option value="order" <?= array_key_exists("order", $campaignTypes) ? "selected='selected'" : "" ?>><?= __("order", "preorder") ?></option>
<option value="reorder" <?= array_key_exists("reorder", $campaignTypes) ? "selected='selected'" : "" ?>><?= __("reorder", "preorder") ?></option>
<option value="legacytransfer" <?= array_key_exists("legacytransfer", $campaignTypes) ? "selected='selected'" : "" ?>><?= __("legacytransfer", "preorder") ?></option>
</select>
</div>
</div>
@@ -134,16 +137,16 @@
<div class="col-lg-10">
<select class="form-control" name="fulfillment" id="fulfillment"
data-placeholder="Bitte auswählen ...">
<option value="thetool" <?= ($campaign->fulfillment == "thetool") ? "selected='selected'" : "" ?>>
<option value="thetool" <?= ($campaign && $campaign->fulfillment == "thetool") ? "selected='selected'" : "" ?>>
thetool
</option>
<option value="rimo" <?= ($campaign->fulfillment == "rimo") ? "selected='selected'" : "" ?>>
<option value="rimo" <?= ($campaign && $campaign->fulfillment == "rimo") ? "selected='selected'" : "" ?>>
RIMO
</option>
<option value="citycom_oan" <?= ($campaign->fulfillment == "citycom_oan") ? "selected='selected'" : "" ?>>
<option value="citycom_oan" <?= ($campaign && $campaign->fulfillment == "citycom_oan") ? "selected='selected'" : "" ?>>
Citycom OAN
</option>
<option value="thirdparty" <?= ($campaign->fulfillment == "thirdparty") ? "selected='selected'" : "" ?>>
<option value="thirdparty" <?= ($campaign && $campaign->fulfillment == "thirdparty") ? "selected='selected'" : "" ?>>
Drittsystem
</option>
</select>
@@ -155,13 +158,13 @@
<div class="col-lg-10">
<select class="form-control" name="oaid_origin" id="oaid_origin"
data-placeholder="Bitte auswählen ...">
<option value="thetool" <?= ($campaign->oaid_origin == "thetool") ? "selected='selected'" : "" ?>>
<option value="thetool" <?= ($campaign && $campaign->oaid_origin == "thetool") ? "selected='selected'" : "" ?>>
thetool
</option>
<option value="ofaa" <?= ($campaign->oaid_origin == "ofaa") ? "selected='selected'" : "" ?>>
<option value="ofaa" <?= ($campaign && $campaign->oaid_origin == "ofaa") ? "selected='selected'" : "" ?>>
OFAA
</option>
<option value="other" <?= ($campaign->oaid_origin == "other") ? "selected='selected'" : "" ?>>
<option value="other" <?= ($campaign && $campaign->oaid_origin == "other") ? "selected='selected'" : "" ?>>
Andere (importieren, aber nicht verarbeiten)
</option>
</select>
@@ -171,6 +174,10 @@
</div>
</div>
<?php $campaignSalesclusters = ($campaign && is_array($campaign->salesclusters)) ? $campaign->salesclusters : []; ?>
<?php $campaignAllFcpNames = ($campaign && is_array($campaign->all_fcp_names)) ? $campaign->all_fcp_names : []; ?>
<?php $campaignBannedFcps = ($campaign && is_array($campaign->banned_fcps)) ? $campaign->banned_fcps : []; ?>
<?php $campaignRequiredFields = ($campaign && is_array($campaign->required_fields)) ? $campaign->required_fields : []; ?>
<div class="card">
<div class="card-body">
@@ -182,7 +189,7 @@
name="adb_netzgebiet_ids[]" id="adb_netzgebiet_ids" multiple="multiple"
data-placeholder="Salescluster ...">
<?php foreach (ADBNetzgebietModel::getAll() as $salescluster): ?>
<option value="<?= $salescluster->id ?>" <?= (is_array($campaign->salesclusters) && array_key_exists($salescluster->id, $campaign->salesclusters)) ? "selected='selected'" : "" ?>><?= $salescluster->name ?></option>
<option value="<?= $salescluster->id ?>" <?= array_key_exists($salescluster->id, $campaignSalesclusters) ? "selected='selected'" : "" ?>><?= $salescluster->name ?></option>
<?php endforeach; ?>
</select>
</div>
@@ -195,8 +202,8 @@
<select class="select2 form-control select2-multiple bg-danger"
name="banned_rimo_fcp[]" id="banned_rimo_fcp" multiple="multiple"
data-placeholder="FCPs ...">
<?php foreach ($campaign->all_fcp_names as $fcp_name): ?>
<option value="<?= $fcp_name ?>" <?= (is_array($campaign->banned_fcps) && in_array($fcp_name, $campaign->banned_fcps)) ? "selected='selected'" : "" ?>><?= $fcp_name ?></option>
<?php foreach ($campaignAllFcpNames as $fcp_name): ?>
<option value="<?= $fcp_name ?>" <?= in_array($fcp_name, $campaignBannedFcps) ? "selected='selected'" : "" ?>><?= $fcp_name ?></option>
<?php endforeach; ?>
</select>
</div>
@@ -208,7 +215,7 @@
<div class="col-lg-10">
<select class="select2 form-control select2-multiple" name="required_fields[]"
id="required_fields" multiple="multiple" data-placeholder="Felder ...">
<option value="contact_type" <?= (is_array($campaign->required_fields) && in_array("contact_type", $campaign->required_fields)) ? "selected='selected'" : "" ?>>
<option value="contact_type" <?= in_array("contact_type", $campaignRequiredFields) ? "selected='selected'" : "" ?>>
Kontakttyp (Besitzer/Bewohner)
</option>
</select>
@@ -221,10 +228,10 @@
Ort:</label>
<div class="col-lg-10">
<select class="form-control" name="district_is_city" id="district_is_city">
<option value="0" <?= (!$campaign->district_is_city) ? "selected='selected'" : "" ?>>
<option value="0" <?= (!$campaign || !$campaign->district_is_city) ? "selected='selected'" : "" ?>>
Nein
</option>
<option value="1" <?= ($campaign->district_is_city) ? "selected='selected'" : "" ?>>
<option value="1" <?= ($campaign && $campaign->district_is_city) ? "selected='selected'" : "" ?>>
Ja
</option>
</select>
@@ -238,10 +245,10 @@
<div class="col-lg-10">
<select class="form-control" name="hausnummer_add_zusatz"
id="hausnummer_add_zusatz">
<option value="0" <?= (!$campaign->hausnummer_add_zusatz) ? "selected='selected'" : "" ?>>
<option value="0" <?= (!$campaign || !$campaign->hausnummer_add_zusatz) ? "selected='selected'" : "" ?>>
Nein
</option>
<option value="1" <?= ($campaign->hausnummer_add_zusatz) ? "selected='selected'" : "" ?>>
<option value="1" <?= ($campaign && $campaign->hausnummer_add_zusatz) ? "selected='selected'" : "" ?>>
Ja
</option>
</select>
@@ -253,10 +260,10 @@
pro Wohneinheit (API):</label>
<div class="col-lg-10">
<select class="form-control" name="exist_is_error" id="exist_is_error">
<option value="0" <?= (!$campaign->exist_is_error) ? "selected='selected'" : "" ?>>
<option value="0" <?= (!$campaign || !$campaign->exist_is_error) ? "selected='selected'" : "" ?>>
Mehr als eine
</option>
<option value="1" <?= ($campaign->exist_is_error) ? "selected='selected'" : "" ?>>
<option value="1" <?= ($campaign && $campaign->exist_is_error) ? "selected='selected'" : "" ?>>
Maximal eine
</option>
</select>
@@ -270,7 +277,7 @@
<label class="col-lg-2 col-form-label" for="cifurl">CIF Url</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="cifurl" id="cifurl"
value="<?= $campaign->cifurl ?>"/>
value="<?= $campaign ? $campaign->cifurl : "" ?>"/>
<small>
Customer Installation Feedback (für QR-Code bei Status 145).<br/>
Templatevariable <code>{{CIFTOKEN}}</code> wird mit echtem Cif Token ersetzt<br/>
@@ -284,7 +291,7 @@
for="cifcableurl">Kabelnachbestell-Url</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="cifcableurl" id="cifcableurl"
value="<?= $campaign->cifcableurl ?>"/>
value="<?= $campaign ? $campaign->cifcableurl : "" ?>"/>
<small>Für Begleitschreiben - Status 145</small>
</div>
</div>
@@ -335,13 +342,15 @@
</div>
</div>
<?php $campaignActiveOperators = ($campaign && is_array($campaign->active_operators)) ? $campaign->active_operators : []; ?>
<?php $campaignPassiveOperators = ($campaign && is_array($campaign->passive_operators)) ? $campaign->passive_operators : []; ?>
<div class="card bg-light">
<div class="card-body">
<h4>Netzbetreiber</h4>
<div class="card">
<div class="card-body">
<h4>Aktivnetzbetreiber</h4>
<?php foreach ($campaign->active_operators as $aop): ?>
<?php foreach ($campaignActiveOperators as $aop): ?>
<div class="form-group row">
<label class="col-lg-2 col-form-label"
for="active_operators_<?= $aop->id ?>"></label>
@@ -415,7 +424,7 @@
id="passive_operators" multiple="multiple"
data-placeholder="Netzbetreiber wählen ...">
<?php foreach (AddressModel::search(['addresstype' => ["netowner", "salespartner"]]) as $operator): ?>
<option value="<?= $operator->id ?>" <?= (is_array($campaign->passive_operators) && array_key_exists($operator->id, $campaign->passive_operators)) ? "selected='selected'" : "" ?>><?= $operator->getCompanyOrName() ?></option>
<option value="<?= $operator->id ?>" <?= array_key_exists($operator->id, $campaignPassiveOperators) ? "selected='selected'" : "" ?>><?= $operator->getCompanyOrName() ?></option>
<?php endforeach; ?>
</select>
</div>
@@ -433,7 +442,7 @@
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="">Netzinhaber FIBU Kostenstelle</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="netowner_fibu_cost_code" value="<?=$campaign->netowner_fibu_cost_code?>" />
<input type="text" class="form-control" name="netowner_fibu_cost_code" value="<?=$campaign ? $campaign->netowner_fibu_cost_code : ""?>" />
</div>
@@ -611,8 +620,9 @@
<select class="select2 form-control select2-multiple"
name="apiusers[]" id="apiusers" multiple="multiple"
data-placeholder="Benutzer auswählen ...">
<?php $campaignApiUsers = ($campaign && is_array($campaign->apiusers)) ? $campaign->apiusers : []; ?>
<?php foreach (UserModel::search(['apikey' => true]) as $user): ?>
<option value="<?= $user->id ?>" <?= (is_array($campaign->apiusers) && array_key_exists($user->id, $campaign->apiusers)) ? "selected='selected'" : "" ?>><?= $user->username ?>
<option value="<?= $user->id ?>" <?= array_key_exists($user->id, $campaignApiUsers) ? "selected='selected'" : "" ?>><?= $user->username ?>
(<?= $user->name ?>)
</option>
<?php endforeach; ?>
@@ -626,7 +636,7 @@
Hostnamen</label>
<div class="col-lg-10">
<textarea class="form-control"
name="corsorigins"><?= ($campaign->corsorigins) ? implode("\n", $campaign->corsorigins) : "" ?></textarea>
name="corsorigins"><?= ($campaign && $campaign->corsorigins) ? implode("\n", $campaign->corsorigins) : "" ?></textarea>
<small>Hostname der Website, mit oder ohne Protokoll
(<em>https://</em>); *. als Wildcard erlaubt
(<em>*.domain.com</em>); ein Eintrag pro Zeile</small>
@@ -642,7 +652,7 @@
<label class="col-lg-2 col-form-label" for="note">Interne Notiz</label>
<div class="col-lg-10">
<textarea class="form-control" style="height:120px;" name="note"
id="note"><?= $campaign->note ?></textarea>
id="note"><?= $campaign ? $campaign->note : "" ?></textarea>
</div>
</div>
</div>
@@ -754,8 +764,8 @@
<script>
$(document).ready(function() {
// Initialize with existing data
let iframeOrigins = <?= $campaign->iframe_origins ?? '[]'; ?>;
let iframeConsents = <?= $campaign->iframe_consents ?? '{}'; ?>;
let iframeOrigins = <?= ($campaign && $campaign->iframe_origins) ? $campaign->iframe_origins : '[]'; ?>;
let iframeConsents = <?= ($campaign && $campaign->iframe_consents) ? $campaign->iframe_consents : '{}'; ?>;
console.log(iframeConsents);

View File

@@ -0,0 +1,133 @@
<?php
if (!isset($vueViewName)) die("vueViewName is not set");
if (!isset($mfLayoutPackage)) die("mfLayoutPackage is not set");
$additionalCSS = $additionalCSS ?? [];
$additionalJS = $additionalJS ?? [];
$vueViewPath = BASEDIR . "/public/js/pages/$vueViewName";
// Load page-specific CSS and JS files
if (is_dir($vueViewPath)) {
foreach (scandir($vueViewPath) as $file) {
if ($file === '.' || $file === '..') continue;
$fileExtension = pathinfo($file, PATHINFO_EXTENSION);
if ($fileExtension === 'css') $additionalCSS[] = "js/pages/$vueViewName/$file";
else if ($fileExtension === 'js') $additionalJS[] = "js/pages/$vueViewName/$file";
}
}
// Add TT-Core CSS
$additionalCSS = [
"plugins/vue/tt-core/styles/tt-core.css",
...$additionalCSS,
];
/**
* Convert PascalCase to snake_case (e.g. PascalCase to pascal-case)
* @param string $str PascalCase string
* @return string snake-case string
*/
function pascalToSnakeCase(string $str): string {
return strtolower(preg_replace('/(?<!^)([A-Z])/', '-$1', $str));
}
$vueTagName = pascalToSnakeCase($vueViewName);
$vueHeaderPath = realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/vueHeader3.php";
if (!file_exists($vueHeaderPath))
$vueHeaderPath = realpath(dirname(__FILE__) . "/../../default") . "/vueHeader3.php";
include($vueHeaderPath); ?>
<div id="app">
<<?php echo $vueTagName; ?>>
</<?php echo $vueTagName; ?>>
</div>
<!-- TT-Core Library -->
<script src="<?php echo mfBaseController::getUrl(""); ?>plugins/vue/tt-core/index.js" type="module"></script>
<!-- Vue 3 Initialization -->
<script>
// TT-Core components to load
const ttCoreComponents = [
'plugins/vue/tt-core/components/data-display/TtDataTable.js',
'plugins/vue/tt-core/components/data-display/TtStatusChip.js',
'plugins/vue/tt-core/components/display/TtInfoCard.js',
'plugins/vue/tt-core/components/feedback/TtLoadingIndicator.js',
'plugins/vue/tt-core/components/feedback/TtSkeleton.js',
'plugins/vue/tt-core/components/forms/TtSmartAutocomplete.js',
'plugins/vue/tt-core/components/forms/TtFileDropzone.js',
'plugins/vue/tt-core/components/forms/TtCopyButton.js',
'plugins/vue/tt-core/components/overlays/TtDialog.js',
'plugins/vue/tt-core/components/navigation/TtViewSwitcher.js'
];
// All additional scripts
const allScripts = <?php echo json_encode($additionalJS); ?>;
// Separate Chart.js libraries (need to load first)
const chartLibs = allScripts.filter(s => s.includes('chart.js/chart.'));
const chartAdapters = allScripts.filter(s => s.includes('chartjs-adapter'));
const pageScripts = allScripts.filter(s => !s.includes('chart.js/') && !s.includes('chartjs-adapter'));
// Wait for TT_CORE to be loaded (since index.js is a module and loads async)
function initVueApp() {
if (typeof window.TT_CORE === 'undefined') {
// TT_CORE not loaded yet, wait a bit and try again
setTimeout(initVueApp, 50);
return;
}
const { createApp } = Vue;
const app = createApp({
data() {
return {
window: window
};
}
});
// CRITICAL: Register TT-Core components and set window.VueApp
window.TT_CORE.registerComponents(app);
// Load scripts in order:
// 1. TT-Core components
// 2. Chart.js library (if needed)
// 3. Chart.js adapters (after Chart.js)
// 4. Page-specific components
loadScripts(ttCoreComponents)
.then(() => chartLibs.length ? loadScripts(chartLibs) : Promise.resolve())
.then(() => chartAdapters.length ? loadScripts(chartAdapters) : Promise.resolve())
.then(() => loadScripts(pageScripts))
.then(() => {
// Mount the app after all components are loaded and registered
app.mount('#app');
})
.catch(err => {
console.error('Failed to load components:', err);
});
}
// Dynamically load scripts
function loadScripts(scriptPaths) {
const baseUrl = '<?php echo mfBaseController::getUrl(""); ?>';
const promises = scriptPaths.map(src => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = baseUrl + src;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(script);
});
});
return Promise.all(promises);
}
// Start initialization
initVueApp();
</script>
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>

View File

@@ -0,0 +1,935 @@
<?php
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Inventur Scanner</title>
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<meta name="theme-color" content="#005384">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.27/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'primary': '#005384',
'secondary': '#fac41b',
},
}
}
}
</script>
<style>
html, body {
overscroll-behavior: none;
touch-action: manipulation;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease-in-out; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.3s ease-out, opacity 0.3s ease-out; }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); opacity: 0; }
#qr-reader {
width: 100%;
border: none !important;
}
#qr-reader video {
border-radius: 12px;
}
#qr-reader__scan_region {
background: transparent !important;
}
#qr-reader__dashboard {
display: none !important;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.success-flash {
animation: successFlash 0.5s ease-out;
}
@keyframes successFlash {
0% { background-color: rgb(34, 197, 94); }
100% { background-color: transparent; }
}
/* Custom numpad styles */
.numpad-btn {
min-height: 52px;
font-size: 1.25rem;
font-weight: 600;
border-radius: 12px;
transition: all 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.numpad-btn:active {
transform: scale(0.95);
}
/* Warning banner animation - intense without causing overflow */
@keyframes pulse-warning {
0%, 100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7), inset 0 0 0 0 rgba(251, 191, 36, 0.2);
border-color: rgb(251, 191, 36);
background-color: rgb(254, 243, 199);
}
50% {
opacity: 0.95;
box-shadow: 0 0 20px 5px rgba(251, 191, 36, 0.6), inset 0 0 20px 0 rgba(251, 191, 36, 0.2);
border-color: rgb(245, 158, 11);
background-color: rgb(253, 230, 138);
}
}
.warning-pulse {
animation: pulse-warning 0.8s ease-in-out infinite;
}
.dark .warning-pulse {
animation: pulse-warning-dark 0.8s ease-in-out infinite;
}
@keyframes pulse-warning-dark {
0%, 100% {
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5), inset 0 0 0 0 rgba(251, 191, 36, 0.1);
border-color: rgb(217, 119, 6);
}
50% {
box-shadow: 0 0 25px 8px rgba(251, 191, 36, 0.4), inset 0 0 15px 0 rgba(251, 191, 36, 0.15);
border-color: rgb(245, 158, 11);
}
}
</style>
</head>
<body class="bg-slate-100 dark:bg-slate-900 transition-colors duration-300">
<div id="app" class="min-h-screen"></div>
<script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
const app = createApp({
setup() {
// === STATE ===
const currentScreen = ref('stocktake-select'); // stocktake-select, scanner, manual-entry
const stocktakes = ref([]);
const selectedStocktake = ref(null);
const isLoading = ref(true);
const scannerActive = ref(false);
const cameraAvailable = ref(true);
const lastScan = ref(null);
const recentScans = ref([]);
const progress = reactive({ totalScanned: 0, myScanned: 0 });
const theme = ref(localStorage.getItem('theme') || 'system');
// Categories
const categories = ref([]);
const selectedCategory = ref(null);
const showCategoryBrowser = ref(false);
// Already scanned warning
const alreadyScannedWarning = reactive({
show: false,
existingItem: null,
});
// Form state
const manualForm = reactive({
show: false,
article: null,
quantity: '',
rack: '',
shelf: '',
note: '',
searchQuery: '',
searchResults: [],
searching: false,
showNumpad: false,
});
// Scanner instance
let html5QrCode = null;
const API_BASE = window.TT_CONFIG.BASE_PATH || '/WarehouseStocktakePWA';
const api = axios.create({ baseURL: API_BASE });
// === COMPUTED ===
const isDark = computed(() => {
if (theme.value === 'dark') return true;
if (theme.value === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
// Check if mobile device for numpad display
const isMobile = computed(() => {
const ua = navigator.userAgent.toLowerCase();
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i.test(ua);
});
// === METHODS ===
const applyTheme = () => {
document.documentElement.classList.toggle('dark', isDark.value);
};
const setTheme = (newTheme) => {
theme.value = newTheme;
localStorage.setItem('theme', newTheme);
applyTheme();
};
const fetchStocktakes = async () => {
isLoading.value = true;
try {
const res = await api.get('/getActiveStocktakes');
if (res.data.success) {
stocktakes.value = res.data.stocktakes;
}
} catch (e) {
console.error('Failed to fetch stocktakes:', e);
} finally {
isLoading.value = false;
}
};
const fetchCategories = async () => {
try {
const res = await api.get('/getCategories');
if (res.data.success) {
categories.value = res.data.categories;
}
} catch (e) {
console.error('Failed to fetch categories:', e);
}
};
const selectStocktake = async (stocktake) => {
selectedStocktake.value = stocktake;
currentScreen.value = 'scanner';
await fetchMyScans();
await fetchProgress();
await fetchCategories();
await nextTick();
startScanner();
};
const backToList = () => {
stopScanner();
selectedStocktake.value = null;
currentScreen.value = 'stocktake-select';
fetchStocktakes();
};
const startScanner = async () => {
if (html5QrCode) {
await stopScanner();
}
try {
html5QrCode = new Html5Qrcode("qr-reader");
await html5QrCode.start(
{ facingMode: "environment" },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
},
onScanSuccess,
onScanFailure
);
scannerActive.value = true;
cameraAvailable.value = true;
} catch (err) {
console.error('Scanner start error:', err);
cameraAvailable.value = false;
// Don't show alert - user can use manual search
}
};
const stopScanner = async () => {
if (html5QrCode && scannerActive.value) {
try {
await html5QrCode.stop();
} catch (e) {
console.error('Scanner stop error:', e);
}
}
scannerActive.value = false;
};
const onScanSuccess = async (decodedText) => {
// Prevent rapid duplicate scans
if (lastScan.value && lastScan.value.code === decodedText && Date.now() - lastScan.value.time < 2000) {
return;
}
lastScan.value = { code: decodedText, time: Date.now() };
// Vibrate feedback
if (navigator.vibrate) {
navigator.vibrate(100);
}
// Lookup article
try {
const res = await api.get('/getArticle', { params: { code: decodedText } });
if (res.data.success) {
await handleArticleSelected(res.data.article);
} else {
showToast(res.data.message || 'Artikel nicht gefunden', 'error');
}
} catch (e) {
showToast('Fehler beim Laden des Artikels', 'error');
}
};
const onScanFailure = (error) => {
// Ignore - continuous scanning
};
const handleArticleSelected = async (article) => {
await stopScanner();
// Reset form fields
manualForm.article = article;
manualForm.quantity = '';
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.showNumpad = true;
// Check if already scanned
try {
const checkRes = await api.get('/checkAlreadyScanned', {
params: {
stocktakeId: selectedStocktake.value.id,
articleId: article.id
}
});
if (checkRes.data.success && checkRes.data.alreadyScanned) {
alreadyScannedWarning.show = true;
alreadyScannedWarning.existingItem = checkRes.data.existingItem;
} else {
alreadyScannedWarning.show = false;
alreadyScannedWarning.existingItem = null;
}
} catch (e) {
console.error('Check already scanned error:', e);
}
manualForm.show = true;
};
const submitScan = async (overwrite = false) => {
const qty = parseFloat(manualForm.quantity) || 0;
if (!manualForm.article || qty <= 0) {
showToast('Bitte Menge angeben', 'error');
return;
}
try {
const payload = {
stocktakeId: selectedStocktake.value.id,
articleId: manualForm.article.id,
quantity: qty,
rack: manualForm.rack || null,
shelf: manualForm.shelf || null,
note: manualForm.note || null,
};
if (overwrite && alreadyScannedWarning.existingItem) {
payload.overwrite = true;
payload.overwriteItemId = alreadyScannedWarning.existingItem.id;
}
const res = await api.post('/submitScan', payload);
if (res.data.success) {
showToast(res.data.message, 'success');
// Add to recent scans
recentScans.value.unshift({
...res.data.item,
scannedAt: new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
flash: true,
});
if (recentScans.value.length > 20) {
recentScans.value.pop();
}
// Update progress
if (!overwrite) {
progress.totalScanned++;
progress.myScanned++;
}
closeForm();
} else {
showToast(res.data.message || 'Fehler beim Speichern', 'error');
}
} catch (e) {
showToast('Netzwerkfehler', 'error');
}
};
const closeForm = async () => {
manualForm.show = false;
manualForm.article = null;
manualForm.quantity = '';
manualForm.searchQuery = '';
manualForm.searchResults = [];
manualForm.showNumpad = false;
alreadyScannedWarning.show = false;
alreadyScannedWarning.existingItem = null;
selectedCategory.value = null;
showCategoryBrowser.value = false;
await nextTick();
if (cameraAvailable.value) {
startScanner();
}
};
const openManualEntry = async () => {
await stopScanner();
// Reset form fields
manualForm.show = true;
manualForm.article = null;
manualForm.quantity = '';
manualForm.rack = '';
manualForm.shelf = '';
manualForm.note = '';
manualForm.searchQuery = '';
manualForm.searchResults = [];
manualForm.showNumpad = false;
alreadyScannedWarning.show = false;
selectedCategory.value = null;
showCategoryBrowser.value = false;
};
const openCategoryBrowser = () => {
showCategoryBrowser.value = true;
selectedCategory.value = null;
manualForm.searchResults = [];
};
const selectCategory = async (category) => {
selectedCategory.value = category;
showCategoryBrowser.value = false;
manualForm.searching = true;
try {
const res = await api.get('/searchArticles', {
params: { categoryId: category.id, query: manualForm.searchQuery || '' }
});
if (res.data.success) {
manualForm.searchResults = res.data.articles;
}
} catch (e) {
console.error('Category search error:', e);
} finally {
manualForm.searching = false;
}
};
const clearCategoryFilter = () => {
selectedCategory.value = null;
manualForm.searchResults = [];
if (manualForm.searchQuery.length >= 2) {
searchArticles();
}
};
const searchArticles = async () => {
if (manualForm.searchQuery.length < 2 && !selectedCategory.value) {
manualForm.searchResults = [];
return;
}
manualForm.searching = true;
try {
const params = { query: manualForm.searchQuery };
if (selectedCategory.value) {
params.categoryId = selectedCategory.value.id;
}
const res = await api.get('/searchArticles', { params });
if (res.data.success) {
manualForm.searchResults = res.data.articles;
}
} catch (e) {
console.error('Search error:', e);
} finally {
manualForm.searching = false;
}
};
const selectSearchResult = async (article) => {
await handleArticleSelected(article);
};
const fetchMyScans = async () => {
if (!selectedStocktake.value) return;
try {
const res = await api.get('/getMyScans', { params: { stocktakeId: selectedStocktake.value.id } });
if (res.data.success) {
recentScans.value = res.data.items;
}
} catch (e) {
console.error('Failed to fetch scans:', e);
}
};
const fetchProgress = async () => {
if (!selectedStocktake.value) return;
try {
const res = await api.get('/getProgress', { params: { stocktakeId: selectedStocktake.value.id } });
if (res.data.success) {
progress.totalScanned = res.data.progress.totalScanned;
progress.myScanned = res.data.progress.myScanned;
}
} catch (e) {
console.error('Failed to fetch progress:', e);
}
};
// Numpad functions
const numpadInput = (val) => {
if (val === 'clear') {
manualForm.quantity = '';
} else if (val === 'backspace') {
manualForm.quantity = String(manualForm.quantity).slice(0, -1);
} else if (val === '+') {
const current = parseFloat(manualForm.quantity) || 0;
manualForm.quantity = String(current + 1);
} else if (val === '-') {
const current = parseFloat(manualForm.quantity) || 0;
if (current > 1) {
manualForm.quantity = String(current - 1);
}
} else if (val === '.') {
if (!String(manualForm.quantity).includes('.')) {
manualForm.quantity = (manualForm.quantity || '0') + '.';
}
} else {
manualForm.quantity = (manualForm.quantity || '') + val;
}
};
// Toast notification
const toast = reactive({ show: false, message: '', type: 'success' });
let toastTimeout = null;
const showToast = (message, type = 'success') => {
toast.message = message;
toast.type = type;
toast.show = true;
if (toastTimeout) clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => { toast.show = false; }, 3000);
};
const logout = () => {
window.location.href = API_BASE + '/logout';
};
// === LIFECYCLE ===
onMounted(() => {
applyTheme();
fetchStocktakes();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
});
onUnmounted(() => {
stopScanner();
});
// Debounced search
let searchTimeout = null;
watch(() => manualForm.searchQuery, (val) => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchArticles, 300);
});
return {
currentScreen, stocktakes, selectedStocktake, isLoading, scannerActive, cameraAvailable,
recentScans, progress, theme, manualForm, toast, categories, selectedCategory,
showCategoryBrowser, alreadyScannedWarning, isMobile,
selectStocktake, backToList, submitScan, closeForm,
openManualEntry, selectSearchResult, setTheme, logout,
openCategoryBrowser, selectCategory, clearCategoryFilter, numpadInput,
};
},
template: `
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-primary text-white px-4 py-3 flex items-center justify-between sticky top-0 z-30 shadow-lg">
<div class="flex items-center">
<button v-if="currentScreen === 'scanner'" @click="backToList" class="mr-3 p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 class="font-bold text-lg">Inventur Scanner</h1>
<p v-if="selectedStocktake" class="text-xs text-white/80">{{ selectedStocktake.title }}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="setTheme(theme === 'dark' ? 'light' : 'dark')" class="p-2 rounded-full hover:bg-white/10">
<svg v-if="theme === 'dark'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<button @click="logout" class="p-2 rounded-full hover:bg-white/10">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<!-- Stocktake Selection Screen -->
<div v-if="currentScreen === 'stocktake-select'" class="p-4">
<div v-if="isLoading" class="space-y-4">
<div v-for="i in 3" :key="i" class="bg-white dark:bg-slate-800 rounded-xl p-4 shadow animate-pulse">
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
</div>
</div>
<div v-else-if="stocktakes.length === 0" class="text-center py-20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-slate-300 dark:text-slate-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-slate-500 dark:text-slate-400">Keine aktiven Inventuren</p>
<button @click="fetchStocktakes" class="mt-4 px-4 py-2 bg-primary text-white rounded-lg">
Aktualisieren
</button>
</div>
<div v-else class="space-y-4">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Aktive Inventuren auswählen:</p>
<div v-for="st in stocktakes" :key="st.id"
@click="selectStocktake(st)"
class="bg-white dark:bg-slate-800 rounded-xl p-4 shadow cursor-pointer active:scale-[0.98] transition">
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold text-slate-800 dark:text-white">{{ st.title }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ st.locationName }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">{{ st.stocktakeNumber }}</p>
</div>
<div class="text-right">
<span class="inline-block bg-secondary text-primary text-xs font-bold px-2 py-1 rounded-full">
{{ st.totalScannedItems }} Artikel
</span>
<p v-if="st.startedAt" class="text-xs text-slate-400 mt-1">{{ st.startedAt }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Scanner Screen -->
<div v-if="currentScreen === 'scanner'" class="flex flex-col h-full">
<!-- Progress Bar -->
<div class="bg-white dark:bg-slate-800 px-4 py-2 flex justify-between items-center text-sm border-b dark:border-slate-700">
<span class="text-slate-600 dark:text-slate-300">
<strong class="text-primary dark:text-secondary">{{ progress.totalScanned }}</strong> gesamt
</span>
<span class="text-slate-600 dark:text-slate-300">
<strong class="text-green-600 dark:text-green-400">{{ progress.myScanned }}</strong> von mir
</span>
</div>
<!-- Scanner View -->
<div v-if="!manualForm.show" class="p-4">
<div v-if="cameraAvailable" class="relative bg-black rounded-xl overflow-hidden mb-4">
<div id="qr-reader" class="w-full"></div>
<div v-if="!scannerActive" class="absolute inset-0 flex items-center justify-center bg-slate-900/80">
<div class="text-center text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-2 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p>Kamera wird gestartet...</p>
</div>
</div>
</div>
<div v-else class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4 mb-4">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500 mr-2 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="text-amber-800 dark:text-amber-200 font-medium">Kamera nicht verfügbar</p>
<p class="text-amber-700 dark:text-amber-300 text-sm">Verwenden Sie die manuelle Suche unten.</p>
</div>
</div>
</div>
<button @click="openManualEntry"
class="w-full py-3 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-xl font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Manuelle Suche
</button>
</div>
<!-- Entry Form -->
<transition name="slide-up">
<div v-if="manualForm.show" class="flex-1 bg-white dark:bg-slate-800 p-4 overflow-auto">
<!-- Search (if no article selected) -->
<div v-if="!manualForm.article" class="space-y-4">
<!-- Category Filter -->
<div v-if="selectedCategory" class="flex items-center bg-primary/10 dark:bg-primary/20 rounded-lg p-2 mb-2">
<span class="text-sm text-primary dark:text-secondary font-medium flex-1">
Kategorie: {{ selectedCategory.name }}
</span>
<button @click="clearCategoryFilter" class="p-1 text-primary dark:text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Search Input -->
<div class="relative">
<input v-model="manualForm.searchQuery" type="text" inputmode="search"
placeholder="Artikelnummer oder Name..."
class="w-full px-4 py-3 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-primary">
<div v-if="manualForm.searching" class="absolute right-3 top-3">
<svg class="animate-spin h-5 w-5 text-slate-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<!-- Category Browser Button -->
<button @click="openCategoryBrowser"
class="w-full py-3 bg-primary/10 dark:bg-primary/20 text-primary dark:text-secondary rounded-xl font-medium flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Nach Kategorie durchsuchen
</button>
<!-- Category Browser Modal -->
<div v-if="showCategoryBrowser" class="fixed inset-0 bg-black/50 z-50 flex items-end">
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl max-h-[70vh] flex flex-col">
<div class="p-4 border-b dark:border-slate-700 flex justify-between items-center">
<h3 class="font-bold text-lg dark:text-white">Kategorie wählen</h3>
<button @click="showCategoryBrowser = false" class="p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1 p-4">
<div class="grid grid-cols-2 gap-2">
<div v-for="cat in categories" :key="cat.id"
@click="selectCategory(cat)"
class="p-3 bg-slate-50 dark:bg-slate-700 rounded-lg cursor-pointer active:bg-slate-100 dark:active:bg-slate-600 text-center">
<p class="font-medium text-slate-800 dark:text-white text-sm">{{ cat.name }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Search Results -->
<div v-if="manualForm.searchResults.length" class="space-y-2 max-h-64 overflow-auto">
<div v-for="article in manualForm.searchResults" :key="article.id"
@click="selectSearchResult(article)"
class="p-3 bg-slate-50 dark:bg-slate-700 rounded-lg cursor-pointer active:bg-slate-100 dark:active:bg-slate-600">
<p class="font-medium text-slate-800 dark:text-white">{{ article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</p>
</div>
</div>
<button @click="closeForm" class="w-full py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl">
Abbrechen
</button>
</div>
<!-- Article Form (if article selected) -->
<div v-else class="space-y-4">
<!-- Already Scanned Warning -->
<div v-if="alreadyScannedWarning.show" class="bg-amber-100 dark:bg-amber-900/30 border-2 border-amber-400 dark:border-amber-600 rounded-xl p-4 warning-pulse">
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-amber-600 dark:text-amber-400 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-bold text-amber-800 dark:text-amber-200">Bereits gescannt!</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">
Dieser Artikel wurde bereits erfasst:
<br><strong>{{ alreadyScannedWarning.existingItem.countedQuantity }} Stk.</strong>
von {{ alreadyScannedWarning.existingItem.scannedBy }}
({{ alreadyScannedWarning.existingItem.scannedAt }})
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
Geben Sie die neue Menge ein und klicken Sie auf "Überschreiben".
</p>
</div>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-700 rounded-xl p-4">
<p class="font-bold text-lg text-slate-800 dark:text-white">{{ manualForm.article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ manualForm.article.articleNumber }}</p>
<p v-if="manualForm.article.categoryName" class="text-xs text-slate-400 mt-1">{{ manualForm.article.categoryName }}</p>
</div>
<!-- Quantity with Custom Numpad -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Menge ({{ manualForm.article.unit }}) *
</label>
<div class="text-center bg-slate-100 dark:bg-slate-700 rounded-xl p-4 mb-3">
<span class="text-4xl font-bold text-primary dark:text-secondary">
{{ manualForm.quantity || '0' }}
</span>
<span class="text-xl text-slate-500 ml-2">{{ manualForm.article.unit }}</span>
</div>
<!-- Desktop: regular input field -->
<div v-if="!isMobile" class="mt-2">
<input v-model="manualForm.quantity" type="number" inputmode="decimal" min="0.01" step="0.01"
placeholder="Menge eingeben..."
class="w-full px-4 py-3 text-xl font-bold text-center rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-primary">
</div>
<!-- Numpad (only on mobile devices) -->
<div v-if="manualForm.showNumpad && isMobile" class="grid grid-cols-4 gap-2">
<button @click="numpadInput('1')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">1</button>
<button @click="numpadInput('2')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">2</button>
<button @click="numpadInput('3')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">3</button>
<button @click="numpadInput('+')" class="numpad-btn bg-green-500 text-white">+</button>
<button @click="numpadInput('4')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">4</button>
<button @click="numpadInput('5')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">5</button>
<button @click="numpadInput('6')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">6</button>
<button @click="numpadInput('-')" class="numpad-btn bg-red-500 text-white">-</button>
<button @click="numpadInput('7')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">7</button>
<button @click="numpadInput('8')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">8</button>
<button @click="numpadInput('9')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">9</button>
<button @click="numpadInput('backspace')" class="numpad-btn bg-slate-300 dark:bg-slate-500 text-slate-800 dark:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
</svg>
</button>
<button @click="numpadInput('.')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">.</button>
<button @click="numpadInput('0')" class="numpad-btn bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white">0</button>
<button @click="numpadInput('clear')" class="numpad-btn bg-slate-300 dark:bg-slate-500 text-slate-800 dark:text-white col-span-2">C</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
<input v-model="manualForm.rack" type="text"
class="w-full px-4 py-2 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Fach</label>
<input v-model="manualForm.shelf" type="text"
class="w-full px-4 py-2 rounded-xl border dark:border-slate-600 dark:bg-slate-700 dark:text-white">
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-2 pt-4">
<div class="flex space-x-3">
<button @click="closeForm" class="flex-1 py-3 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-xl font-medium">
Abbrechen
</button>
<!-- Show "Speichern" only when NOT already scanned -->
<button v-if="!alreadyScannedWarning.show"
@click="submitScan(false)"
class="flex-1 py-3 bg-green-600 text-white rounded-xl font-bold">
Speichern
</button>
<!-- Show "Überschreiben" only when already scanned -->
<button v-else
@click="submitScan(true)"
class="flex-1 py-3 bg-amber-500 text-white rounded-xl font-bold">
Überschreiben
</button>
</div>
</div>
</div>
</div>
</transition>
<!-- Recent Scans List -->
<div v-if="!manualForm.show && recentScans.length" class="flex-1 bg-white dark:bg-slate-800 overflow-auto">
<div class="px-4 py-2 bg-slate-50 dark:bg-slate-700/50 sticky top-0">
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Letzte Scans</p>
</div>
<div class="divide-y dark:divide-slate-700">
<div v-for="(item, index) in recentScans" :key="item.id"
:class="{ 'success-flash': item.flash }"
class="px-4 py-3 flex justify-between items-center">
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ item.articleTitle }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ item.articleNumber }}
<span v-if="item.rack || item.shelf" class="ml-2">
| {{ item.rack || '-' }} / {{ item.shelf || '-' }}
</span>
</p>
</div>
<div class="text-right ml-4">
<p class="font-bold text-primary dark:text-secondary">{{ item.countedQuantity }} {{ item.unit }}</p>
<p class="text-xs text-slate-400">{{ item.scannedAt }}</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Toast Notification -->
<transition name="fade">
<div v-if="toast.show"
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
class="fixed bottom-20 left-4 right-4 p-4 rounded-xl text-white text-center font-medium shadow-lg z-50">
{{ toast.message }}
</div>
</transition>
</div>
`
});
app.mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<?php
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
// QR code options - small padding, high quality
$options = new QROptions([
'outputType' => 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);
?>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; }
html, body { height: 25mm; width: 50mm; }
body { font-family: Arial, sans-serif; color: #000; }
table { border-collapse: collapse; }
</style>
</head>
<body>
<table cellpadding="0" cellspacing="0" border="0" style="width: 50mm; height: 25mm;">
<tr>
<td style="width: 22mm; height: 25mm; vertical-align: middle; text-align: center;">
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm;">
</td>
<td style="height: 25mm; vertical-align: middle; padding-left: 1mm; padding-right: 1mm;">
<img src="<?php echo BASEDIR; ?>/public/assets/images/xinon-full-transparent.png" style="width: 24mm; height: auto; display: block; margin-bottom: 1mm;">
<div style="font-size: 10px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($articleNumber); ?></div>
<div style="font-size: 7px; color: #000; margin-top: 1px; word-wrap: break-word; overflow: hidden; text-align: center;"><?php echo htmlspecialchars($articleTitle); ?></div>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -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));
?>
<!DOCTYPE html>
@@ -116,7 +117,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
<table>
<tr>
<td class="label" style="text-align: left"><?= $text['offerNumberLabel'] ?></td>
<td><?= htmlspecialchars($offerNumber) ?></td>
<td><?= htmlspecialchars($offerNumber) ?> - v<?= isset($offer->version) ? $offer->version : 1 ?></td>
<td class="label"><?= $text['offerDateLabel'] ?></td>
<td><?= $formattedOfferDate ?></td>
</tr>
@@ -173,7 +174,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
<td class="amount"><?= number_format($p['amount'], 2, ',', '.') ?></td>
<td class="unit"><?= htmlspecialchars($p['articleUnit']) ?></td>
<td class="price"><?= formatPrice($p['price'], '€') ?></td>
<td class="price"><?= htmlspecialchars($p['discount'] . '%') ?></td>
<td class="discount"><?= htmlspecialchars($p['discount'] . '%') ?></td>
<td class="total"><?= formatPrice($p['totalPrice'], '€') ?></td>
</tr>
<?php endforeach; ?>

View File

@@ -11,7 +11,7 @@ if(preg_match('/^(.+)-1R$/', $color_name, $cmatch)) {
<select class="form-control selectpicker show-tick" name="wfitem_<?=$item->name?>" id="wfitem_<?=$item->name?>_<?=$$wftype->id?>" title="Farbe wählen" data-style="btn-outline-<?=$color_name?>">
<option></option>
<?php foreach(TT_CABLE_COLORS as $name => $color): ?>
<?php if($color['two-color']): ?>
<?php if(!empty($color['two-color'])): ?>
<option
style="color: #<?=$color["hexfg"]?>;
background: rgb(<?=$color["r"]?>,<?=$color["g"]?>,<?=$color["b"]?>);
@@ -20,16 +20,16 @@ if(preg_match('/^(.+)-1R$/', $color_name, $cmatch)) {
"
value="<?=$name?>"
data-bg-color="#<?=$color["hex"]?>" <?=($color['mark']) ? "data-icon='fa-ellipsis-h'" : ""?>
data-bg-color="#<?=$color["hex"]?>" <?=(!empty($color['mark'])) ? "data-icon='fa-ellipsis-h'" : ""?>
<?=($name == $item->value->value_string) ? "selected='selected'" : ""?>
>
<?=ucfirst($name)?>
</option>
<?php else: ?>
<option
style="background-color: rgba(<?=$color["r"]?>,<?=$color["g"]?>,<?=$color["b"]?>, .5); color: #<?=$color["hexfg"]?>"
value="<?=$name?>"
data-bg-color="#<?=$color["hex"]?>" <?=($color['mark']) ? "data-icon='fa-ellipsis-h'" : ""?>
<option
style="background-color: rgba(<?=$color["r"]?>,<?=$color["g"]?>,<?=$color["b"]?>, .5); color: #<?=$color["hexfg"]?>"
value="<?=$name?>"
data-bg-color="#<?=$color["hex"]?>" <?=(!empty($color['mark'])) ? "data-icon='fa-ellipsis-h'" : ""?>
<?=($name == $item->value->value_string) ? "selected='selected'" : ""?>
>
<?=ucfirst($name)?>

View File

@@ -144,6 +144,8 @@
<!-- add a new line Arbeitsaufträge for RMLCompany, add a new line Arbeitsaufträge-Management for RMLAdmin -->
<?php if($me->can("RMLCompany")): ?><li><a href="<?=self::getUrl("WorkorderCompany")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge</a></li><?php endif; ?>
<?php if($me->can("RMLAdmin")): ?><li><a href="<?=self::getUrl("WorkorderAdmin")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge-Management</a></li><?php endif; ?>
<?php if($me->can("WorkorderMph")): ?><li><a href="<?=self::getUrl("WorkorderMphCompany")?>"><i class="far fa-fw fa-buildings text-info"></i> MPH Arbeitsaufträge</a></li><?php endif; ?>
<?php if($me->can("WorkorderMphAdmin")): ?><li><a href="<?=self::getUrl("WorkorderMphAdmin")?>"><i class="far fa-fw fa-buildings text-info"></i> MPH Arbeitsaufträge Verwaltung</a></li><?php endif; ?>
</ul>
</li>
<?php endif; ?>
@@ -179,9 +181,10 @@
<!-- --><?php //if($me->can("WarehouseAdmin")): ?><!--<li><a href="--><?php //=self::getUrl("WarehouseItem")?><!--"><i class="far fa-fw fa-boxes text-info"></i> Lagerbestand (WIP)</a></li>--><?php //endif; ?>
<!-- --><?php //if($me->can("WarehouseAdmin")): ?><!--<li><a href="--><?php //=self::getUrl("WarehouseOrderRecommendation")?><!--"><i class="far fa-fw fa-box-full text-info"></i> Bestellvorschläge (WIP)</a></li>--><?php //endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseOrder")?>"><i class="far fa-fw fa-shopping-bag text-info"></i> Bestellungen</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseOffer")?>"><i class="far fa-fw fa-file-signature text-info"></i> Angebote</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseOrderRequest")?>"><i class="far fa-fw fa-shopping-cart text-info"></i> Bestellwünsche</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseShippingNote")?>"><i class="far fa-fw fa-shipping-fast text-info"></i> Lieferscheine</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseProject")?>"><i class="fas fa-fw fa-project-diagram text-info"></i> Projekte</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseStocktake")?>"><i class="far fa-fw fa-clipboard-check text-info"></i> Inventur</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseDistributor")?>"><i class="far fa-fw fa-cogs text-info"></i> Administration</a></li><?php endif; ?>
@@ -209,7 +212,7 @@
</li>
<?php endif; ?>
<?php if($me->is(["Admin","netowner","salespartner"]) || $me->can(["Order", "Preorder"])): ?>
<?php if($me->is(["Admin","netowner","salespartner"]) || $me->can(["Order", "Preorder", "WarehouseAdmin"])): ?>
<li class="has-submenu">
<a href="#">
<i class="fal fa-fw fa-money-bill-wave"></i>Verkauf <div class="arrow-down"></div>
@@ -220,6 +223,8 @@
<li><a href="<?=self::getUrl("Preorder", "RimoTypeMap")?>"><i class="far fa-fw fa-map text-info"></i> RIMO Typen-Karte</a></li>
<?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseOffer")?>"><i class="far fa-fw fa-file-signature text-info"></i> Angebote</a></li><?php endif; ?>
<?php if($me->is(["Admin","salespartner"]) && $me->can("Order")): ?>
<li class="border-top"><a href="<?=self::getUrl("Order")?>"><i class="far fa-fw fa-file-signature text-info"></i> Bestellungen</a></li>
<?php endif; ?>

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= MFAPPNAME_FULL ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="shortcut icon" href="<?= self::getResourcePath() ?>assets/images/favicon.ico">
<link href="<?= self::getResourcePath() ?>cssbundler.php?<?= $git_merge_ts ?>" rel="stylesheet">
<link href="<?= self::getResourcePath() ?>fontawesome/css/fontawesome.min.css?<?= $git_merge_ts ?>"
rel="stylesheet">
<link href="<?= self::getResourcePath() ?>fontawesome/css/solid.min.css?<?= $git_merge_ts ?>" rel="stylesheet">
<link href="<?= self::getResourcePath() ?>fontawesome/css/regular.min.css?<?= $git_merge_ts ?>" rel="stylesheet">
<link href="<?=self::getResourcePath()?>fontawesome/css/duotone.min.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
<link href="<?=self::getResourcePath()?>fontawesome/css/duotone-regular.min.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
<link href="<?= self::getResourcePath() ?>fontawesome/css/sharp-light.min.css?<?= $git_merge_ts ?>"
rel="stylesheet">
<?php if (!empty($additionalCSS)):
foreach ($additionalCSS as $css): ?>
<link rel="stylesheet" href="<?= self::getResourcePath() ?><?= $css ?>?<?= $git_merge_ts ?>">
<?php endforeach;
endif;
if (!empty($additionalHead)):
foreach ($additionalHead as $head):
echo $head;
endforeach;
endif; ?>
<script>
const baseurl = '<?=self::getResourcePath()?>';
window.mfNotify = <?=json_encode($mfNotify ?? null)?>;
window.TT_CONFIG = {};
<?php
if(isset($JSGlobals) && is_array($JSGlobals) && count($JSGlobals)):
foreach($JSGlobals as $key => $value): ?>
window.TT_CONFIG.<?=$key?> = <?=is_array($value) ? json_encode($value) : "'$value'"; ?>;
<?php endforeach; endif;?>
</script>
<script type="text/javascript" src="<?=self::getResourcePath()?>js/jquery.min.js"></script>
<script type="text/javascript" src="<?=self::getResourcePath()?>js/popper.min.js"></script>
<script type="text/javascript" src="<?=self::getResourcePath()?>js/bootstrap.min.js"></script>
<!-- Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Axios for HTTP requests -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<!-- Moment.js for date handling -->
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
<script src="<?= self::getResourcePath() ?>plugins/notification/notify.js" defer></script>
<script src="<?= self::getResourcePath() ?>plugins/bookstack/bookstackIntegration.js" defer></script>
<style>
body {
min-height: 100vh;
}
<?php if (MFAPPNAME === "devthetool"): ?>
body {
border-left: 8px dashed #f672a7;
}
<?php endif; ?>
</style>
</head>
<body>
<header id="topnav">
<?php
include(__DIR__ . "/topbar.php");
include(__DIR__ . "/menu.php");
?>
</header>
<div class="wrapper pl-0 pl-lg-1 pr-0 pr-lg-1">
<div class="container-fluid">

View File

@@ -189,6 +189,12 @@ class ADBHausnummerModel {
}
/**
* @param $filter
* @param $limit
* @param $returnDBRessource
* @return ADBHausnummer[] ADBHausnummer Objects
*/
public static function search($filter, $limit = false, $returnDBRessource = false) {
$items = [];
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);

View File

@@ -1,78 +1,124 @@
<?php
class ADBNetzgebiet extends mfBaseModel {
private $gemeinden;
private $json_options;
protected function init() {
$this->db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$this->table = "Netzgebiet";
}
public function loadByExtref($extref) {
$extref = $this->db->escape(trim($extref));
if(!$extref) {
return false;
}
$res = $this->db->select("Netzgebiet", "*", "extref='$extref'");
if(!$this->db->num_rows($res)) {
return false;
}
$data = $this->db->fetch_object($res);
$this->load($data);
return true;
}
public function getOption($opt) {
$options = $this->getOptions();
if(!$options) return null;
if(property_exists($options, $opt)) {
return $options->$opt;
}
return null;
}
public function getOptions() {
if(!$this->options) {
return false;
}
$opts = json_decode($this->options);
if(json_last_error() != JSON_ERROR_NONE) {
return null;
}
return $opts;
}
public function getProperty($name) {
if($this->$name == null) {
if($name == "gemeinden") {
$gemeinden = [];
foreach(ADBGemeindeNetzgebietModel::search(["netzgebiet_id" => $this->id]) as $gem_netz) {
$g = $gem_netz->gemeinde;
if(!$g || array_key_exists($g->gemeinde_id, $gemeinden)) continue;
//var_dump($g);exit;
$gemeinden[$g->id] = $g;
}
if(count($gemeinden)) {
$this->gemeinden = $gemeinden;
}
return $this->gemeinden;
}
$classname = ucfirst($name);
$idfield = $name."_id";
$this->$name = new $classname($this->$idfield);
if($this->$name->id) {
return $this->$name;
} else {
return null;
}
}
return $this->$name;
}
require_once LIBDIR . '/mfBaseModelV2/mfBaseModelV2.php';
class ADBNetzgebietRelations {
/** @var array{id: int, name: string}[] */
public array $networks = [];
/** @var array{id: int, name: string}[] */
public array $campaigns = [];
/** @var array{id: int, name: string}[] */
public array $consentProjects = [];
}
/**
* @property-read ADBGemeinde[] $gemeinden
* @property-read ADBNetzgebietRelations $relations
*/
class ADBNetzgebiet extends mfBaseModelV2 {
protected static string $__tableName = 'Netzgebiet';
protected static string $__primaryKey = 'id';
protected static ?array $__databaseConfig = [
'host' => ADDRESSDB_DBHOST,
'user' => ADDRESSDB_DBUSER,
'pass' => ADDRESSDB_DBPASS,
'name' => ADDRESSDB_DBNAME
];
protected static array $__journalFieldMap = [
'name' => 'Name', 'extref' => 'ExtRef', 'rimo_id' => 'RIMO ID',
'source' => 'Source', 'source_id' => 'Source ID', 'borderpoly' => 'Border Polygon',
'freigabe' => 'Freigaben', 'options' => 'Options', 'create' => 'Erstellt', 'edit' => 'Bearbeitet',
];
public int $id;
public ?string $name = null;
public ?string $extref = null;
public ?string $rimo_id = null;
public ?string $source = null;
public ?string $source_id = null;
public ?string $borderpoly = null;
public ?string $freigabe = '["interest", "provision", "order", "reorder"]';
public ?string $options = '{"create_address_parts": 0, "update_freigabe": 1, "update_address": 1, "hausnummer_dont_overwrite_netzgebiet": 0, "create_preorder": 0, "preorder_only_oaid": 0, "wo_ignore_status": 0, "delete_units": 0, "mph_min_homes_tool_automatic_count": 3, "unit_create_oaid": 0}';
public int $create;
public int $edit;
private ?array $__gemeinden = null;
private ?ADBNetzgebietRelations $__relations = null;
public function __get(string $name) {
if ($name === 'gemeinden') {
if ($this->__gemeinden === null) {
$this->__gemeinden = [];
foreach (ADBGemeindeNetzgebietModel::search(["netzgebiet_id" => $this->id]) as $gem_netz) {
$g = $gem_netz->gemeinde;
if (!$g || array_key_exists($g->id, $this->__gemeinden)) continue;
$this->__gemeinden[$g->id] = $g;
}
}
return $this->__gemeinden;
}
if ($name === 'relations') {
return $this->__relations ??= $this->loadRelations();
}
return null;
}
public function loadByExtref(string $extref): bool {
$extref = trim($extref);
if (empty($extref)) return false;
$found = static::getFirst(['=extref' => $extref]);
if ($found) {
foreach (get_object_vars($found) as $key => $value) {
if (property_exists($this, $key) && !str_starts_with($key, '__')) {
$this->$key = $value;
}
}
return true;
}
return false;
}
public function getOption(string $opt): mixed {
$options = $this->getOptions();
return $options && property_exists($options, $opt) ? $options->$opt : null;
}
public function getOptions(): ?object {
if (empty($this->options)) return null;
$opts = json_decode($this->options);
return json_last_error() === JSON_ERROR_NONE ? $opts : null;
}
public function getFreigabe(): array {
if (empty($this->freigabe)) return [];
$freigabe = json_decode($this->freigabe, true);
return json_last_error() === JSON_ERROR_NONE ? $freigabe : [];
}
public function loadRelations(): ADBNetzgebietRelations {
$rel = new ADBNetzgebietRelations();
$networks = NetworkModel::search(['adb_netzgebiet_id' => $this->id]);
foreach ($networks as $network) {
$rel->networks[] = ['id' => $network->id, 'name' => $network->name];
}
$networkIds = array_column($rel->networks, 'id');
if (!empty($networkIds)) {
$campaigns = PreordercampaignModel::search(['network_id' => $networkIds]);
foreach ($campaigns as $campaign) {
$rel->campaigns[] = ['id' => $campaign->id, 'name' => $campaign->name];
}
}
$rel->consentProjects = ConstructionConsentProject::getByAdbNetzgebietId($this->id);
return $rel;
}
}

View File

@@ -0,0 +1,137 @@
<?php
class ADBNetzgebietController extends mfBaseController {
public User $me;
private array $postData = [];
protected function init(): void {
$this->needlogin = true;
$this->me = new User();
$this->me->loadMe();
$this->layout()->set("me", $this->me);
if (!$this->me->is("Admin")) {
$this->redirect("Dashboard");
}
$rawInput = file_get_contents('php://input');
if ($rawInput) $this->postData = json_decode($rawInput, true) ?? [];
}
protected function indexAction(): void {
Helper::renderVue3($this, $this->mod, "Netzgebietverwaltung", [
"GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"),
"SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"),
"HISTORY_URL" => $this::getUrl("ADBNetzgebiet/getHistory"),
"NETWORK_URL" => $this::getUrl("Network/Index"),
"NETWORK_CREATE_URL" => $this::getUrl("Network/add"),
"CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"),
"CAMPAIGN_CREATE_URL" => $this::getUrl("Preordercampaign/add"),
"CONSENT_URL" => $this::getUrl("ConstructionConsentProject/edit"),
"CONSENT_CREATE_URL" => $this::getUrl("ConstructionConsentProject/add"),
"HIDE_PAGE_TITLE" => true,
"USER_ID" => $this->me->id,
]);
}
protected function getNetzgebieteAction(): void {
$filter = [];
if (!empty($_GET['name'])) $filter['name'] = $_GET['name'];
if (!empty($_GET['extref'])) $filter['extref'] = $_GET['extref'];
if (!empty($_GET['source'])) $filter['=source'] = $_GET['source'];
if (!empty($_GET['source_id'])) $filter['source_id'] = $_GET['source_id'];
$allNetzgebiete = ADBNetzgebiet::getAll($filter, null, 0, ['column' => 'name', 'dir' => 'ASC']);
$response = [];
foreach ($allNetzgebiete as $netzgebiet) {
$response[] = [
'netzgebiet' => $netzgebiet->toArray(),
'related' => [
'networks' => $netzgebiet->relations->networks,
'campaigns' => $netzgebiet->relations->campaigns,
'consent_projects' => $netzgebiet->relations->consentProjects
]
];
}
self::returnJson(['success' => true, 'data' => $response, 'total' => count($response)]);
}
protected function saveAction(): void {
$data = $this->postData;
if (empty($data)) { self::sendError("No data received."); return; }
$isNew = empty($data['id']);
$model = $isNew ? new ADBNetzgebiet() : ADBNetzgebiet::get($data['id']);
if (!$model) { self::sendError("Netzgebiet not found."); return; }
if (isset($data['name'])) $model->name = trim($data['name']) ?: null;
if (array_key_exists('extref', $data)) $model->extref = trim($data['extref']) ?: null;
if (array_key_exists('rimo_id', $data)) $model->rimo_id = trim($data['rimo_id']) ?: null;
if (isset($data['source'])) $model->source = $data['source'] ?: null;
if (array_key_exists('source_id', $data)) $model->source_id = trim($data['source_id']) ?: null;
if (array_key_exists('borderpoly', $data)) $model->borderpoly = $data['borderpoly'] ?: null;
if (isset($data['freigabe'])) {
$model->freigabe = is_array($data['freigabe'])
? json_encode(array_values($data['freigabe']))
: $data['freigabe'];
}
if (isset($data['options'])) {
if (is_array($data['options'])) {
$options = $data['options'];
if (isset($options['mph_min_homes_tool_automatic_count'])) {
$options['mph_min_homes_tool_automatic_count'] = (int)$options['mph_min_homes_tool_automatic_count'];
}
$boolFields = ['create_address_parts', 'update_freigabe', 'update_address',
'hausnummer_dont_overwrite_netzgebiet', 'create_preorder', 'preorder_only_oaid',
'wo_ignore_status', 'delete_units', 'unit_create_oaid'];
foreach ($boolFields as $field) {
if (isset($options[$field])) $options[$field] = $options[$field] ? 1 : 0;
}
$model->options = json_encode($options);
} else {
$model->options = $data['options'];
}
}
if (!$model->save()) { self::sendError("Failed to save Netzgebiet."); return; }
self::returnJson([
'success' => true,
'message' => $isNew ? 'Netzgebiet created.' : 'Netzgebiet saved.',
'id' => $model->getId()
]);
}
protected function getHistoryAction(): void {
$id = $_GET['id'] ?? $this->postData['id'] ?? null;
if (empty($id)) { self::sendError("ID required."); return; }
$model = ADBNetzgebiet::get($id);
if (!$model) { self::sendError("Netzgebiet not found."); return; }
$history = $model->getJournalHistory();
$userIds = array_unique(array_filter(array_column($history, 'user_id')));
$users = [];
foreach ($userIds as $userId) {
$user = new User($userId);
if ($user->id) $users[$user->id] = $user->name ?? 'User #' . $user->id;
}
foreach ($history as $entry) {
$entry->user_name = $users[$entry->user_id] ?? 'System';
}
self::returnJson(['success' => true, 'data' => $history]);
}
// TODO: Implement RIMO API check
protected function checkRimoSourceIdAction(): void {
self::returnJson(['success' => false, 'message' => "RIMO API check not available."]);
}
}

View File

@@ -1,188 +1,4 @@
<?php
class ADBNetzgebietModel {
public $name;
public $extref;
public $source;
public $source_id;
public $rimo_id;
public $freigabe;
public $create = null;
public $edit = null;
public static function create(Array $data) {
$model = new ADBNetzgebiet();
foreach($data as $field => $value) {
if(property_exists(get_called_class(), $field)) {
$model ->$field = $value;
}
}
$me = mfValuecache::singleton()->get("me");
if(!$me) {
$me = new User();
$me->loadMe();
mfValuecache::singleton()->set("me", $me);
}
/*
if($model->create_by === null) {
$model->create_by = $me->id;
}
if($model->edit_by === null) {
$model->edit_by = $me->id;
}*/
return $model;
}
public static function getFirst($filter) {
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$where = self::getSqlFilter($filter);
$res = $db->select("Netzgebiet", "*", "$where ORDER BY name LIMIT 1");
if($db->num_rows($res)) {
$data = $db->fetch_object($res);
$item = new ADBNetzgebiet($data);
if($item->id) {
return $item;
} else {
return null;
}
}
return null;
}
public static function getAll($indexed_by_id = false) {
$items = [];
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$res = $db->select("Netzgebiet", "*", "1=1 ORDER BY name");
if($db->num_rows($res)) {
while($data = $db->fetch_object($res)) {
if($indexed_by_id) {
$items[$data->id] = new ADBNetzgebiet($data);
} else {
$items[] = new ADBNetzgebiet($data);
}
}
}
return $items;
}
public static function count($filter) {
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$where = self::getSqlFilter($filter);
$sql = "SELECT COUNT(*) as cnt FROM Netzgebiet
WHERE $where
";
$res = $db->query($sql);
if($db->num_rows($res)) {
$data = $db->fetch_object($res);
return $data->cnt;
}
return 0;
}
public static function search($filter, $limit = false) {
$items = [];
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$where = self::getSqlFilter($filter);
$sql = "SELECT Netzgebiet.* FROM Netzgebiet
WHERE $where
ORDER BY name";
mfLoghandler::singleton()->debug($sql);
if(is_array($limit) && count($limit)) {
if(is_numeric($limit['start']) && is_numeric($limit['count'])) {
$sql .= " LIMIT ".$limit['start'].", ".$limit['count'];
} elseif(is_numeric($limit['count'])) {
$sql .= " LIMIT ".$limit['count'];
}
}
$res = $db->query($sql);
if($db->num_rows($res)) {
while($data = $db->fetch_object($res)) {
$items[] = new ADBNetzgebiet($data);
}
}
return $items;
}
private static function getSqlFilter($filter) {
$where = "1=1 ";
if(array_key_exists("netzgebiet_id", $filter)) {
$netzgebiet_id = $filter['netzgebiet_id'];
if(is_numeric($netzgebiet_id)) {
$where .= " AND Netzgebiet.id=$netzgebiet_id";
} elseif(is_array($netzgebiet_id) && count($netzgebiet_id)) {
$where .= " AND Netzgebiet.id IN (". implode(",", $netzgebiet_id).")";
}
}
if(array_key_exists("name", $filter)) {
$name = FronkDB::singleton()->escape($filter['name']);
if($name) {
$where .= " AND Netzgebiet.`name` = '$name'";
}
}
if(array_key_exists("name%", $filter)) {
$name = FronkDB::singleton()->escape($filter['name%']);
if($name) {
$where .= " AND Netzgebiet.`name` LIKE '$name%'";
}
}
if(array_key_exists("extref", $filter)) {
$extref = FronkDB::singleton()->escape($filter['extref']);
if($extref) {
$where .= " AND Netzgebiet.`extref` = '$extref'";
}
}
if(array_key_exists("rimo_id", $filter)) {
$rimo_id = FronkDB::singleton()->escape($filter['rimo_id']);
if($rimo_id) {
$where .= " AND Netzgebiet.`rimo_id` LIKE '%$rimo_id%'";
}
}
if(array_key_exists("source_id", $filter)) {
$source_id = FronkDB::singleton()->escape($filter['source_id']);
if($source_id) {
$where .= " AND Netzgebiet.`source_id` LIKE '%$source_id%'";
}
}
if(array_key_exists("source", $filter)) {
$source = FronkDB::singleton()->escape($filter['source']);
if($source) {
$where .= " AND Netzgebiet.`source` = '$source'";
}
}
if(array_key_exists("borderpoly", $filter)) {
$borderpoly = $filter['borderpoly'];
if($borderpoly === true) {
$where .= " AND Netzgebiet.`borderpoly` IS NOT NULL";
} elseif($borderpoly === false || $borderpoly === null) {
$where .= " AND (Netzgebiet.`borderpoly` IS NULL OR Netzgebiet.`borderpoly` = '')";
}
}
//var_dump($filter, $where);exit;
return $where;
}
}
/** @deprecated Use ADBNetzgebiet directly */
class ADBNetzgebietModel extends ADBNetzgebiet {}

View File

@@ -50,6 +50,8 @@ class ADBWohneinheit extends mfBaseModel {
if(!array_key_exists("no_updates", $_params) || !$_params['no_updates']) {
// Statuschange from Rimo statuschange
AddressDB::handleRimoStatusUpdate($this->id);
// Check if tuer has changed and sync to RIMO
$this->syncTuerToRimo();
}
$this->refreshUnitCount();
@@ -59,6 +61,59 @@ class ADBWohneinheit extends mfBaseModel {
return true;
}
private function syncTuerToRimo() {
// Check if tuer field has changed
$changes = $this->getChangedFields();
if(!in_array("tuer", $changes)) {
return true;
}
// Check if we have a RIMO external ID
if(!$this->extref) {
$this->log->debug("[".$this->_ruid."] ".__METHOD__.": No extref set, skipping RIMO sync");
return true;
}
// Get the hausnummer to access network owner
$hausnummer = $this->getProperty("hausnummer");
if(!$hausnummer || !$hausnummer->id) {
$this->log->debug("[".$this->_ruid."] ".__METHOD__.": No hausnummer found, skipping RIMO sync");
return true;
}
// Get RIMO API credentials
$creds = $hausnummer->getNetownerRimoApiCredentials();
if(!$creds) {
$this->log->warn("[".$this->_ruid."] ".__METHOD__.": No API credentials found for network owner");
return true;
}
// Get the API key (using prod environment)
$apikey = $creds["prod"]["key"];
if(!$apikey) {
$this->log->warn("[".$this->_ruid."] ".__METHOD__.": No API key found in credentials");
return true;
}
// Call RIMO API to update subAddress (tuer)
$this->log->info("[".$this->_ruid."] ".__METHOD__.": Syncing tuer change to RIMO for home ".$this->extref.": '".$this->tuer."'");
$subaddress = "";
if($this->tuer) {
$subaddress = "Top ".$this->tuer;
}
$result = Rimoapi::changeHomeSubAddress($apikey, $this->extref, $subaddress);
if($result) {
$this->log->info("[".$this->_ruid."] ".__METHOD__.": Successfully synced tuer to RIMO");
} else {
$this->log->error("[".$this->_ruid."] ".__METHOD__.": Failed to sync tuer to RIMO");
}
return $result;
}
public function refreshUnitCount() {
// ADBWohneinheit_onSave_noAutoUnitCount can be defined if doing bulk
// operations where unit count is calculated seperately

View File

@@ -28,7 +28,10 @@ class ADBWohneinheitController extends mfBaseController {
foreach($my_networks as $network) {
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
$netzgebiet_ids[] = $network->adb_netzgebiet_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;
}
}
$this->layout()->set("my_adb_networks", $my_adb_networks);

View File

@@ -726,16 +726,24 @@ class AddressController extends mfBaseController {
}
$xinon_project = new XinonProject();
$tickets = $xinon_project->searchSupportTickets('', 0, ['pageSize' => 100,
'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [$address->customer_number]]]])]);
$filterParams = ['pageSize' => 100,
'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [(string)$address->customer_number]]]])];
$tickets = $xinon_project->searchSupportTickets('', 0, $filterParams) ?? [];
$shippingNotes = array_map(function ($shippingNote) {
$shippingNote->createByName = (new User($shippingNote->createBy))->getAbbrName();
return $shippingNote;
}, WarehouseShippingNoteModel::getAll(['billingAddressId' => $address->id]));
Helper::renderVue($this,"AddressTickets",
"Tickets und Lieferscheine von Kunden: " . $address->getCompanyOrName() . '(' . $address->customer_number . ')', ["TICKETS" => $tickets,"SHIPPING_NOTES" => $shippingNotes,"ADDRESS" => $address]);
$customerName = str_replace(["\r", "\n"], ' ', $address->getCompanyOrName());
Helper::renderVue($this,"AddressTickets", "Tickets und Lieferscheine", [
"TICKETS" => $tickets,
"SHIPPING_NOTES" => $shippingNotes,
"CUSTOMER_NAME" => $customerName,
"CUSTOMER_NUMBER" => $address->customer_number,
"HIDE_PAGE_TITLE" => true
]);
}
protected function sendServicePinAction() {

View File

@@ -130,9 +130,13 @@ class AddressDB {
}
$netowner = $hausnummer->getNetowner();
if(!$netowner || !$netowner->id) {
$log->debug(__METHOD__.": Unable to determine netowner for Hausnummer ".$hausnummer->id);
return true;
}
$status_matrix = array_reverse(TT_PREORDER_RIMO_STATUS_MATRIX);
$status_matrix = array_reverse(TT_PREORDER_RIMO_STATUS_MATRIX[$netowner->id]);
$log->debug(__METHOD__.": b_ex_state: ".$b_ex_state);
$log->debug(__METHOD__.": b_op_state: ".$b_op_state);
@@ -218,7 +222,6 @@ class AddressDB {
if($hausnummer->status_id != $old_status) {
$hausnummer->save();
}
}
//$wohneinheit = new ADBWohneinheit($wohneinheit->id);
@@ -234,9 +237,18 @@ class AddressDB {
}
if(array_key_exists("hf", $matrix)) {
$hausnummer_flag = $matrix["hf"];
if($hausnummer_flag) {
$log->debug(__METHOD__.": new Hausnummer (".$wohneinheit->id.") statusflag: ".$matrix["hf"]);
$hausnummer->setStatusflag($hausnummer_flag, 1);
}
}
break;
// commented, for 140/141
//break;
}
return true;
}

View File

@@ -1,7 +1,7 @@
<?php
class AddressDBController extends mfBaseController {
protected function init() {
$this->needlogin=true;
$me = new User();
@@ -66,7 +66,10 @@ class AddressDBController extends mfBaseController {
foreach($my_networks as $network) {
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
$netzgebiet_ids[] = $network->adb_netzgebiet_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;
}
}
//var_dump($my_networks, $my_adb_networks);
@@ -108,7 +111,13 @@ class AddressDBController extends mfBaseController {
}
$this->layout()->set("ortschaften", ADBOrtschaftModel::search($filter_filter));
}
if($this->request->rimoAddressUpdate) {
$this->updateAddressesInRimo(ADBHausnummerModel::search($addressdb_filter));
unset($filter["rimoAddressUpdate"]);
$qs = http_build_query($filter);
$this->redirect("AddressDB", "index", $filter);
}
}
@@ -209,6 +218,13 @@ class AddressDBController extends mfBaseController {
return $new_filter;
}
private function updateAddressesInRimo($addresses) {
foreach($addresses as $address) {
$address->updateAddressInRimo();
}
$this->layout()->setFlash(count($addresses)." Adressen in Rimo aktualisiert", "success");
}
protected function viewAction() {
$this->layout()->setTemplate("AddressDB/View");
@@ -247,7 +263,10 @@ class AddressDBController extends mfBaseController {
foreach($my_networks as $network) {
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
$netzgebiet_ids[] = $network->adb_netzgebiet_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;
}
}
$this->layout()->set("my_adb_networks", $my_adb_networks);
@@ -490,7 +509,10 @@ class AddressDBController extends mfBaseController {
foreach($my_networks as $network) {
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_id, $netzgebiet_ids)) {
$netzgebiet_ids[] = $network->adb_netzgebiet_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;
}
}
@@ -941,12 +963,15 @@ class AddressDBController extends mfBaseController {
if($updated) {
$hausnummer->save(["no_aftersave" => true]);
if($do_rimo_update) {
$hausnummer->updateAddressInRimo();
}
$u++;
}
if($do_rimo_update) {
// reload to make sure we have the latest data in caches
$hausnummer = new ADBHausnummer($hausnummer->id);
$hausnummer->updateAddressInRimo();
}
$i++;
}

View File

@@ -0,0 +1,63 @@
<?php
class AddressPriceTypeController extends mfBaseController {
protected function init() {
$this->needlogin = true;
$user = new User();
$user->loadMe();
$this->me = $user;
}
public function getAction() {
$addressId = $_GET['address_id'] ?? null;
if (!$addressId) {
self::returnJson(['success' => false, 'message' => 'Address ID required']);
}
$priceType = AddressPriceTypeModel::getFirst(['address_id' => $addressId]);
self::returnJson([
'success' => true,
'priceType_id' => $priceType ? $priceType->priceType_id : null
]);
}
public function saveAction() {
$addressId = $_POST['address_id'] ?? null;
$priceTypeId = $_POST['priceType_id'] ?? null;
if (!$addressId) {
self::returnJson(['success' => false, 'message' => 'Address ID required']);
}
$existing = AddressPriceTypeModel::getFirst(['address_id' => $addressId]);
if (empty($priceTypeId)) {
if ($existing) {
AddressPriceTypeModel::delete($existing->id);
}
self::returnJson(['success' => true, 'message' => 'Preistyp entfernt']);
}
if ($existing) {
$updateData = (array) $existing;
$updateData['priceType_id'] = $priceTypeId;
$result = AddressPriceTypeModel::update($updateData);
} else {
$result = AddressPriceTypeModel::create([
'address_id' => $addressId,
'priceType_id' => $priceTypeId,
'create' => time(),
'createBy' => $this->me->id
]);
}
if ($result) {
self::returnJson(['success' => true, 'message' => 'Preistyp gespeichert']);
} else {
self::returnJson(['success' => false, 'message' => 'Fehler beim Speichern']);
}
}
}

View File

@@ -0,0 +1,9 @@
<?php
class AddressPriceTypeModel extends TTCrudBaseModel {
public int $id;
public int $address_id;
public int $priceType_id;
public int $create;
public int $createBy;
}

View File

@@ -80,6 +80,64 @@ class AdminController extends mfBaseController {
}
protected function downloadBusinessCustomers() {
$bc_sql = "SELECT COUNT(Contract.id) as contract_count, Address.* FROM `Address`
LEFT JOIN Contract ON (Contract.owner_id = Address.id)
WHERE customer_number > 0 AND company <> ''
AND (Contract.cancel_date IS NULL OR Contract.cancel_date > UNIX_TIMESTAMP())
GROUP BY Address.id HAVING contract_count > 0
ORDER BY Address.company, Address.lastname
";
//$this->log->debug($bc_sql);
$bc_res = $this->db()->query($bc_sql);
$csv = "customer_number;fibu_account_number;uid;company;firstname;lastname;street;zip;city;country;email;phone;fax;mobile;birthdate;allow_contact;allow_spin\n";
while($address = $this->db()->fetch_object($bc_res)) {
$country = new Country($address->country_id);
$birthdate = false;
if($address->birthdate)
try {
$birthdate = new DateTime($address->birthdate);
} catch(Exception $e) {
$birthdate = false;
}
$csv .= "{$address->customer_number};";
$csv .= "{$address->fibu_account_number};";
$csv .= "\"{$address->uid}\";";
$csv .= "\"{$address->company}\";";
$csv .= "\"{$address->firstname}\";";
$csv .= "\"{$address->lastname}\";";
$csv .= "\"{$address->street}\";";
$csv .= "\"{$address->zip}\";";
$csv .= "\"{$address->city}\";";
if($country->id) {
$csv .= "\"{$country->name}\";";
} else {
$csv .= ";";
}
$csv .= "\"{$address->email}\";";
$csv .= "\"".(string)$address->phone."\";";
$csv .= "\"".(string)$address->fax."\";";
$csv .= "\"".(string)$address->mobile."\";";
if($birthdate) {
$csv .= "\"".$birthdate->format('d.m.Y')."\";";
} else {
$csv .= ";";
}
$csv .= "{$address->allow_contact};";
$csv .= "{$address->allow_spin};";
$csv .= "\n";
}
header("Content-type: text/csv; charset=utf-8");
header('Content-disposition: attachment; filename="tt-business_customers-'.date('Y-m-d_H-i-s').'.csv"');
echo $csv;
exit;
}
protected function rtrReporting() {
require_once(realpath(dirname(__FILE__)."/functions")."/RtrReporting.php");

View File

@@ -691,7 +691,11 @@ class AddressdbApicontroller extends mfBaseApicontroller {
}
$addresses = [];
$netzgebiete = ADBNetzgebietModel::getAll(true);
$netzgebiete = [];
$netzgebiete_unindexed = ADBNetzgebietModel::getAll();
foreach($netzgebiete_unindexed as $nu) {
$netzgebiete[$nu->id] = $nu;
}
$stati = ADBStatusModel::getAll(true);

View File

@@ -17,6 +17,65 @@ class SnoppCitycom extends Modules\ApiControllerModule
}
public function getOntStatus($req_id) {
if(!$req_id) {
return \mfResponse::BadRequest(["message" => "id missing"]);
}
$wohneinheit = false;
if(is_numeric($req_id)) {
$id = $req_id;
$wohneinheit = new \ADBWohneinheit($id);
}
if(!$wohneinheit || !$wohneinheit->id) {
$oaid = $req_id;
$wohneinheit = \ADBWohneinheitModel::getFirst(["oaid" => $oaid]);
if (!$wohneinheit) {
return \mfResponse::NotFound(["message" => "Home not found"]);
}
}
$cc = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS);
$data = $cc->getOntStatus($wohneinheit->oaid);
$status = [];
$status["online"] = ($data->{"oper-status"} == "present" && $data->status == "Confirmed") ? 1 : 0;
$status["uptime"] = ($data->up_time) ? (date('U') - $data->up_time) : null;
$status["downtime"] = null;
$status["rx"] = $data->{"opt-signal-level"};
$status["tx"] = $data->tx_opt_level_dbm;
$status["distance"] = null;
return \mfResponse::Ok(["status" => $status]);
}
public function getOntTelemetry($req_id) {
if(!$req_id) {
return \mfResponse::BadRequest(["message" => "id missing"]);
}
$wohneinheit = false;
if(is_numeric($req_id)) {
$id = $req_id;
$wohneinheit = new \ADBWohneinheit($id);
}
if(!$wohneinheit || !$wohneinheit->id) {
$oaid = $req_id;
$wohneinheit = \ADBWohneinheitModel::getFirst(["oaid" => $oaid]);
if (!$wohneinheit) {
return \mfResponse::NotFound(["message" => "Home not found"]);
}
}
$cc = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS);
$data = $cc->getOntStatusDetail($wohneinheit->oaid);
print_r($data);exit;
return \mfResponse::Ok(["status" => $status]);
}
public function orderService($req_id) {
if(!$req_id) {
return \mfResponse::BadRequest(["message" => "id missing"]);

View File

@@ -50,8 +50,9 @@ class OperationaldataApicontroller extends mfBaseApicontroller
$this->addRoute("/operationaldata/home/:id/connected", [$modules["Snopp"], "setPreorderConnected"], "POST");
$this->addRoute("/operationaldata/home/:id/active", [$modules["Snopp"], "setPreorderActive"], "POST");
//$this->addRoute("/operationaldata/preorder/:id/orderServiceTest", [$modules["SnoppCitycom"], "orderServiceTest"], "POST");
$this->addRoute("/operationaldata/preorder/:id/orderService", [$modules["SnoppCitycom"], "orderService"], "POST");
$this->addRoute("/operationaldata/preorder/:id/ontStatus", [$modules["SnoppCitycom"], "getOntStatus"], "GET");
$this->addRoute("/operationaldata/preorder/:id/ontTelemetry", [$modules["SnoppCitycom"], "getOntTelemetry"], "GET");
}

View File

@@ -136,7 +136,10 @@ class PreorderApicontroller extends mfBaseApicontroller {
foreach($my_networks as $network) {
if($network->adb_netzgebiet_id && !in_array($network->adb_netzgebiet_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;
}
}
$preorder_filter = [];

View File

@@ -11,7 +11,6 @@ class AssetManagementController extends TTCrud
['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']],
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
['key' => 'journal', 'text' => 'Historie', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
];
@@ -21,7 +20,7 @@ class AssetManagementController extends TTCrud
// Restrict actions if the user does not have the 'AssetAdmin' permission.
if (!$this->user->can('AssetAdmin')) {
$this->additionalJSVariables['ASSET_ADMIN'] = '0';
$this->columns = array_filter($this->columns, fn($col) => !in_array($col['key'], ['actions', 'journal']));
$this->columns = array_filter($this->columns, fn($col) => $col['key'] !== 'actions');
}
}
@@ -277,4 +276,40 @@ class AssetManagementController extends TTCrud
AssetManagementReservationModel::delete($post['id']);
self::returnJson(['success' => true, 'message' => 'Reservierung gelöscht.']);
}
protected function printLabelAction() {
if (!$this->user->can('AssetAdmin')) {
self::sendError("Permission denied", 403);
}
$assetId = $this->request->id;
$size = $this->request->size ?? '25'; // Default to 25mm
$asset = AssetManagementModel::get($assetId);
if (!$asset) {
self::sendError("Asset not found", 404);
}
$pdf_vars = [
'companyAddress' => 'Fladnitz 150, 8322 Studenzen',
'companyPhone' => '+43 3115 40800',
'invNumber' => $asset->assetNumber,
'size' => $size
];
$pdf = new PdfForm("AssetManagement/LABEL", $pdf_vars);
if ($size == '50') {
$wkhtmltopdfArgs = "--page-height 50mm --page-width 80mm --margin-top 1mm --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8";
} else { // 25mm
$wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 1mm --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8";
}
$filename = $pdf->render($wkhtmltopdfArgs);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="label-' . $asset->assetNumber . '.pdf"');
readfile($filename);
die();
}
}

View File

@@ -191,7 +191,7 @@ class BillingController extends mfBaseController {
$contract_search = [
"finish_date<" => mktime(2,0,0,$now_month, $now_day, $now_year),
"cancel_date_null_or_gte" => mktime(0,0,0,$now_month, 1, $now_year),
//"owner_id" => 1221
//"owner_id" => 7719
];
foreach(ContractModel::search($contract_search) as $contract) {
@@ -715,6 +715,7 @@ class BillingController extends mfBaseController {
}
if (!array_key_exists($zone->id, $voicebills[$number][$call_date_start])) {
$voicebills[$number][$call_date_start][$zone->id] = [
//"zone_id" => $zone->id,
"zone_name" => $zone->name,
"voiceplan" => $voiceplan->name,
"duration" => 0,
@@ -738,8 +739,8 @@ class BillingController extends mfBaseController {
// save to BillingVoicenumber
foreach($voicebills as $vbnumber => $zones) {
foreach($zones as $zone_id => $zone) {
foreach($zone as $zone_start_date => $vb) {
foreach($zones as $zone_start_date => $zone) {
foreach($zone as $zone_id => $vb) {
$vbdata = [];
$vbdata["billing_id"] = $billing->id;
$vbdata["contract_id"] = $contract->id;
@@ -747,6 +748,7 @@ class BillingController extends mfBaseController {
$vbdata["start_date"] = $vb["start_date"];
$vbdata["end_date"] = $vb["end_date"];
$vbdata["voiceplan"] = $vb["voiceplan"];
$vbdata["zone_id"] = $zone_id;
$vbdata["zone"] = $vb["zone_name"];
$vbdata["call_count"] = $vb["count"];
$vbdata["duration"] = $vb["duration"];
@@ -756,6 +758,7 @@ class BillingController extends mfBaseController {
$vbdata["increment_first"] = $vb["increment_first"];
$bill_voice = BillingVoicenumberModel::create($vbdata);
$this->log->debug(__METHOD__.": billingvoicenumber record created: ".print_r($vbdata, true));
if(!$bill_voice->save()) {
var_dump($vbdata);
die("Error saving Billing Voicenumber!");

View File

@@ -7,6 +7,7 @@ class BillingVoicenumberModel {
public $start_date;
public $end_date;
public $voiceplan;
public $zone_id;
public $zone;
public $call_count;
public $duration;

View File

@@ -303,7 +303,7 @@ class Building extends mfBaseModel {
}
if($name == "termination_workflow_comments") {
$comments = "";
$comment = "";
foreach($this->getProperty("terminations") as $term) {
if($term->workflow_comment) {
$comment .= $term->code.": ".trim($term->workflow_comment)."\n\n";

View File

@@ -177,6 +177,9 @@ class BuildingController extends mfBaseController {
protected function saveAction() {
$r = $this->request;
$id = $r->id;
$adb_hausnummer_id = null;
//var_dump($r);exit;
if(is_numeric($id) && $id > 0) {
$mode = "edit";
@@ -187,6 +190,9 @@ class BuildingController extends mfBaseController {
}
} else {
$mode = "add";
if($r->adb_hausnummer_id) {
$adb_hausnummer_id = $r->adb_hausnummer_id;
}
}
if(!$r->network_id || !$r->type_id) {
@@ -215,10 +221,17 @@ class BuildingController extends mfBaseController {
$data['contact'] = trim($r->contact);
$data['phone'] = trim($r->phone);
$data['email'] = trim($r->email);
$data['units'] = trim($r->units);
$data['description'] = trim($r->description);
$data['note'] = trim($r->note);
if($adb_hausnummer_id) {
$data["adb_hausnummer_id"] = $adb_hausnummer_id;
$data['units'] = 0;
} else {
$data['units'] = trim($r->units);
}
if($this->me->is(["Admin", "netowner"])) {
if($r->gps_lat) $data['gps_lat'] = trim($r->gps_lat);
if($r->gps_long) $data['gps_long'] = trim($r->gps_long);
@@ -235,7 +248,7 @@ class BuildingController extends mfBaseController {
// check if building exists already
$checkBuilding = BuildingModel::search(['=street' => $data['street'], '=city' => $data['city'], '=zip' => $data['zip']]);
if($checkBuilding) {
$this->layout()->setFlash("Objekt ist <a target='_blank' href='".self::getUrl("Building")."#building=".$checkBuilding[0]->id."'>bereits vorhanden</a>!", "error");
$this->layout()->set("building", $building);
@@ -283,30 +296,49 @@ class BuildingController extends mfBaseController {
}
// Anschlüsse anlegen
if(!$building->terminations && $building->units > 0) {
for($i = 1; $i <= $building->units; $i++) {
$data = [];
$data['building_id'] = $building->id;
$data['code'] = $building->code . "." . sprintf("%03d", $i);
if($building->units == 1) {
$data['contact'] = $building->contact;
$data['phone'] = $building->phone;
$data['email'] = $building->email;
}
/*
// no more lineworker_id in Termination
if($building->lineworker_id) {
$data['lineworker_id'] = $building->lineworker_id;
}*/
if($building->oaid) {
$data['oaid'] = $building->oaid. "." . sprintf("%03d", $i);
}
$term = TerminationModel::create($data);
$term->save();
}
if($mode == "add" && $adb_hausnummer_id) {
$i = 1;
foreach(ADBWohneinheitModel::search(["hausnummer_id" => $adb_hausnummer_id]) as $wohneinheit) {
$data = [];
$data['building_id'] = $building->id;
$oaid_parts = explode(".", $wohneinheit->oaid);
if($wohneinheit->oaid && array_key_exists(1, $oaid_parts)) {
$data['code'] = $building->oaid . "." . $oaid_parts[1];
} else {
$data['code'] = $building->code . "." . sprintf("%04d", $i);
}
$data['oaid'] = $data['code'];
$term = TerminationModel::create($data);
$term->save();
$i++;
}
} elseif(!$building->terminations && $building->units > 0) {
for($i = 1; $i <= $building->units; $i++) {
$data = [];
$data['building_id'] = $building->id;
$data['code'] = $building->code . "." . sprintf("%03d", $i);
if($building->units == 1) {
$data['contact'] = $building->contact;
$data['phone'] = $building->phone;
$data['email'] = $building->email;
}
/*
// no more lineworker_id in Termination
if($building->lineworker_id) {
$data['lineworker_id'] = $building->lineworker_id;
}*/
if($building->oaid) {
$data['oaid'] = $building->oaid. "." . sprintf("%03d", $i);
}
$term = TerminationModel::create($data);
$term->save();
}
}
@@ -343,7 +375,55 @@ class BuildingController extends mfBaseController {
$this->layout()->setFlash("Objekt gelöscht", "success");
$this->redirect("Building");
}
protected function createFromAdbAction() {
$adb_hausnummer_id = $this->request->adb_hausnummer_id;
if(!$adb_hausnummer_id) {
$this->layout()->setFlash("AddressDB Adresse nicht gefunden.", "error");
$this->redirect("AddressDB");
}
$hausnummer = new ADBHausnummer($adb_hausnummer_id);
if(!$hausnummer->id) {
$this->layout()->setFlash("AddressDB Adresse nicht gefunden.", "error");
$this->redirect("AddressDB");
}
$network = NetworkModel::getFirst(["adb_netzgebiet_id" => $hausnummer->netzgebiet_id]);
if(!$network) {
$this->layout()->setFlash("AddressDB-Netzgebiet ist keinem Netzbau-Netzgebiet zugeordnet!", "error");
$this->redirect("AddressDB");
}
$building_data = [];
$building_data["adb_hausnummer_id"] = $hausnummer->id;
$building_data["network_id"] = $network->id;
$building_data["type_id"] = ($hausnummer->tool_building_type <= 1) ? 1 : 3; // 1 = EFH | 2 = MPH
$building_data["status_id"] = 1;
if($hausnummer->oaid) {
if(!BuildingModel::getFirst(["code" => $hausnummer->oaid])) {
$building_data["code"] = $hausnummer->oaid;
}
$building_data["oaid"] = $hausnummer->oaid;
}
$building_data["street"] = $hausnummer->strasse->name . " ".$hausnummer->hausnummer;
$building_data["city"] = $hausnummer->strasse->gemeinde->name;
$building_data["zip"] = $hausnummer->plz->plz;
$building_data["gps_lat"] = $hausnummer->gps_lat;
$building_data["gps_long"] = $hausnummer->gps_long;
$building_data["units"] = "from_adb";
$building_data["note"] = "Created from ADB Address {$hausnummer->id}";
$building = BuildingModel::create($building_data);
$this->layout()->set("building", $building);
$this->layout()->set("adb_hausnummer_id", $hausnummer->id);
return $this->addAction();
}
protected function apiAction() {
if(!$this->me->is(["Admin","netowner","pipeplanner"])) {

View File

@@ -1,6 +1,7 @@
<?php
class BuildingModel {
public $adb_hausnummer_id = null;
public $network_id = null;
public $pop_id = null;
public $type_id = null;

View File

@@ -1043,10 +1043,12 @@ class ConstructionConsentController extends mfBaseController {
private function generateStats($baseFilter = array()): array {
function getFilteredCount($wantedFilter, $filterValue, $baseFilter) {
if (!empty($baseFilter[$wantedFilter]) && $baseFilter[$wantedFilter] != $filterValue) return 0;
if ($wantedFilter !== 'deferred' && !empty($baseFilter[$wantedFilter]) && $baseFilter[$wantedFilter] != $filterValue) return 0;
return ConstructionConsent::count(array_merge($baseFilter, [$wantedFilter => $filterValue]));
}
$baseFilter["deferred"] = "NULL";
return [
"all" => ConstructionConsent::count($baseFilter),
"street" => getFilteredCount("object_type", "street", $baseFilter),
@@ -1058,7 +1060,8 @@ class ConstructionConsentController extends mfBaseController {
"status_light_blue" => getFilteredCount("status_light", "blue", $baseFilter),
"status_light_red" => getFilteredCount("status_light", "red", $baseFilter),
"status_light_yellow" => getFilteredCount("status_light", "yellow", $baseFilter),
"status_light_green" => getFilteredCount("status_light", "green", $baseFilter)
"status_light_green" => getFilteredCount("status_light", "green", $baseFilter),
"status_deferred" => getFilteredCount("deferred", "!NULL", $baseFilter),
];
}

View File

@@ -238,6 +238,20 @@ class ConstructionConsentProject extends mfBaseModel {
return $where;
}
/**
* @return array{id: int, name: string}[]
*/
public static function getByAdbNetzgebietId(int $adbNetzgebietId): array {
$db = FronkDB::singleton();
$id = $db->escape($adbNetzgebietId);
$res = $db->query(
"SELECT ccp.id, ccp.name FROM `ConstructionConsentProject` ccp
JOIN `ConstructionConsentNetwork` ccn ON ccp.id = ccn.constructionconsentproject_id
WHERE ccn.adb_netzgebiet_id = '{$id}'"
);
return $db->fetch_all_assoc($res) ?? [];
}
public static function hasFaultyOwnerEntries(int $projectId): bool {
if (empty($projectId)) return false;

View File

@@ -451,8 +451,22 @@ class CpeprovisioningController extends mfBaseController {
$attrs = $prod->product->attributes ?? [];
if (empty($attrs) || !is_array($attrs)) continue;
if ($attrs['hw_only']->value ?? false) $orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name;
if ($attrs['addon']->value ?? false) $orderInfo['hw'][] = $prod->product->name;
if ($attrs['bras_type']->value ?? false) continue;
$added = false;
if ($attrs['hw_only']->value ?? false) {
$orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name;
$added = true;
}
if ($attrs['addon']->value ?? false) {
$orderInfo['hw'][] = $prod->product->name;
$added = true;
}
if (!$added && in_array($prod->product->productgroup_id, [6, 4, 8])) {
$orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name;
}
if ($attrs['voip_chan']->value ?? false) $orderInfo['voip'] = true;
if ($attrs['vot']->value ?? false) $orderInfo['vot'] = true;
}
@@ -529,6 +543,8 @@ class CpeprovisioningController extends mfBaseController {
// Pass API URLs and initial data to the frontend
"CPE_PROV_API_GET_URL" => $this->getUrl("Cpeprovisioning", "apiGet"),
"CPE_PROV_API_SAVE_URL" => $this->getUrl("Cpeprovisioning", "apiSave"),
"CPE_PROV_API_TEST_ACS_VLAN_URL" => $this->getUrl("Cpeprovisioning", "getAcsVlan"),
"CPE_PROV_API_CREATE_RADIUS_USER_URL" => $this->getUrl("Cpeprovisioning", "createRadiusUser"),
"CPE_PROV_PRINT_PDF_URL" => $this->getUrl("Cpeprovisioning", "printPDF"),
"ORDER_URL" => $this->getUrl("Order"),
"NETWORKS" => NetworkModel::getAll(),
@@ -565,6 +581,338 @@ class CpeprovisioningController extends mfBaseController {
);
}
protected function getAcsVlanAction() {
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
$isApiCall = defined('TT_CPE_PROV_ACS_API_KEY') && $apiKey && $apiKey === TT_CPE_PROV_ACS_API_KEY;
$isLoggedInUser = $this->me && $this->me->id;
if (!$isApiCall && !$isLoggedInUser) {
http_response_code(403);
self::returnJson(['success' => false, 'message' => 'Forbidden']);
return;
}
try {
$p = json_decode(file_get_contents('php://input'), true);
$mac = $p['mac'] ?? null;
if (empty($mac)) {
throw new Exception("MAC address is required.");
}
$cpe = CpeprovisioningModel::getFirst(['mac' => $mac]);
if (!$cpe || !$cpe->termination_id) {
throw new Exception("No active provisioning entry found for this MAC address.");
}
$term = new Termination($cpe->termination_id);
$product = $cpe->orderproduct;
if (!$term->id || !$product->id) {
throw new Exception("Could not load termination or product details.");
}
$attrs = $product->product->attributes;
// First, check if any VLAN is explicitly saved (checked in frontend)
// The saved values take priority over defaults
$assignedVlan = $cpe->vlan_public ?? $cpe->vlan_nat ?? $cpe->vlan_ipv6;
// If no VLAN is explicitly saved, fall back to defaults
if (!$assignedVlan) {
$vlanPublicDefault = $term->getPop()->vlan_public ?? $attrs['vlan_default_public']->value ?? null;
$vlanNatDefault = $term->getPop()->vlan_nat ?? $attrs['vlan_default_nat']->value ?? null;
$vlanIpv6Default = $term->getPop()->vlan_ipv6 ?? $attrs['vlan_default_ipv6']->value ?? null;
$assignedVlan = $vlanPublicDefault ?? $vlanNatDefault ?? $vlanIpv6Default;
}
if ($assignedVlan) {
self::returnJson(['success' => true, 'vlan_id' => $assignedVlan]);
} else {
throw new Exception("No default VLAN could be determined for this product/POP combination.");
}
} catch (Exception $e) {
http_response_code(400);
self::returnJson(['success' => false, 'message' => $e->getMessage()]);
}
}
protected function createRadiusUserAction() {
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
$isApiCall = defined('TT_CPE_PROV_ACS_API_KEY') && $apiKey && $apiKey === TT_CPE_PROV_ACS_API_KEY;
$isLoggedInUser = $this->me && $this->me->id;
if (!$isApiCall && !$isLoggedInUser) {
http_response_code(403);
self::returnJson(['success' => false, 'message' => 'Forbidden']);
return;
}
try {
$p = json_decode(file_get_contents('php://input'), true);
$mac = $p['mac'] ?? null;
if (empty($mac)) {
throw new Exception("MAC address is required.");
}
// Normalize MAC address format to uppercase with colons
$mac = strtoupper(str_replace(['-', '.'], ':', $mac));
// Look up CPE provisioning entry
$cpe = CpeprovisioningModel::getFirst(['mac' => $mac]);
if (!$cpe || !$cpe->termination_id) {
throw new Exception("No active provisioning entry found for this MAC address.");
}
$term = new Termination($cpe->termination_id);
$product = $cpe->orderproduct;
$order = new Order($cpe->order_id);
if (!$term->id || !$product->id || !$order->id) {
throw new Exception("Could not load termination, product, or order details.");
}
// Gather all data needed for RADIUS user
$customerNumber = $order->owner->customer_number ?? $order->partner_number ?? '';
$ontSn = $term->getWorkflowValue("ont_sn", "string") ?? '';
$wifiKey = $cpe->wifi_pass ?? '';
// Check if RADIUS credentials are configured
if (!defined('TT_RADIUS_URL') || !defined('TT_RADIUS_USERNAME') || !defined('TT_RADIUS_PASSWORD')) {
throw new Exception("RADIUS server credentials are not configured.");
}
$radiusUrl = TT_RADIUS_URL;
$radiusUsername = TT_RADIUS_USERNAME;
$radiusPassword = TT_RADIUS_PASSWORD;
// Step 1: Check if RADIUS user already exists
$existingUser = $this->checkRadiusUserExists($mac);
if ($existingUser) {
$this->log->info("RADIUS user {$mac} already exists, skipping creation and updating details only.");
} else {
// Step 2: Login to RADIUS server
$cookieFile = tempnam(sys_get_temp_dir(), 'radius_cookie_');
// Generate random password for RADIUS user
$radiusUserPassword = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 8);
$loginResult = $this->radiusHttpRequest(
$radiusUrl . '/login.php',
[
'username' => $radiusUsername,
'password' => $radiusPassword,
'submit' => 'Login'
],
$cookieFile
);
if (strpos($loginResult, 'Login') === false && strpos($loginResult, 'logout') === false) {
throw new Exception("Failed to login to RADIUS server.");
}
// Step 3: Create user with MAC address
$createResult = $this->radiusHttpRequest(
$radiusUrl . '/add_user.php',
[
'user' => $mac,
'pass' => $radiusUserPassword,
'submit' => ' Anlegen '
],
$cookieFile
);
// Check if user creation was successful
if (strpos($createResult, 'already exists') !== false) {
// Somehow it exists now (race condition?), continue to update
$this->log->info("RADIUS user {$mac} already exists, updating details.");
} elseif (strpos($createResult, 'error') !== false || strpos($createResult, 'Error') !== false) {
throw new Exception("Failed to create RADIUS user: User creation returned an error.");
} else {
$this->log->info("RADIUS user {$mac} created successfully.");
}
}
// Step 4: Login to RADIUS server for update (in case we skipped creation)
$cookieFile = tempnam(sys_get_temp_dir(), 'radius_cookie_');
$loginResult = $this->radiusHttpRequest(
$radiusUrl . '/login.php',
[
'username' => $radiusUsername,
'password' => $radiusPassword,
'submit' => 'Login'
],
$cookieFile
);
if (strpos($loginResult, 'Login') === false && strpos($loginResult, 'logout') === false) {
throw new Exception("Failed to login to RADIUS server for update.");
}
// Step 5: Update user details
$updateData = [
'userid' => '',
'user' => $mac,
'Cleartext-Password' => '',
'Custnum' => (strpos($customerNumber, '7000') === 0) ? $customerNumber : '',
'Custnume' => (strpos($customerNumber, '7000') === 0) ? '' : $customerNumber,
'Hotspot_Info' => '',
'ont_sn' => $ontSn,
'Wifikey' => $wifiKey,
'Mikrotik-Group' => '',
'Framed-Pool' => '',
'Pool-Name' => '',
'Framed-IP-Address' => '',
'Framed-IP-Netmask' => '',
'Framed-Route' => '',
'MS-Primary-DNS-Server' => '195.191.252.62',
'MS-Secondary-DNS-Server' => '193.105.204.194',
'DHCP-IP-Address-Lease-Time' => '',
'MaxLogins' => '',
'Valid-From' => '',
'Valid-To' => '',
'Hotspot_Duration' => '',
'Hotspot_Duration_Multiplicant' => '86400',
'Rate-Limit-Down' => '',
'Rate-Limit-Up' => '',
'ContractDown' => '',
'ContractUp' => '',
'Rate-Limit-Down-Burst' => '',
'Rate-Limit-Up-Burst' => '',
'Rate-Limit-Burst-Sec' => '',
'Rate-Limit-Down-Thresh' => '',
'Rate-Limit-Up-Thresh' => '',
'Session-Timeout' => '',
'timeout_max' => '',
'Mikrotik-Recv-Limit' => '',
'transfer_max' => '',
'cisco-avpair[vrf]' => '',
'cisco-avpair[interface]' => '',
'submit' => 'Update'
];
$updateResult = $this->radiusHttpRequest(
$radiusUrl . '/edit_user.php',
$updateData,
$cookieFile
);
// Clean up cookie file
@unlink($cookieFile);
// Check if update was successful
if (strpos($updateResult, 'error') !== false || strpos($updateResult, 'Error') !== false) {
throw new Exception("Failed to update RADIUS user details.");
}
$this->log->info("Successfully created/updated RADIUS user for MAC: {$mac}, Customer: {$customerNumber}");
self::returnJson([
'success' => true,
'message' => 'RADIUS user created/updated successfully.',
'data' => [
'mac' => $mac,
'customer_number' => $customerNumber,
'ont_sn' => $ontSn,
'wifi_key' => $wifiKey
]
]);
} catch (Exception $e) {
// Clean up cookie file on error
if (isset($cookieFile) && file_exists($cookieFile)) {
@unlink($cookieFile);
}
$this->log->error("Failed to create RADIUS user: " . $e->getMessage());
http_response_code(400);
self::returnJson(['success' => false, 'message' => $e->getMessage()]);
}
}
/**
* Check if a RADIUS user exists by username (MAC address)
*
* @param string $username The username/MAC address to check
* @return bool True if user exists, false otherwise
*/
private function checkRadiusUserExists($username) {
try {
if (!defined('TT_RADIUS_API_URL')) {
// Fallback to default if not configured
$apiUrl = 'http://radius.xinon.at/api.php';
} else {
$apiUrl = TT_RADIUS_API_URL;
}
// Query the RADIUS API to check if user exists
$queryParams = http_build_query(['username' => $username]);
$url = $apiUrl . '?' . $queryParams;
$opts = [
"http" => [
"method" => "GET",
"header" => "Authorization: Basic " . base64_encode(TT_RADIUS_USERNAME . ":" . TT_RADIUS_PASSWORD),
"timeout" => 10
]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
$this->log->warning("Could not check if RADIUS user exists: API request failed");
return false;
}
$data = json_decode($response, true);
// If we get results back, the user exists
if (is_array($data) && count($data) > 0) {
return true;
}
return false;
} catch (Exception $e) {
$this->log->error("Error checking RADIUS user existence: " . $e->getMessage());
return false;
}
}
private function radiusHttpRequest($url, $postData, $cookieFile) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookieFile);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookieFile);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
throw new Exception("HTTP request failed: " . $error);
}
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("HTTP request returned status code: " . $httpCode);
}
return $result;
}
private function fixCpeData($data) {
if (!$data) return [];
$data->shipping = (bool)$data->shipping;

View File

@@ -181,6 +181,13 @@ class CpeprovisioningModel {
}
}
if(array_key_exists("mac", $filter)) {
$mac = FronkDB::singleton()->escape($filter['mac']);
if($mac) {
$where .= " AND mac='$mac'";
}
}
//var_dump($filter, $where);exit;
return $where;
}

View File

@@ -196,7 +196,7 @@ class DashboardNewController extends mfBaseController {
$campaign_ids = array_map(fn($campaign) => $campaign->id, $owner_campaigns);
}
$order_max_homes = $this->getTotalHomes($campaign_ids, $gemeinde_ids);
$order_max_homes = PreorderModel::countTotalUnits($campaign_ids, $gemeinde_ids)['total_unit_count'];
$efh_connection_types = ["single-dwelling", "business"];
$mph_connection_types = ["apartment-building", "apartment", "multi-dwelling"];
@@ -370,7 +370,7 @@ class DashboardNewController extends mfBaseController {
$campaign_ids = [$campaign->id];
$gemeinde_ids = []; // Empty array as in original
$order_max_homes = $this->getTotalHomes($campaign_ids, $gemeinde_ids);
$order_max_homes = PreorderModel::countTotalUnits($campaign_ids, $gemeinde_ids)['total_unit_count'];
$efh_connection_types = [0, 1]; // Single-dwelling and business
$mph_connection_types = [2]; // Apartment-building, apartment, multi-dwelling
@@ -568,43 +568,6 @@ class DashboardNewController extends mfBaseController {
return $timeline;
}
private function getTotalHomes(array $preordercampaign_id = [], array $gemeinde_id = []) {
$baseSQL = "SELECT COUNT(adb_wohneinheit.id) as cnt FROM `" . ADDRESSDB_DBNAME . "`.Wohneinheit adb_wohneinheit
LEFT JOIN `" . ADDRESSDB_DBNAME . "`.Hausnummer adb_hausnummer ON (adb_wohneinheit.hausnummer_id = adb_hausnummer.id)
LEFT JOIN `" . ADDRESSDB_DBNAME . "`.Strasse adb_strasse ON (adb_hausnummer.strasse_id = adb_strasse.id)
WHERE 1=1";
$where = "";
if (!empty($preordercampaign_id)) {
$netzgebiet_ids = [];
foreach ($preordercampaign_id as $campaign_id) {
$campaign = new Preordercampaign($campaign_id);
if ($campaign->network_id) {
$network = new Network($campaign->network_id);
$netzgebiet_ids[] = $network->adb_netzgebiet_id;
}
}
$where .= " AND adb_hausnummer.netzgebiet_id IN (" . implode(',', array_map('intval', $netzgebiet_ids)) . ")";
}
if (!empty($gemeinde_id)) {
$where .= " AND adb_strasse.gemeinde_id IN (" . implode(',', array_map('intval', $gemeinde_id)) . ")";
}
$sql = $baseSQL . $where;
$res = $this->db()->query($sql);
if ($this->db()->num_rows($res)) {
$data = $this->db()->fetch_object($res);
return $data->cnt;
}
return 0;
}
protected function getDashboardAddressDBDataAction() {
if (!$this->me->is("Admin")) self::sendError("Keine Berechtigung");
$baseFilter = [];

View File

@@ -212,6 +212,119 @@ class InvoiceController extends mfBaseController {
exit;
}
protected function downloadInvoiceVoiceDetailsAction() {
$id = $this->request->id;
if (!is_numeric($id) || !$id) {
$this->layout()->setFlash("Rechnung nicht gefunden", "error");
$this->redirect("Invoice");
}
$invoice = new Invoice($id);
if (!$invoice->id) {
$this->layout()->setFlash("Rechnung nicht gefunden", "error");
$this->redirect("Invoice");
}
$csv = "Startzeit;Abgehende Nummer;Zielnummer;Zone;Dauer;Kosten\n";
$total = 0;
$destinations_cache = [];
//var_dump($invoice->voicenumbers);exit;
foreach($invoice->voicenumbers as $voicenumber) {
$start_date = new DateTime($voicenumber->start_date);
//$start_date->setTimezone(new DateTimeZone("Europe/Vienna"));
$start_date->setTime(0,0,0);
$end_date = new DateTime($voicenumber->end_date);
//$end_date->setTimezone(new DateTimeZone("Europe/Vienna"));
$end_date->setTime(23,59,59);
$call_date_start = $start_date->format("Y-m-d");
foreach(VoiceCallHistoryModel::getVoiceCallHistoryAsEntity([
"voice_account" => $voicenumber->voicenumber,
"start" => [
"from" => $start_date->getTimestamp(),
"to" => $end_date->getTimestamp()
],
"billable" => 1,
], null, 0, ["key" => "start", "order" => "ASC"]) as $call) {
if(!$call->contract_id) continue;
//$voiceplan = new Voiceplan($voicenumber->voiceplan_id);
$voiceplan = VoiceplanModel::getFirst(["name" => $voicenumber->voiceplan]);
if(!$voiceplan) {
$this->log->warning(__METHOD__.": Voiceplan not found");
exit;
}
$number = $voicenumber->voicenumber;
$dest_nummer = $call->destination;
if (substr($dest_nummer, 0, 2) == "00") {
$dest_nummer = substr($dest_nummer, 2);
}
if (substr($dest_nummer, 0, 1) == "+") {
$dest_nummer = substr($dest_nummer, 1);
}
if (array_key_exists($dest_nummer, $destinations_cache)) {
$destination = $destinations_cache[$dest_nummer];
} else {
$destination = $voiceplan->getDestinationByNumber($dest_nummer);
if (!$destination) {
die("Destination für Zielrufnummer " . $call->destination . " nicht gefunden");
}
$destinations_cache[$dest_nummer] = $destination;
}
//var_dump($destination);
$zone = $destination->voiceplanzone;
if (!$zone) {
die("Keine Zone für Destination " . $dest_nummer . " gefunden");
}
//var_dump($zone);exit;
// inc_first - first minimumm duration to bill
// inc - subsequent minimum duration to bill
$inc_first = $zone->increment_first;
$inc = $zone->increment;
$billable_duration = $call->duration;
if ($billable_duration <= 0) continue;
// calculate price of first duration unit
// then subtract first minimum duration from duration
$sec_price = $zone->price / 60;
$call_price = $inc_first * $sec_price;
$billable_duration -= $inc_first;
// calculate price of remaining duration and make sure to bill in full duration units
if ($billable_duration > 0) {
$multi = ceil($billable_duration / $inc);
$call_price += ($multi * $inc) * $sec_price;
}
$csv .= '"'.$call->start.'";';
$csv .= '"'.$call->source.'"; ';
$csv .= '"'.$call->destination.'"; ';
$csv .= '"'.$zone->name.'"; ';
$csv .= $call->duration.'; ';
$csv .= '"'.str_replace(".",",", $call_price).'"';
$csv .= "\n";
$total += $call_price;
}
}
header("Content-type: text/csv; charset=utf-8");
header('Content-disposition: attachment; filename="'.$invoice->invoice_number.'-egn.csv"');
echo $csv;
exit;
}
protected function createJob() {
$r = $this->request;
@@ -380,34 +493,38 @@ class InvoiceController extends mfBaseController {
$inc = reset($voicebills)->increment;
$inc_first = reset($voicebills)->increment_first;
$zoneId2ZoneName = [];
$voice_rows = [];
foreach ($voicebills as $voicebill) {
$number = $voicebill->voicenumber;
$zone_id = $voicebill->zone_id;
$zone = $voicebill->zone;
$call_count = $voicebill->call_count;
$duration = $voicebill->duration;
$price = $voicebill->price;
$price_total = $voicebill->price_total;
$zoneId2ZoneName[$zone_id] = $zone;
if (!array_key_exists($number, $voice_rows)) {
$voice_rows[$number] = [];
}
if (!array_key_exists($zone, $voice_rows[$number])) {
$voice_rows[$number][$zone] = [];
if (!array_key_exists($zone_id, $voice_rows[$number])) {
$voice_rows[$number][$zone_id] = [];
}
if (!array_key_exists($price, $voice_rows[$number][$zone])) {
$voice_rows[$number][$zone][$price] = [];
$voice_rows[$number][$zone][$price]["call_count"] = 0;
$voice_rows[$number][$zone][$price]["duration"] = 0;
$voice_rows[$number][$zone][$price]["price_total"] = 0;
if (!array_key_exists($price, $voice_rows[$number][$zone_id])) {
$voice_rows[$number][$zone_id][$price] = [];
$voice_rows[$number][$zone_id][$price]["call_count"] = 0;
$voice_rows[$number][$zone_id][$price]["duration"] = 0;
$voice_rows[$number][$zone_id][$price]["price_total"] = 0;
}
$voice_rows[$number][$zone][$price]["call_count"] += $call_count;
$voice_rows[$number][$zone][$price]["duration"] = $duration;
$voice_rows[$number][$zone][$price]["price_total"] = $price_total;
$voice_rows[$number][$zone_id][$price]["call_count"] += $call_count;
$voice_rows[$number][$zone_id][$price]["duration"] = $duration;
$voice_rows[$number][$zone_id][$price]["price_total"] = $price_total;
}
//var_dump($voice_rows);exit;
@@ -427,10 +544,10 @@ class InvoiceController extends mfBaseController {
$invoice_voicenumber->voicenumberzones = [];
foreach ($zones as $zone => $prices) {
foreach ($zones as $zone_id => $prices) {
foreach ($prices as $price => $row_values) {
$zone_data = [];
$zone_data["zone"] = $zone;
$zone_data["zone"] = $zoneId2ZoneName[$zone_id];
$zone_data["call_count"] = $row_values["call_count"];
$zone_data["duration"] = $row_values["duration"];
$zone_data["price"] = $price;

View File

@@ -121,8 +121,33 @@ class LineworkController extends mfBaseController {
}*/
//var_dump($termination_search);exit;
$networks = [];
$pagination['maxItems'] = TerminationModel::count($termination_search);
foreach(TerminationModel::search($termination_search, $pagination) as $term) {
// Store ap_name filter separately for post-processing
$ap_name_filter = null;
if(array_key_exists('ap_name', $termination_search) && $termination_search['ap_name']) {
$ap_name_filter = $termination_search['ap_name'];
unset($termination_search['ap_name']); // Remove from search as it's a workflow value
}
if($ap_name_filter) {
$all_terminations = TerminationModel::search($termination_search, false);
$filtered_terminations = [];
foreach($all_terminations as $term) {
$ap_name = $term->building->getWorkflowvalue('ist_anschlusspunkt_name') ?: $term->building->getWorkflowvalue('anschlusspunkt_name');
if($ap_name && stripos($ap_name, $ap_name_filter) !== false) {
$filtered_terminations[] = $term;
}
}
$pagination['maxItems'] = count($filtered_terminations);
$terminations = array_slice($filtered_terminations, $pagination['start'], $pagination['count']);
} else {
$pagination['maxItems'] = TerminationModel::count($termination_search);
$terminations = TerminationModel::search($termination_search, $pagination);
}
foreach($terminations as $term) {
if(!array_key_exists($term->building->network->name, $networks)) {
$networks[$term->building->network->name] = [];
}
@@ -188,10 +213,12 @@ class LineworkController extends mfBaseController {
$new_filter['id'] = $value;
continue;
}*/
// AP-Name filter is passed through to be handled by the model
// It will filter by workflow values: ist_anschlusspunkt_name or anschlusspunkt_name
$new_filter[$name] = $value;
}
return $new_filter;
}

View File

@@ -1,22 +1,735 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class ManualInvoiceController extends TTCrud
{
protected string $headerTitle = 'Manuelle Rechnungen';
protected bool $createText = false;
protected array $additionalJS = ["js/pages/ManualInvoice/ManualInvoice.js"];
protected array $additionalHead = ["<link rel='stylesheet' href='/js/pages/ManualInvoice/ManualInvoice.css'>"];
private array $tempPositions = [];
//@formatter:off
protected array $columns = [
['key' => 'invoiceNumber', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'customerName', 'text' => 'Kunde', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'invoiceDate', 'text' => 'Datum', 'table' => ['sortable' => true, 'filter' => 'date']],
['key' => 'totalAmount', 'text' => 'Betrag', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
['key' => 'id', 'text' => 'ID', 'table' => ['visible' => false], 'modal' => false],
['key' => 'invoice_number', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'invoice_date', 'text' => 'Datum', 'type' => 'timestamp', 'table' => ['sortable' => true, 'filter' => 'date', 'formatter' => 'formatDate']],
['key' => 'company', 'text' => 'Firma', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'firstname', 'text' => 'Vorname', 'table' => ['visible' => false], 'modal' => false],
['key' => 'lastname', 'text' => 'Nachname', 'table' => ['visible' => false], 'modal' => false],
['key' => 'customer_number', 'text' => 'Kundennr.', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'total', 'text' => 'Netto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
['key' => 'total_gross', 'text' => 'Brutto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
['value' => 'draft', 'text' => 'Entwurf'],
['value' => 'sent', 'text' => 'Gesendet'],
['value' => 'paid', 'text' => 'Bezahlt'],
['value' => 'erstellt', 'text' => 'Erstellt'],
['value' => 'gesendet', 'text' => 'Gesendet'],
['value' => 'exportiert', 'text' => 'Exportiert'],
]]],
['key' => 'billing_type', 'text' => 'Zahlungsart', 'table' => ['filter' => 'select', 'filterOptions' => [
['value' => 'invoice', 'text' => 'Rechnung'],
['value' => 'sepa', 'text' => 'SEPA'],
]]],
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
];
//@formatter:on
protected array $additionalActions = [
['key' => 'createGutschrift', 'title' => 'Gutschrift erstellen', 'class' => 'fas fa-file-invoice text-warning'],
['key' => 'pdfPreview', 'title' => 'PDF Vorschau', 'class' => 'fas fa-file-pdf text-danger'],
['key' => 'sendInvoice', 'title' => 'Rechnung aussenden', 'class' => 'fas fa-paper-plane text-success']
];
protected function createPDFAction($returnFilename = false) {
$post = json_decode(file_get_contents('php://input'), true);
$invoice = (object)[];
$positions = [];
if (isset($post['preview']) && $post['preview'] === true) {
$invoice = (object) array_merge([
'id' => 0, 'invoice_number' => null, 'invoice_date' => time(),
'customer_number' => 0, 'fibu_account_number' => 0,
'company' => '', 'firstname' => '', 'lastname' => '',
'street' => '', 'zip' => '', 'city' => '', 'country' => 'Österreich',
'email' => '', 'uid' => '', 'tax_text' => '', 'billing_type' => 'invoice',
'leistungszeitraum' => '', 'einleitender_text' => '', 'externe_referenz' => '', 'gesamtrabatt' => 0,
'total' => 0, 'total_gross' => 0
], $post);
// Convert invoice_date from string to timestamp if needed
if (isset($invoice->invoice_date) && is_string($invoice->invoice_date)) {
$invoice->invoice_date = strtotime($invoice->invoice_date);
}
$positions = array_map(function($p) {
$obj = (object)$p;
// Map _group to position_group for preview
if (isset($p['_group'])) {
$obj->position_group = $p['_group'];
}
return $obj;
}, $post['positions'] ?? []);
} else {
$id = $this->request->id ?? $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
http_response_code(500);
self::returnJson(['success' => false, 'message' => 'Rechnung wurde nicht gefunden']);
return;
}
$positions = $invoice->getProperty('positions');
}
$vat = [];
foreach ($positions as $p) {
$pObj = (object)$p;
$vat[$pObj->vatrate] = ($vat[$pObj->vatrate] ?? 0) + ($pObj->price_gross ?? 0) - ($pObj->price_total ?? 0);
}
$invoice->positions = $positions;
$pdf_vars = [
"invoice" => $invoice,
"vat" => $vat,
"bank_iban" => TT_INVOICE_BANK_IBAN,
"bank_bic" => TT_INVOICE_BANK_BIC,
"bank_bank" => TT_INVOICE_BANK_BANK,
"bank_owner" => TT_INVOICE_BANK_OWNER
];
$replacements = [
"{{ basedir }}" => BASEDIR,
"{{ addressLine_1 }}" => $invoice->company ?: "",
"{{ addressLine_2 }}" => trim($invoice->firstname . " " . $invoice->lastname),
"{{ addressLine_3 }}" => $invoice->street ?? '',
"{{ addressLine_4 }}" => ($invoice->zip ?? '') . " " . ($invoice->city ?? ''),
"{{ addressLine_5 }}" => ($invoice->country ?? '') != "Österreich" ? ($invoice->country ?? '') : "",
"{{ customerNumber }}" => $invoice->customer_number ?? '',
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
"{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->leistungszeitraum) . "</td></tr>" : "",
"{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->externe_referenz) . "</td></tr>" : "",
"{{ vatHtml }}" => ($invoice->uid ?? '') ? "<tr><td>Ihre UID:</td><td>" . $invoice->uid . "</td></tr>" : "",
"{{ 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 = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Rechnung</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
// Logos
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
$html .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
$html .= nl2br(htmlspecialchars($bodyText));
$html .= '</div>';
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
$html .= '</div></div></body></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;
}
}

View File

@@ -1,186 +1,80 @@
<?php
function getMockData() {
$mockData = [
[
'id' => 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;
}
}

View File

@@ -0,0 +1,12 @@
<?php
class ManualInvoiceJournalModel extends TTCrudBaseModel {
public int $id;
public int $manualinvoiceId;
public ?string $text;
public ?string $data;
public ?string $statusChange;
public ?string $fileIds;
public int $create;
public int $createBy;
}

View File

@@ -0,0 +1,28 @@
<?php
class ManualInvoicepositionModel extends TTCrudBaseModel {
public int $id;
public ?int $manualinvoice_id;
public ?string $position_group;
public ?int $billing_id;
public int $contract_id;
public ?string $matchcode;
public int $product_id;
public string $product_name;
public ?string $product_info;
public float $amount;
public string $unit;
public float $price;
public float $discount;
public float $price_total;
public float $price_gross;
public float $vatrate;
public ?int $fibu_cost_account;
public ?int $fibu_cost_account_legacy;
public ?int $fibu_taxcode;
public ?string $options;
public int $create_by;
public int $edit_by;
public int $create;
public int $edit;
}

View File

@@ -358,6 +358,13 @@ class OrderModel {
$where .= " AND `Order`.name='$name'";
}
}
if(array_key_exists("product_name", $filter)) {
$product_name = FronkDB::singleton()->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'];

View File

@@ -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] = [];
}

View File

@@ -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) {

View File

@@ -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."<br />";
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['<status_code'] = 800;
@@ -1035,6 +1045,7 @@ class PreorderController extends mfBaseController {
$this->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() {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
{

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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;

View File

@@ -7,7 +7,7 @@
*/
class User extends mfBaseModel {
public $permissions;
public $flags;
public $flags = [];
public $address;
protected $forcestr = ['mobile','twofactorcode'];

View File

@@ -142,6 +142,7 @@ class VoiceCallHistoryController extends mfBaseController {
"^43317244160",
"^4368181877218",
"^491744919930",
"^4924194559562",
];
$unknown_numbers = [];

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -1,5 +1,8 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class WarehouseOfferController extends TTCrud
{
protected string $headerTitle = 'Angebote';
@@ -53,6 +56,7 @@ class WarehouseOfferController extends TTCrud
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
$this->postData['status'] = 'new';
$this->postData['version'] = 1;
$this->postData['validity'] = 14;
$this->postData['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 = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Angebot</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
// Logos
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
$html .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
$html .= nl2br(htmlspecialchars($bodyText));
$html .= '</div>';
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
$html .= '</div></div></body></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,

View File

@@ -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();

View File

@@ -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
}
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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,10 @@
<?php
class WarehouseProjectJournalModel extends TTCrudBaseModel {
public int $id;
public int $projectId;
public ?string $text;
public ?string $data; // json
public int $createBy;
public int $create;
}

View File

@@ -0,0 +1,9 @@
<?php
class WarehouseProjectMemberModel extends TTCrudBaseModel {
public int $id;
public int $projectId;
public int $userId;
public ?string $role;
public int $create;
}

View File

@@ -0,0 +1,8 @@
<?php
class WarehouseProjectOrderRequestModel extends TTCrudBaseModel {
public int $id;
public int $projectId;
public int $orderRequestId;
public int $create;
}

View File

@@ -0,0 +1,14 @@
<?php
class WarehouseProjectTaskModel extends TTCrudBaseModel {
public int $id;
public int $projectId;
public ?int $parentTaskId;
public string $title;
public ?string $description;
public string $status; // todo, in_progress, done
public ?int $assignedUserId;
public int $order;
public int $createBy;
public int $create;
}

View File

@@ -7,7 +7,8 @@ class WarehouseShippingNoteController extends TTCrud {
//@formatter:off
protected array $columns = [
['key' => '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');

View File

@@ -0,0 +1,462 @@
<?php
class WarehouseStocktakeController extends TTCrud {
protected string $headerTitle = 'Inventur';
protected string $createText = 'Inventur erstellen';
protected bool $reopenOnCreate = false;
protected array $columns = [
['key' => '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'] = "<span class='badge bg-info'>{$row['totalScannedItems']} Artikel gescannt</span>";
// 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]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
class WarehouseStocktakeModel extends TTCrudBaseModel {
public int $id;
public ?string $stocktakeNumber = null;
public string $title;
public ?string $description = null;
public int $warehouseLocationId;
public string $status = 'planned';
public ?int $startedAt = null;
public ?int $completedAt = null;
public ?int $startedBy = null;
public ?int $completedBy = null;
public int $totalItems = 0;
public int $totalScannedItems = 0;
public ?string $notes = null;
public int $createBy;
public int $create;
/**
* Generate next stocktake number (ST-YYYY-NNNN)
*/
public static function generateStocktakeNumber(): string {
$year = date('Y');
$prefix = "IN{$year}-X";
$db = FronkDB::singleton();
$result = $db->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}");
}
}

View File

@@ -0,0 +1,195 @@
<?php
class WarehouseStocktakeItemController extends TTCrud {
protected string $headerTitle = 'Inventur-Artikel';
protected string $createText = 'Artikel hinzufügen';
protected array $columns = [
['key' => '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.',
]
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
class WarehouseStocktakeItemModel extends TTCrudBaseModel {
public int $id;
public int $stocktakeId;
public int $articleId;
public ?int $warehouseItemId;
public float $countedQuantity;
public ?string $rack;
public ?string $shelf;
public ?string $note;
public ?int $scannedAt;
public ?int $scannedBy;
public ?int $overwrittenById;
public int $createBy;
public int $create;
/**
* Get the article object
*/
public function getArticle(): ?WarehouseArticleModel {
return WarehouseArticleModel::get($this->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);
}
}

View File

@@ -0,0 +1,43 @@
<?php
class WarehouseStocktakeLogModel extends TTCrudBaseModel {
public int $id;
public int $stocktakeId;
public ?int $stocktakeItemId;
public string $action;
public ?string $details;
public int $userId;
public int $create;
/**
* Create a log entry
*/
public static function log(int $stocktakeId, string $action, ?int $stocktakeItemId = null, ?array $details = null, ?int $userId = null): self {
$me = mfValuecache::singleton()->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']);
}
}

View File

@@ -0,0 +1,494 @@
<?php
class WarehouseStocktakePWAController extends mfBaseController {
protected $user;
protected function init() {
$this->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,
]
]);
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,22 @@
<?php
class WorkorderMphModel extends TTCrudBaseModel
{
public int $id;
public int $hausnummerId;
public ?int $companyId;
public string $status;
public ?int $assignmentDate;
public ?int $deadlineDate;
public ?int $appointmentDate;
public ?string $additionalInfo;
public ?int $easement;
public ?int $btb;
public ?int $fttxLocationSupplied;
public ?int $conduitToHuepLaid;
public ?int $huepMounted;
public ?int $dropCableAvailable;
public ?int $spliceCompleted;
public int $create;
public int $createBy;
}

View File

@@ -0,0 +1,378 @@
<?php
class WorkorderMphAdminController extends WorkorderMphBaseController
{
protected string $headerTitle = 'MPH Arbeitsaufträge Verwaltung';
protected bool $createText = false;
protected array $permissionCheck = ['WorkorderMphAdmin'];
protected array $columns = [
['key' => '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.']);
}
}

View File

@@ -0,0 +1,566 @@
<?php
class WorkorderMphBaseController extends TTCrud
{
protected array $statusColumn = [
'key' => '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 = ["<link rel='stylesheet' href='/js/pages/WorkorderMphBase/WorkorderMphBase.css'>"];
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
}

View File

@@ -0,0 +1,301 @@
<?php
class WorkorderMphCompanyController extends WorkorderMphBaseController
{
protected string $headerTitle = 'Meine MPH Arbeitsaufträge';
protected bool $createText = false;
protected array $permissionCheck = ['RMLCompany'];
protected array $columns = [
['key' => '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]);
}
}

Some files were not shown because too many files have changed in this diff Show More