Merge remote-tracking branch 'origin/spidev' into spidev
This commit is contained in:
@@ -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">×</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
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
52
Layout/default/AssetManagement/LABEL.php
Normal file
52
Layout/default/AssetManagement/LABEL.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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">
|
||||
|
||||
43
Layout/default/ManualInvoice/PDF_FOOTER.html
Normal file
43
Layout/default/ManualInvoice/PDF_FOOTER.html
Normal 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>
|
||||
107
Layout/default/ManualInvoice/PDF_HEADER.html
Normal file
107
Layout/default/ManualInvoice/PDF_HEADER.html
Normal 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>
|
||||
257
Layout/default/ManualInvoice/PDF_MAIN.php
Normal file
257
Layout/default/ManualInvoice/PDF_MAIN.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1860,7 +1860,7 @@
|
||||
|
||||
|
||||
}
|
||||
reader.readAsText(selectedFile);
|
||||
reader.readAsArrayBuffer(selectedFile);
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
133
Layout/default/VueViews/Vue3.php
Normal file
133
Layout/default/VueViews/Vue3.php
Normal 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"); ?>
|
||||
935
Layout/default/VueViews/WarehouseStocktakePWA.php
Normal file
935
Layout/default/VueViews/WarehouseStocktakePWA.php
Normal 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>
|
||||
40
Layout/default/WarehouseArticle/LABEL.php
Normal file
40
Layout/default/WarehouseArticle/LABEL.php
Normal 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>
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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)?>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
83
Layout/default/vueHeader3.php
Normal file
83
Layout/default/vueHeader3.php
Normal 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">
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
137
application/ADBNetzgebiet/ADBNetzgebietController.php
Normal file
137
application/ADBNetzgebiet/ADBNetzgebietController.php
Normal 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."]);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
63
application/AddressPriceType/AddressPriceTypeController.php
Normal file
63
application/AddressPriceType/AddressPriceTypeController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
application/AddressPriceType/AddressPriceTypeModel.php
Normal file
9
application/AddressPriceType/AddressPriceTypeModel.php
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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!");
|
||||
|
||||
@@ -7,6 +7,7 @@ class BillingVoicenumberModel {
|
||||
public $start_date;
|
||||
public $end_date;
|
||||
public $voiceplan;
|
||||
public $zone_id;
|
||||
public $zone;
|
||||
public $call_count;
|
||||
public $duration;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"])) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
class BuildingModel {
|
||||
public $adb_hausnummer_id = null;
|
||||
public $network_id = null;
|
||||
public $pop_id = null;
|
||||
public $type_id = null;
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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] = [];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
class User extends mfBaseModel {
|
||||
public $permissions;
|
||||
public $flags;
|
||||
public $flags = [];
|
||||
public $address;
|
||||
protected $forcestr = ['mobile','twofactorcode'];
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ class VoiceCallHistoryController extends mfBaseController {
|
||||
"^43317244160",
|
||||
"^4368181877218",
|
||||
"^491744919930",
|
||||
"^4924194559562",
|
||||
];
|
||||
|
||||
$unknown_numbers = [];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
class WarehouseProjectMemberModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $projectId;
|
||||
public int $userId;
|
||||
public ?string $role;
|
||||
public int $create;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
class WarehouseProjectOrderRequestModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $projectId;
|
||||
public int $orderRequestId;
|
||||
public int $create;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
462
application/WarehouseStocktake/WarehouseStocktakeController.php
Normal file
462
application/WarehouseStocktake/WarehouseStocktakeController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
74
application/WarehouseStocktake/WarehouseStocktakeModel.php
Normal file
74
application/WarehouseStocktake/WarehouseStocktakeModel.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
22
application/WorkorderMph/WorkorderMphModel.php
Normal file
22
application/WorkorderMph/WorkorderMphModel.php
Normal 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;
|
||||
}
|
||||
378
application/WorkorderMphAdmin/WorkorderMphAdminController.php
Normal file
378
application/WorkorderMphAdmin/WorkorderMphAdminController.php
Normal 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.']);
|
||||
}
|
||||
}
|
||||
566
application/WorkorderMphBase/WorkorderMphBaseController.php
Normal file
566
application/WorkorderMphBase/WorkorderMphBaseController.php
Normal 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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user