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

This commit is contained in:
Daniel Spitzer
2026-01-29 21:20:02 +01:00
191 changed files with 26809 additions and 597 deletions

2
.gitignore vendored
View File

@@ -51,3 +51,5 @@ Thumbs.db
/Layout/default/DeviceDetail/
/Layout/default/DeviceDetail/
mobile-presentation/
nul

View File

@@ -55,6 +55,7 @@
</style>
<?php endif; ?>
<?php $openreplayUserType = 'internal'; include(__DIR__ . "/../default/includes/openreplay.php"); ?>
</head>
<body>

View File

@@ -233,8 +233,18 @@
</div>
</div>
<?php if($me->can("Fibu")): ?>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="bank_account_bic"></label>
<label class="col-lg-2 col-form-label" for="manual_invoice_sepa_limit">Manuelle Rechnungen abbuchen bis (&euro;)</label>
<div class="col-lg-10">
<input type="text" class="form-control" name="manual_invoice_sepa_limit" id="manual_invoice_sepa_limit" value="<?=round($address->manual_invoice_sepa_limit, 2)?>" />
<small>Wenn Bankeinzug aktiviert ist</small>
</div>
</div>
<?php endif; ?>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for=""></label>
<div class="col-lg-10 alert alert-danger hidden" id="bank-error"></div>
</div>

View File

@@ -144,8 +144,12 @@
</tr><tr>
<th>BIC</th>
<td><?=$address->bank_account_bic?></td>
</tr><tr>
<th>Manuelle Rechnungen abbuchen bis (erfordert Bankeinzug)</th>
<td><?=number_format($address->manual_invoice_sepa_limit, 2, ",", ".")?> &euro;</td>
</tr>
<?php if($me->can("Fibu")): ?>
<tr>
<th>Sepa Mandatsdatum</th>
<td><?=($address->sepa_date) ? date("d.m.Y", $address->sepa_date) : ""?></td>

View File

@@ -318,6 +318,9 @@ $pagination_entity_name = "Zu provisionierende CPEs";
<option value="FritzBox 6490 Cable" <?= ($product->cpeprovisioning->routertype == "FritzBox 6490 Cable") ? "selected='selected'" : "" ?>>
FritzBox 6490 Cable (Inet, Phone, IPTV)
</option>
<option value="FritzBox 6670 Cable" <?= ($product->cpeprovisioning->routertype == "FritzBox 6670 Cable") ? "selected='selected'" : "" ?>>
FritzBox 6670 Cable (Inet, Phone, IPTV)
</option>
<?php endif; ?>
</select>
</div>

View File

@@ -25,22 +25,52 @@ $pagination_entity_name = "Rechnungen";
<div class="row">
<div class="col-lg-12">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col">
<a href="https://thetool.xinon.at/xfarm/" class="btn btn-primary" target="_blank"><i class="far fa-arrows-to-circle fa-fw"></i> Fakt-Rechnungen Import</a>
<div class="col-3">
<h4 class="header-title mb-3">Manuelle Rechnungen</h4>
<div class="row">
<div class="col">
<a href="https://thetool.xinon.at/xfarm/" class="btn btn-primary mt-4" target="_blank"><i class="far fa-arrows-to-circle fa-fw"></i> Fakt-Rechnungen Import</a>
</div>
</div>
</div>
<div class="col-9">
<form method="post" action="<?=self::getUrl("Invoice", "manualExportBmd")?>">
<div class="row">
<div class="col-2">
<label class="form-label" for="manual_invoice_date_from">Rechnungsdatum von</label>
<input type="text" class="form-control" name="manual_invoice_date_from" id="manual_invoice_date_from" value="" />
</div>
<div class="col-2">
<label class="form-label" for="manual_invoice_date_to">Rechnungsdatum bis</label>
<input type="text" class="form-control" name="manual_invoice_date_to" id="manual_invoice_date_to" value="" />
</div>
</div>
<div class="row mt-2">
<div class="col-12">
<button type="submit" class="btn btn-outline-primary ml-1"><i class="far fa-fw fa-file-export"></i> Rechnungsexport für BMD</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-8">
<div class="card">
<div class="card-body mb-3">
<h4 class="header-title mb-3">Filter</h4>
<h4 class="header-title mb-3">Contract Rechnungen</h4>
<form method="get" action="<?=self::getUrl("Invoice")?>">
<div class="row">
@@ -399,6 +429,22 @@ $pagination_entity_name = "Rechnungen";
todayBtn: 'linked',
autoclose: true
});
$('#manual_invoice_date_from').datepicker({
orientation: "bottom",
language: 'de',
format: "dd.mm.yyyy",
showWeekDays: true,
todayBtn: 'linked',
autoclose: true
});
$('#manual_invoice_date_to').datepicker({
orientation: "bottom",
language: 'de',
format: "dd.mm.yyyy",
showWeekDays: true,
todayBtn: 'linked',
autoclose: true
});
$('.datepicker').datepicker({
orientation: "bottom",
language: 'de',

View File

@@ -68,9 +68,7 @@
<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>
{{ qrCodeHtml }}
<td>
<table class="invoice-details">
<tr>

View File

@@ -17,7 +17,7 @@ foreach($invoice->positions as $p) {
}
}
$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
$total_discount = $invoice->total_discount ?? 0;
$subtotal = 0;
foreach($invoice->positions as $p) {
$subtotal += $p->price_total ?? 0;
@@ -127,8 +127,8 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
<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 if($invoice->introductory_text ?? ''): ?>
<p style="margin-top: 10pt; margin-bottom: 20pt; text-align: center; font-weight: bold;"><?=nl2br(htmlspecialchars($invoice->introductory_text))?></p>
<?php endif; ?>
<table style="border-collapse: collapse; width: 100%;" id="invoiceTable">
@@ -166,7 +166,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
<tr class="position <?=($i%2 == 0) ? "even" : "uneven" ?>">
<td style="padding-left: 4pt; vertical-align: top;">
<?=htmlspecialchars($p->product_name ?? '')?>
<?=htmlspecialchars($p->warehousearticle_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; ?>
@@ -186,17 +186,17 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
endforeach;
endforeach;
?>
<?php if($gesamtrabatt > 0): ?>
<?php if($total_discount > 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>
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamtrabatt <?=number_format($total_discount, 2, ",", ".")?>%:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt; color: #d32f2f;">-<?=number_format($subtotal * ($total_discount / 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;'?>">
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;<?=($total_discount > 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>

View File

@@ -0,0 +1,9 @@
<?php
$appConfig = [
'title' => 'Xinon Mobile',
'appName' => 'Xinon',
'manifestPath' => '/mobile/manifest.json',
'appJsPath' => '/mobile/app.js',
'swPath' => '/mobile/sw.js',
];
require __DIR__ . '/Base.php';

View File

@@ -0,0 +1,71 @@
<?php
$config = array_merge([
'title' => 'Xinon Mobile',
'appName' => 'Xinon',
'manifestPath' => '/mobile/manifest.json',
'appJsPath' => '/mobile/app.js',
'swPath' => '/mobile/sw.js',
'additionalStylesheets' => [],
], $appConfig ?? []);
?>
<!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><?= htmlspecialchars($config['title']) ?></title>
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<link rel="apple-touch-icon" href="/assets/images/xinon-sm-192.png">
<link rel="apple-touch-icon" sizes="192x192" href="/assets/images/xinon-sm-192.png">
<link rel="apple-touch-icon" sizes="512x512" href="/assets/images/xinon-sm-512.png">
<link rel="manifest" href="<?= htmlspecialchars($config['manifestPath']) ?>">
<meta name="theme-color" content="#005384">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="<?= htmlspecialchars($config['appName']) ?>">
<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://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<link rel="stylesheet" href="/mobile/shared/base.css">
<?php foreach ($config['additionalStylesheets'] as $sheet): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($sheet) ?>">
<?php endforeach; ?>
<script>
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'primary': '#005384',
'secondary': '#fac41b',
},
}
}
};
</script>
</head>
<body class="transition-colors duration-300 overflow-hidden">
<div id="app" class="h-screen w-screen overflow-hidden antialiased">
<div class="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-900">
<div class="text-center">
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
<img src="/assets/images/xinon-full-transparent-white.png" class="h-12 mx-auto mb-4 hidden dark:block">
<div class="animate-pulse text-slate-500 dark:text-slate-400">Lädt...</div>
</div>
</div>
</div>
<script type="module" src="<?= htmlspecialchars($config['appJsPath']) ?>"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('<?= htmlspecialchars($config['swPath']) ?>')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.log('SW registration failed:', err));
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
<?php
$appConfig = [
'title' => 'Lager Inventur',
'appName' => 'Inventur',
'manifestPath' => '/mobile/warehouse-stocktake/manifest.json',
'appJsPath' => '/mobile/warehouse-stocktake/app.js',
'swPath' => '/mobile/warehouse-stocktake/sw.js',
'additionalStylesheets' => ['/mobile/warehouse-stocktake/app.css'],
];
require __DIR__ . '/Base.php';

View File

@@ -437,7 +437,7 @@
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="order_date">Bestelldatum</label>
<div class="col-lg-4">
<input type="text" class="form-control" name="order_date" id="order_date" value="<?=($order->order_date) ? date("d.m.Y", $order->order_date) : ""?>" />
<input type="text" class="form-control" name="order_date" id="order_date" value="<?=(is_numeric($order->order_date)) ? date("d.m.Y", $order->order_date) : $order->order_date ?>" />
</div>
</div>
<div class="form-group row">
@@ -553,7 +553,6 @@
</div>
</div>
<div class="card">
<div class="card-body" id="products-form">
<h4>Produkte</h4>
@@ -585,6 +584,9 @@
<div class="row product-container" id="position-<?=$i?>">
<input type="hidden" name="products[<?=$i?>][delete]" id="products-<?=$i?>-delete" value="0" />
<input type="hidden" name="products[<?=$i?>][orderproduct_id]" value="<?=$product->id?>" />
<?php if($product->preorder_id): ?>
<input type="hidden" name="products[<?=$i?>][preorder_id]" value="<?=$product->preorder_id?>" />
<?php endif; ?>
<div class="col-md-1 product-<?=$i?>">
<div class="row">
<div class="col-md-12">
@@ -596,9 +598,11 @@
</div>
</div>
<div class="row">
<div class="col-md-12 delete-button-container">
<i class="btn btn-outline-danger fas fa-trash-can pointer" style="font-size: 1.5em" onclick="toggleDeletePos(<?=$i?>)"></i>
</div>
<?php if(!$product->preorder_id): ?>
<div class="col-md-12 delete-button-container">
<a href="#" class="btn btn-xl btn-outline-danger" onclick="toggleDeletePos(<?=$i?>); return false;"><i class="fas fa-fw fa-trash-can fa-xl pointer"></i></a>
</div>
<?php endif; ?>
</div>
</div>
@@ -642,6 +646,16 @@
</div>
<?php if($product->product->getAttributeValue("oaid_enabled")): ?>
<div class="row mt-1 mb-2" id="oaid-<?=$i?>-line">
<?php else: ?>
<div class="row mt-1 mb-2 hidden" id="oaid-<?=$i?>-line">
<?php endif; ?>
<div class="col-4">
<label class="form-label" for="oaid-<?=$i?>">OAID</label>
<input type="text" name="products[<?=$i?>][oaid]" id="oaid-<?=$i?>" class="form-control" value="<?=$product->oaid?>" placeholder="optional">
</div>
</div>
<?php if(
(is_array($product->product->attributes) && count($product->product->attributes))
&& (array_key_exists(TT_ATTRIB_TERMINATION_REQUIRED_NAME, $product->product->attributes)
@@ -828,7 +842,7 @@
</div>
<div class="row">
<div class="col-md-12 delete-button-container">
<i class="btn btn-outline-info fas fa-trash-can pointer" title="Inhalte löschen" style="font-size: 1.5em" onclick="clearNewPos(<?=$i?>)"></i>
<a href="#" class="btn btn-xl btn-outline-info" title="Inhalte löschen" onclick="clearNewPos(<?=$i?>); return false;"><i class="fas fa-fw fa-trash-can fa-xl pointer"></i></a>
</div>
</div>
</div>
@@ -870,6 +884,13 @@
</div>
</div>
<div class="row mt-1 mb-2 hidden" id="oaid-<?=$i?>-line">
<div class="col-4">
<label class="form-label" for="oaid-<?=$i?>">OAID</label>
<input type="text" name="products[<?=$i?>][oaid]" id="oaid-<?=$i?>" class="form-control" value="<?=$product->oaid?>" placeholder="optional">
</div>
</div>
<div class="row mt-1 mb-2 hidden" id="termination_id-<?=$i?>-line">
<!-- line to choose termination -->
<div class="col-12">
@@ -1383,9 +1404,16 @@
});
} else {
$('#termination_id-' + id + '-line').hide();
//$('#termination_id-' + id + '-line').hide();
}
if(typeof p.attributes === 'object' && "oaid_enabled" in p.attributes && p.attributes.oaid_enabled == 1) {
console.log("oaid_enabled");
$('#oaid-' + id + '-line').show();
console.log($('#oaid-' + id).val());
} else {
$('#oaid-' + id + '-line').hide();
}
if(typeof p.attributes === 'object' && "needs_number" in p.attributes && p.attributes.needs_number == 1) {
console.log("needs_number");
$('#voicenumber-' + id + '-line').show();
@@ -1893,7 +1921,7 @@
} else {
$('#products-' + id + '-delete').val(0);
//$('#position-' + id + ' .delete-button-container i').removeClass("fa-trash-can-slash").addClass("fa-trash-can");
$('#position-' + id + ' .delete-button-container').html('<i class="btn btn-outline-danger fas fa-trash-can pointer" style="font-size: 1.5em" onclick="toggleDeletePos(' + id + ')"></i>');
$('#position-' + id + ' .delete-button-container').html('<a href="#" class="btn btn-xl btn-outline-info" title="Inhalte löschen" onclick="toggleDeletePos(' + id + '); return false;"><i class="fas fa-fw fa-trash-can fa-xl pointer"></i></a>');
//$('#position-' + id + ' .delete-button-container i').removeClass("btn-outline-white fa-trash-can-slash").addClass("text-danger fa-trash-can");
$('#position-' + id).removeClass('text-white deleted');
@@ -1932,7 +1960,7 @@
</div> \
<div class="row"> \
<div class="col-md-12 delete-button-container"> \
<i class="btn btn-outline-info fas fa-trash-can pointer" title="Inhalte löschen" style="font-size: 1.5em" onclick="clearNewPos(' + i +')"></i> \
<a href="#" class="btn btn-xl btn-outline-info" title="Inhalte löschen" onclick="clearNewPos(' + i +'); return false;"><i class="fas fa-fw fa-trash-can fa-xl pointer"></i></a> \
</div> \
</div> \
</div> \
@@ -1973,7 +2001,14 @@
<input type="text" class="form-control" name="products[' + i +'][price_setup]" id="price_setup-' + i +'" value="" placeholder="Preis Setup" /> \
</div> \
</div> \
\
\
<div class="row mt-1 mb-2 hidden" id="oaid-<?=$i?>-line"> \
<div class="col-4"> \
<label class="form-label" for="oaid-<?=$i?>">OAID</label> \
<input type="text" name="products[<?=$i?>][oaid]" id="oaid-<?=$i?>" class="form-control" value="" placeholder="optional"> \
</div> \
</div> \
\
<div class="row mt-1 mb-2 hidden" id="termination_id-' + i +'-line"> \
<!-- line to choose termination --> \
<div class="col-12"> \

View File

@@ -299,6 +299,10 @@
<a href="<?=self::getUrl("Order", "sendServicePin", ["id" => $order->id])?>" onclick="if(!confirm('Soll der Service-PIN an den Vertragsinhaber gesendet werden?')) return false;"><i class="fas fa-paper-plane" title="Service PIN als PDF per Email an Vertragsinhaber"></i></a>
<a href="<?=self::getUrl("Order", "edit", ["id" => $order->id, "filter" => $filter, "noTermProducts" => 1])?>"><i class="far fa-edit" title="Bearbeiten"></i></a>
<a href="<?=self::getUrl("Order", "delete", ["id" => $order->id])?>" onclick="if(!confirm('Bestellung wirklich löschen?')) return false;" class="text-danger" title="Löschen"><i class="fas fa-trash"></i></a>
<?php if(!$order->getSnoppProduct() && ($order->getPreorderProduct() || $order->getOaidProduct())): ?>
<a href="<?=self::getUrl("Order", "createSnoppOrder", ["id" => $order->id, "filter" => $filter, "noTermProducts" => 1])?>" class="ml-2"><img src="<?=self::getResourcePath()?>img/snop-logo.png" style="width:24px;height:auto;" /></a>
<?php endif; ?>
</td>
</tr>
<tr class="building-list-tr <?=($order_count % 2 == 0) ? "table-bg-even" : ""?>" id="order-dates-<?=$order->id?>">
@@ -543,7 +547,10 @@
<td class="text-right"><?=$product->pos?></td>
<td class="text-right"><?=$product->formatAmount()?></td>
<td>
<?=$product->product->name?>
<?php if($product->snopp_order_id): ?>
<img src="<?=self::getResourcePath()?>/img/snop-logo.png" style="width:24px;height:auto;" title="Bestellung in Snopp">
<?php endif; ?>
<?=$product->product->name?> <?=$product->oaid ? "<span class='text-pink font-italic'>{$product->oaid}</span>" : ""?>
<?php
if(
(is_array($product->product->attributes) && count($product->product->attributes))

View File

@@ -888,9 +888,10 @@ $pagination_entity_name = "Vorbestellungen";
Filter-Vorlagen <i class="fas fa-caret-down"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25], "rimo_workorder" => 1, "borderpoint" => "all"]])?>">Gelöschte Bestellungen mit Workorder</a></li>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25,930,931,932,933,934], "rimo_workorder" => 1, "rimo_workorder_status" => ["Clarify","Accepted","Plan released","Assigned","Executed","Documented","Review"]]])?>">Gelöschte Bestellungen mit Workorder</a></li>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["preorder_status_flags" => [4], "connection_type" => ["apartment", "apartment-building"], "borderpoint" => "all"]])?>">Wohnung - Verkabelung erledigt</a></li>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25]]])?>">Storniert</a></li>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [1,2,3,4,5,6,7,8,9,10,11,12,13,15,16,17,18,28,34]]])?>">Offene Bestellungen Status 10-299</a></li>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25,29,30,31,32,33]]])?>">Storniert</a></li>
<?php if ($me->isAdmin() || $me->address->id == 209): ?>
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["onlyShowCustomMailSent" => 1]])?>">Bestellungen mit gesendeter Custom-300 Benachrichtigung</a></li>
<input type="hidden" name="filter[onlyShowCustomMailSent]" value="<?= (isset($filter['onlyShowCustomMailSent']) && $filter['onlyShowCustomMailSent'] == 1) ? 1 : 0 ?>"/>
@@ -1359,7 +1360,7 @@ $pagination_entity_name = "Vorbestellungen";
* Globals for map display
*/
var borderpolies = [];
<?php if($me->is("Admin")): ?>
<?php if($me->is("Admin") && !isset($campaign)): ?>
<?php foreach(ADBNetzgebietModel::search(["borderpoly" => true]) as $bp_netz): ?>
borderpolies.push([<?=$bp_netz->borderpoly?>]);
<?php endforeach; ?>

View File

@@ -37,6 +37,8 @@
<div class="card-body">
<div class="card-header bg-info text-white pl-2 pr-2 pt-1 pb-1">Details</div>
<div class="row">
<div class="col-8">
<div class="loader-big hidden" ><img src="<?=self::getResourcePath()?>assets/images/loader-big.gif" /></div>
@@ -170,6 +172,18 @@
</tr>
</table>
<div class="row mb-2">
<div class="col-8">
<?php if($preorder->orderproduct): ?>
<a href="<?=self::getUrl("Order", "index", ["id" => $preorder->orderproduct->order_id])?>" target="_blank"><i class="far fa-fw fa-angle-double-right"></i> Internetbestellung anzeigen</a>
<?php else: ?>
<a href="<?=self::getUrl("Preorder","createOrderFromPreorder", ["preorder_id" => $preorder->id])?>" target="_blank" class="btn btn-outline-primary"><i class="far fa-fw fa-angle-double-right"></i> Internetbestellung anlegen</a>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-4">
@@ -697,6 +711,7 @@
<th>ctag</th>
<th>Typ</th>
<th>External ID</th>
<th>External Name</th>
<th>External State</th>
</tr>
<?php if(is_array($preorder->ctags) && count($preorder->ctags)): ?>
@@ -706,6 +721,7 @@
<td class="text-monospace"><?=$ctag->ctag?></td>
<td class="text-monospace"><?=$ctag->service_type?></td>
<td class="text-monospace"><?=htmlentities($ctag->ext_id)?></td>
<td class="text-monospace"><?=htmlentities($ctag->ext_name)?></td>
<td class="text-monospace"><?=htmlentities($ctag->ext_status)?></td>
</tr>
<?php endforeach; ?>

View File

@@ -117,6 +117,7 @@
<option></option>
<option value="ported_out" <?=($number->disabled_reason == "ported_out") ? "selected='selected'" : ""?>>Zu neuem Provider portiert</option>
<option value="ported_back" <?=($number->disabled_reason == "ported_back") ? "selected='selected'" : ""?>>Zum Anker zurückportiert</option>
<option value="contract_cancelled" <?=($number->disabled_reason == "contract_cancelled") ? "selected='selected'" : ""?>>Vertrag gekündigt</option>
<option value="reserved" <?=($number->disabled_reason == "reserved") ? "selected='selected'" : ""?>>Reserviert</option>
<option value="legacy" <?=($number->disabled_reason == "legacy") ? "selected='selected'" : ""?>>Legacy</option>
<option value="damaged" <?=($number->disabled_reason == "damaged") ? "selected='selected'" : ""?>>Kaputt</option>

View File

@@ -17,14 +17,16 @@
</tr>
<?php $i = 0; foreach(range((array_key_exists($block->id, $num_from) ? $num_from[$block->id] : $block->first), $block->last) as $number): ?>
<?php $num = VoicenumberModel::getFirst(['voicenumberblock_id' => $block->id, 'number' => $number]) ?>
<tr>
<tr class="<?=($num->disabled) ? "bg-secondary text-white" : ""?>">
<td><?=$number?></td>
<td>
<?php if($num->active): ?>
<span class="text-success"><i class="fas fa-check"></i></span>
<small class="text-monospace">(seit <?=($num->id) ? date("d.m.Y H:i:s", $num->activated_date) : ""?>)</small>
<span class="text-success"><i class="fas fa-check"></i></span>
<small class="text-monospace">(seit <?=($num->id) ? date("d.m.Y H:i:s", $num->activated_date) : ""?>)</small>
<?php elseif($num->disabled): ?>
<span class="text-success text-danger" title="Nummer wurde wegportiert"><i class="far fa-anchor bg-white" style="padding: 2px;"></i> <i class="far fa-file-export bg-white" style="padding: 2px;"></i></span>
<?php else: ?>
<span class="text-danger"><i class="fas fa-times"></i></span>
<span class="text-danger"><i class="fas fa-times"></i></span>
<?php endif; ?>
</td>
<td><a href="<?=self::getUrl("Contract", "view", ["id" => $num->contract_id])?>"><?=$num->contract_id?></a></td>
@@ -40,7 +42,7 @@
Lokal
<?php endif; ?>
</td>
<td><?=$num->disabled_reason?></td>
<td><?=$num->disabled_reason?><?=($num->disabled > 1) ? " (<i>".date("d.m.Y H:i", $num->disabled)."</i>)" : ""?></td>
<td><?=($num->id && $num->enable_on_date) ? date("d.m.Y", $num->enable_on_date) : ""?></td>
<td>
<a href="<?=self::getUrl("Voicenumber", "edit", ["block_id" => $block->id, "number" => $number])?>"><i class="fas fa-edit"></i></a>

View File

@@ -1,4 +1,12 @@
<?php
// Prepare OpenReplay user data
$openreplayUserId = '';
if (class_exists('mfUser') && class_exists('mfLoginController') && mfLoginController::isLoggedIn()) {
$user = mfUser::singleton();
if ($user && $user->id) {
$openreplayUserId = (string) $user->id;
}
}
?>
<!DOCTYPE html>
<html lang="de">
@@ -32,6 +40,34 @@
}
</script>
<!-- OpenReplay Session Recording -->
<script>
var initOpts = {
projectKey: "96MdXVcId8Ph3eOirMWj",
ingestPoint: "https://openreplay.xinon.at/ingest",
defaultInputMode: 2,
obscureTextNumbers: false,
obscureTextEmails: true,
};
var startOpts = { userID: <?= json_encode($openreplayUserId) ?> };
(function(A,s,a,y,e,r){
r=window.OpenReplay=[e,r,y,[s-1, e]];
s=document.createElement('script');s.src=A;s.async=!a;
document.getElementsByTagName('head')[0].appendChild(s);
r.start=function(v){r.push([0])};
r.stop=function(v){r.push([1])};
r.setUserID=function(id){r.push([2,id])};
r.setUserAnonymousID=function(id){r.push([3,id])};
r.setMetadata=function(k,v){r.push([4,k,v])};
r.event=function(k,p,i){r.push([5,k,p,i])};
r.issue=function(k,p){r.push([6,k,p])};
r.isActive=function(){return false};
r.getSessionToken=function(){};
})("//static.openreplay.com/17.0.0/openreplay.js",1,0,initOpts,startOpts);
window.OpenReplay.setMetadata('userType', 'internal');
window.OpenReplay.setMetadata('app', 'warehouse-stocktake-pwa');
</script>
<style>
html, body {
overscroll-behavior: none;

View File

@@ -1,4 +1,13 @@
<?php
// Prepare OpenReplay user data for external company users
$openreplayUserId = '';
$openreplayCompanyId = $JSGlobals['COMPANY_ID'] ?? '';
if (class_exists('mfUser') && class_exists('mfLoginController') && mfLoginController::isLoggedIn()) {
$user = mfUser::singleton();
if ($user && $user->id) {
$openreplayUserId = 'company_' . $user->id;
}
}
?>
<!DOCTYPE html>
<html lang="de">
@@ -34,6 +43,34 @@
}
</script>
<!-- OpenReplay Session Recording -->
<script>
var initOpts = {
projectKey: "96MdXVcId8Ph3eOirMWj",
ingestPoint: "https://openreplay.xinon.at/ingest",
defaultInputMode: 2,
obscureTextNumbers: false,
obscureTextEmails: true,
};
var startOpts = { userID: <?= json_encode($openreplayUserId) ?> };
(function(A,s,a,y,e,r){
r=window.OpenReplay=[e,r,y,[s-1, e]];
s=document.createElement('script');s.src=A;s.async=!a;
document.getElementsByTagName('head')[0].appendChild(s);
r.start=function(v){r.push([0])};
r.stop=function(v){r.push([1])};
r.setUserID=function(id){r.push([2,id])};
r.setUserAnonymousID=function(id){r.push([3,id])};
r.setMetadata=function(k,v){r.push([4,k,v])};
r.event=function(k,p,i){r.push([5,k,p,i])};
r.issue=function(k,p){r.push([6,k,p])};
r.isActive=function(){return false};
r.getSessionToken=function(){};
})("//static.openreplay.com/17.0.0/openreplay.js",1,0,initOpts,startOpts);
window.OpenReplay.setMetadata('userType', 'external');
window.OpenReplay.setMetadata('companyId', <?= json_encode($openreplayCompanyId) ?>);
</script>
<style>
html, body {
/* Prevents the rubber-band scroll effect on iOS and pull-to-refresh on Android */
@@ -247,6 +284,14 @@
return documentation.journals.filter(j => !j.text.toLowerCase().includes('wurde zugewiesen.'));
});
const isCivilEngineering = computed(() => {
return selectedWorkorder.value?.status === 'civil_engineering_required';
});
const showNormalDocsForCivilEng = computed(() => {
return isCivilEngineering.value && tenantConfig.value?.tiefbauSeesNormalDocs;
});
// --- METHODS ---
const applyTheme = () => {
@@ -485,6 +530,22 @@
}
};
const completeCivilEngineering = async () => {
if (!confirm("Möchten Sie den Tiefbau wirklich abschließen?")) return;
try {
const response = await api.post('/completeCivilEngineering', { workorderId: selectedWorkorder.value.id });
if (response.data.success) {
await fetchWorkorders();
closeDetails();
} else {
alert(response.data.message);
}
} catch(e) {
console.error("Failed to complete civil engineering", e);
alert(e.response?.data?.message || 'Fehler beim Abschließen des Tiefbaus.');
}
};
const selectFcp = (fcpValue) => {
selectedFcp.value = fcpValue;
isFcpSelectOpen.value = false;
@@ -520,10 +581,11 @@
checklist, fullscreenViewer, missingTasksPopover, translatedDocs, filteredJournals, installModal, isStandalone,
selectedFcp, isFcpSelectOpen, fcpOptions, selectedFcpText, fcpSearchTerm, filteredFcpOptions, fcpInputRef,
isSettingsOpen, theme, showThemePicker,
savingData, // <-- ADDED
savingData,
isCivilEngineering, showNormalDocsForCivilEng,
fetchWorkorders, openDetails, closeDetails, getStatusInfo, formatDate, googleMapsLink, startEditInfo, saveAdditionalInfo,
handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick, selectFcp, setTheme,
saveWorkorderData // <-- ADDED
saveWorkorderData, completeCivilEngineering
};
},
template: `
@@ -673,7 +735,7 @@
{{ savingData ? 'Speichert...' : 'Daten speichern' }}
</button>
</div>
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
<div v-if="!isCivilEngineering || showNormalDocsForCivilEng" class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
<h3 class="font-bold text-slate-700 dark:text-secondary mb-3">Checkliste</h3>
<div v-if="isDetailsLoading" class="space-y-3 animate-pulse">
<div v-for="i in 4" :key="i" class="flex items-center">
@@ -693,7 +755,7 @@
</div>
</div>
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
<div v-if="!isCivilEngineering || showNormalDocsForCivilEng" class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
<h3 class="font-bold text-slate-700 dark:text-secondary mb-2">Dokumentation</h3>
<label for="file-upload" class="w-full inline-flex items-center justify-center px-4 py-2 border border-dashed border-slate-300 dark:border-slate-700 text-sm font-medium rounded-md text-slate-700 dark:text-slate-200 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer">
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
@@ -718,7 +780,7 @@
</div>
</div>
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
<div v-if="!isCivilEngineering || showNormalDocsForCivilEng" class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
<h3 class="font-bold text-slate-700 dark:text-secondary mb-4">Journal</h3>
<div v-if="isDetailsLoading" class="animate-pulse">
<div class="flex items-start">
@@ -748,18 +810,23 @@
<footer class="bg-white dark:bg-slate-900 p-2 border-t border-slate-200 dark:border-slate-800 flex-shrink-0 grid grid-cols-2 gap-2 pt-2 px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))]">
<button @click="problemModal.show = true" class="w-full px-4 py-3 bg-red-600 text-white font-bold rounded-md text-center">Problem melden</button>
<div class="relative">
<button @click="handleCompleteClick" class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-md text-center disabled:bg-slate-300">Abschließen</button>
<transition name="fade">
<div v-if="missingTasksPopover.show" class="absolute bottom-full right-0 mb-2 w-72 bg-red-700 text-white text-sm rounded-lg shadow-lg p-3">
<h4 class="font-bold mb-1">Fehlende Punkte:</h4>
<ul class="list-disc list-inside space-y-1">
<li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li>
</ul>
<div class="absolute bottom-[-5px] right-[calc(6rem-8px)] w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-red-700"></div>
</div>
</transition>
</div>
<template v-if="isCivilEngineering">
<button @click="completeCivilEngineering" class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-md text-center">Tiefbau abschließen</button>
</template>
<template v-else>
<div class="relative">
<button @click="handleCompleteClick" class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-md text-center disabled:bg-slate-300">Abschließen</button>
<transition name="fade">
<div v-if="missingTasksPopover.show" class="absolute bottom-full right-0 mb-2 w-72 bg-red-700 text-white text-sm rounded-lg shadow-lg p-3">
<h4 class="font-bold mb-1">Fehlende Punkte:</h4>
<ul class="list-disc list-inside space-y-1">
<li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li>
</ul>
<div class="absolute bottom-[-5px] right-[calc(6rem-8px)] w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-red-700"></div>
</div>
</transition>
</div>
</template>
</footer>
</div>
</transition>

View File

@@ -18,21 +18,21 @@ $qrCodeBase64 = (new QRCode($options))->render($qrData);
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; }
html, body { height: 25mm; width: 50mm; }
html, body { height: 25mm; width: 63mm; overflow: hidden; }
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;">
<table cellpadding="0" cellspacing="0" border="0" style="width: 63mm; 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 style="width: 24mm; height: 25mm; position: relative;">
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm; position: absolute; top: 50%; left: 50%; margin-top: -10.5mm; margin-left: -10.5mm;">
</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>
<img src="<?php echo BASEDIR; ?>/public/assets/images/xinon-full-transparent.png" style="width: 24mm; height: auto; display: block; margin: 0 auto 1mm auto;">
<div style="font-size: 11px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($articleNumber); ?></div>
<div style="font-size: 9px; color: #000; margin-top: 1px; word-wrap: break-word; overflow: hidden; text-align: center;"><?php echo htmlspecialchars($articleTitle); ?></div>
</td>
</tr>
</table>

View File

@@ -0,0 +1,54 @@
<?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,
]);
$qrcode = new QRCode($options);
?>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; }
html, body { margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; color: #000; }
table { border-collapse: collapse; }
.label-page {
height: 25mm;
width: 63mm;
overflow: hidden;
page-break-after: always;
}
/* Last page should not have a break if possible, but wkhtmltopdf handles it fine usually */
.label-page:last-child {
page-break-after: auto;
}
</style>
</head>
<body>
<?php foreach($articles as $article):
$qrData = "WA:" . $article->id . ":" . $article->articleNumber;
$qrCodeBase64 = $qrcode->render($qrData);
?>
<div class="label-page">
<table cellpadding="0" cellspacing="0" border="0" style="width: 63mm; height: 25mm;">
<tr>
<td style="width: 24mm; height: 25mm; position: relative;">
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm; position: absolute; top: 50%; left: 50%; margin-top: -10.5mm; margin-left: -10.5mm;">
</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: 0 auto 1mm auto;">
<div style="font-size: 11px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($article->articleNumber); ?></div>
<div style="font-size: 9px; color: #000; margin-top: 1px; word-wrap: break-word; overflow: hidden; text-align: center;"><?php echo htmlspecialchars($article->title); ?></div>
</td>
</tr>
</table>
</div>
<?php endforeach; ?>
</body>
</html>

View File

@@ -61,8 +61,10 @@ if ($includeTax) {
}
$formattedOfferDate = date("d.m.Y", $offerDate);
$validityDays = isset($validity) ? (int)$validity : 14;
$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $offerDate));
$validityDays = isset($validity) ? (int)$validity : 31;
// Use versionDate (when this version was created) for validity calculation, fallback to offerDate
$validityBaseDate = isset($versionDate) ? $versionDate : $offerDate;
$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $validityBaseDate));
?>
<!DOCTYPE html>

View File

@@ -30,6 +30,7 @@
.invoice-details td {
text-align: left;
white-space: nowrap;
}
.invoice-details td:first-child {

View File

@@ -99,6 +99,7 @@
</style>
<?php endif; ?>
<?php $openreplayUserType = 'internal'; include(__DIR__ . "/includes/openreplay.php"); ?>
</head>
<body>

View File

@@ -0,0 +1,79 @@
<?php
/**
* OpenReplay Session Recording Integration
* Include this file in header templates to enable session recording.
*
* Usage: <?php include(__DIR__ . "/includes/openreplay.php"); ?>
*
* Variables that can be set before including:
* - $openreplayUserType: 'internal' (default) or 'external'
* - $openreplayDisabled: set to true to disable tracking
*/
if (!empty($openreplayDisabled)) return;
$openreplayUserId = '';
$openreplayUserName = '';
$openreplayUserType = $openreplayUserType ?? 'internal';
$openreplayMetadata = [];
// Get user info for internal users
if (class_exists('mfUser') && class_exists('mfLoginController') && mfLoginController::isLoggedIn()) {
$user = mfUser::singleton();
if ($user && $user->id) {
$openreplayUserId = (string) $user->id;
$openreplayUserName = $user->username ?? '';
$openreplayMetadata['userType'] = $openreplayUserType;
$openreplayMetadata['username'] = $openreplayUserName;
}
}
// Allow override from JSGlobals (for PWA contexts)
if (isset($JSGlobals['OPENREPLAY_USER_ID'])) {
$openreplayUserId = (string) $JSGlobals['OPENREPLAY_USER_ID'];
}
if (isset($JSGlobals['OPENREPLAY_USER_TYPE'])) {
$openreplayUserType = $JSGlobals['OPENREPLAY_USER_TYPE'];
$openreplayMetadata['userType'] = $openreplayUserType;
}
if (isset($JSGlobals['OPENREPLAY_COMPANY_ID'])) {
$openreplayMetadata['companyId'] = $JSGlobals['OPENREPLAY_COMPANY_ID'];
}
// Disable on dev environment if needed
$openreplayEnabled = true;
if (defined('MFAPPNAME') && MFAPPNAME === 'devthetool') {
// Optionally disable on dev - comment out to enable on dev too
// $openreplayEnabled = false;
}
if ($openreplayEnabled):
?>
<script>
var initOpts = {
projectKey: "96MdXVcId8Ph3eOirMWj",
ingestPoint: "https://openreplay.xinon.at/ingest",
defaultInputMode: 2,
obscureTextNumbers: false,
obscureTextEmails: true,
};
var startOpts = { userID: <?= json_encode($openreplayUserId) ?> };
(function(A,s,a,y,e,r){
r=window.OpenReplay=[e,r,y,[s-1, e]];
s=document.createElement('script');s.src=A;s.async=!a;
document.getElementsByTagName('head')[0].appendChild(s);
r.start=function(v){r.push([0])};
r.stop=function(v){r.push([1])};
r.setUserID=function(id){r.push([2,id])};
r.setUserAnonymousID=function(id){r.push([3,id])};
r.setMetadata=function(k,v){r.push([4,k,v])};
r.event=function(k,p,i){r.push([5,k,p,i])};
r.issue=function(k,p){r.push([6,k,p])};
r.isActive=function(){return false};
r.getSessionToken=function(){};
})("//static.openreplay.com/17.0.0/openreplay.js",1,0,initOpts,startOpts);
<?php foreach ($openreplayMetadata as $key => $value): ?>
window.OpenReplay.setMetadata(<?= json_encode($key) ?>, <?= json_encode($value) ?>);
<?php endforeach; ?>
</script>
<?php endif; ?>

View File

@@ -141,9 +141,9 @@
<?php if($me->is(["Admin","netowner","pipeplanner","pipeplanner"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanDispatcher")?>"><i class="fas fa-building-circle-arrow-right text-info"></i> Verteiler und Schächte</a></li><?php endif; ?>
<?php if($me->is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanPipe")?>"><i class="fas fa-pipe text-info pl-1"></i> Rohrverzeichnis</a></li><?php endif; ?>
<?php if($me->is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanCable")?>"><i class="fa-solid fa-timeline text-info "></i> Kabelverzeichnis</a></li><?php endif; ?>
<!-- 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("RMLAdmin")): ?><li><a href="<?=self::getUrl("WorkorderDashboard")?>"><i class="far fa-fw fa-chart-line text-info"></i> Arbeitsaufträge-Dashboard</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>
@@ -185,6 +185,7 @@
<?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") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseMovement")?>"><i class="far fa-fw fa-arrow-right-arrow-left text-info"></i> Lagerbewegung</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; ?>

View File

@@ -69,6 +69,8 @@
<?php endif; ?>
</style>
<?php $openreplayUserType = 'internal'; include(__DIR__ . "/includes/openreplay.php"); ?>
</head>
<body>

View File

@@ -147,7 +147,7 @@ class ADBHausnummerModel {
$sql .= " WHERE $where";
if (!empty($filter['home_oaid_rimo_id'])) {
if (!empty($filter['home_oaid_rimo_id']) && !$join_tables) {
$sql .= " GROUP BY Hausnummer.id";
}

View File

@@ -41,6 +41,10 @@ class ADBNetzgebiet extends mfBaseModelV2 {
public ?string $source_id = null;
public ?string $borderpoly = null;
public ?string $freigabe = '["interest", "provision", "order", "reorder"]';
public int $unit_count = 0;
public int $unit_count_sd = 0;
public int $unit_count_md = 0;
public int $unit_create_oaid = 0;
public ?string $options = '{"create_address_parts": 0, "update_freigabe": 1, "update_address": 1, "hausnummer_dont_overwrite_netzgebiet": 0, "create_preorder": 0, "preorder_only_oaid": 0, "wo_ignore_status": 0, "delete_units": 0, "mph_min_homes_tool_automatic_count": 3, "unit_create_oaid": 0}';
public int $create;
public int $edit;

View File

@@ -24,6 +24,9 @@ class ADBNetzgebietController extends mfBaseController {
"GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"),
"SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"),
"HISTORY_URL" => $this::getUrl("ADBNetzgebiet/getHistory"),
"START_RIMO_IMPORT_URL" => $this::getUrl("ADBNetzgebiet/startRimoImport"),
"GET_RIMO_IMPORT_STATUS_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportStatus"),
"GET_RIMO_IMPORT_LOG_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportLog"),
"NETWORK_URL" => $this::getUrl("Network/Index"),
"NETWORK_CREATE_URL" => $this::getUrl("Network/add"),
"CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"),
@@ -130,6 +133,193 @@ class ADBNetzgebietController extends mfBaseController {
self::returnJson(['success' => true, 'data' => $history]);
}
protected function startRimoImportAction(): void {
$id = $_GET['id'] ?? null;
if (empty($id)) {
self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]);
return;
}
$netzgebiet = ADBNetzgebiet::get($id);
if (!$netzgebiet || !$netzgebiet->id) {
self::returnJson(['success' => false, 'message' => "Netzgebiet not found."]);
return;
}
if (strpos($netzgebiet->source, 'rimo-') !== 0) {
self::returnJson(['success' => false, 'message' => "This action is only for RIMO-source Netzgebiete."]);
return;
}
if (empty($netzgebiet->source_id)) {
self::returnJson(['success' => false, 'message' => "Netzgebiet has no Source ID."]);
return;
}
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
$importTempDir = TEMP_DIR . "/ADBNetzgebietRimoImport/";
$logDir = $importTempDir . $safeSourceId;
$logFile = $logDir . "/import.log";
$lockFile = $logDir . "/import.lock";
if (is_dir($importTempDir)) {
foreach (glob($importTempDir . "*") as $dir) {
if (is_dir($dir) && (time() - filemtime($dir)) > 86400) {
// simple cleanup
if (file_exists($dir . "/import.log")) @unlink($dir . "/import.log");
if (file_exists($dir . "/import.lock")) @unlink($dir . "/import.lock");
@rmdir($dir);
}
}
}
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
if (file_exists($lockFile)) {
if ((time() - filemtime($lockFile)) > 3600) { // stale lock for 1h
@unlink($lockFile);
} else {
self::returnJson(['success' => false, 'message' => "Import is already running.", 'status' => 'running']);
return;
}
}
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
$remaining = 900 - (time() - filemtime($logFile));
self::returnJson(['success' => false, 'message' => "Please wait before starting another import.", 'status' => 'cooldown', 'remaining' => $remaining]);
return;
}
touch($lockFile);
$projectRoot = dirname(dirname(__DIR__));
$scriptRelativePath = 'scripts/adb-rimo-import/rimo-import.php';
$scriptFullPath = $projectRoot . '/' . $scriptRelativePath;
if (!file_exists($scriptFullPath)) {
self::returnJson(['success' => false, 'message' => "Import script not found."]);
return;
}
$php_executable = "php";
$command = "$php_executable $scriptRelativePath " . escapeshellarg($netzgebiet->source_id);
$bgCommand = 'cd ' . escapeshellarg($projectRoot) . ' && ' . $command . ' > ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
$pid = shell_exec($bgCommand);
if(empty($pid) || !is_numeric(trim($pid))) {
self::returnJson(['success' => false, 'message' => "Failed to start background process."]);
return;
}
file_put_contents($lockFile, trim($pid));
self::returnJson(['success' => true, 'message' => 'RIMO import started.']);
}
protected function getRimoImportStatusAction(): void {
$ids = $this->postData['ids'] ?? [];
if (empty($ids)) {
self::returnJson(['success' => true, 'data' => []]);
return;
}
$statuses = [];
foreach ($ids as $id) {
$netzgebiet = ADBNetzgebiet::get($id);
if (!$netzgebiet || !$netzgebiet->id || strpos($netzgebiet->source, 'rimo-') !== 0 || empty($netzgebiet->source_id)) {
$statuses[$id] = ['status' => 'not_applicable'];
continue;
}
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
$logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId;
$logFile = $logDir . "/import.log";
$lockFile = $logDir . "/import.lock";
if (file_exists($lockFile)) {
$pid = trim(file_get_contents($lockFile));
// Check if process is still running. posix_getpgid returns false if process does not exist.
if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) {
$statuses[$id] = ['status' => 'running'];
} else {
// Stale lock file, process is gone.
@unlink($lockFile);
// Check for cooldown based on log file from the finished process
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
$statuses[$id] = [
'status' => 'cooldown',
'remaining' => 900 - (time() - filemtime($logFile))
];
} else {
$statuses[$id] = ['status' => 'idle'];
}
}
} elseif (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
$statuses[$id] = [
'status' => 'cooldown',
'remaining' => 900 - (time() - filemtime($logFile))
];
} else {
$statuses[$id] = ['status' => 'idle'];
}
}
self::returnJson(['success' => true, 'data' => $statuses]);
}
protected function getRimoImportLogAction(): void {
$id = $_GET['id'] ?? null;
if (empty($id)) {
self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]);
return;
}
$netzgebiet = ADBNetzgebiet::get($id);
if (!$netzgebiet || !$netzgebiet->id || empty($netzgebiet->source_id)) {
self::returnJson(['success' => false, 'message' => "Netzgebiet not found or not applicable."]);
return;
}
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
$logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId;
$logFile = $logDir . "/import.log";
$lockFile = $logDir . "/import.lock";
$logContent = "";
if (file_exists($logFile)) {
$logContent = file_get_contents($logFile);
}
$status = 'idle';
if (file_exists($lockFile)) {
$pid = trim(file_get_contents($lockFile));
if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) {
$status = 'running';
} else {
@unlink($lockFile); // Stale lock, process is gone
}
}
if ($status !== 'running') {
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
$status = 'cooldown';
} else {
$status = file_exists($logFile) ? 'finished' : 'idle';
}
}
self::returnJson([
'success' => true,
'data' => [
'log' => $logContent,
'status' => $status,
'timestamp' => file_exists($logFile) ? filemtime($logFile) : null
]
]);
}
// TODO: Implement RIMO API check
protected function checkRimoSourceIdAction(): void {
self::returnJson(['success' => false, 'message' => "RIMO API check not available."]);

View File

@@ -333,6 +333,8 @@ class AddressController extends mfBaseController {
$data['fibu_supplier_due'] = ($r->fibu_supplier_due) ? trim($r->fibu_supplier_due) : null;
$data['fibu_supplier_skonto'] = ($r->fibu_supplier_skonto) ? trim($r->fibu_supplier_skonto) : null;
$data['fibu_supplier_skonto_rate'] = ($r->fibu_supplier_skonto_rate) ? trim($r->fibu_supplier_skonto_rate) : null;
$data["manual_invoice_sepa_limit"] = ($r->manual_invoice_sepa_limit) ? str_replace(",", ".", trim($r->manual_invoice_sepa_limit)) : null;
}

View File

@@ -131,7 +131,7 @@ class AddressdbApicontroller extends mfBaseApicontroller {
protected function getClusters() {
$cluster_search = [];
if(count($this->filter_salescluster_ids)) {
$cluster_search['netzgebiet_id'] = $this->filter_salescluster_ids;
$cluster_search['id'] = $this->filter_salescluster_ids;
}
$clusters = [];
foreach(ADBNetzgebietModel::search($cluster_search) as $cluster) {

View File

@@ -0,0 +1,176 @@
<?php
class InvestigatorApicontroller extends mfBaseApicontroller
{
protected function init() {}
protected function registerRoutes()
{
$this->addRoute("/investigator/customer/:id", "getCustomer", "GET");
$this->addRoute("/investigator/customer/:id/contract", "getCustomerContract", "GET");
$this->addRoute("/investigator/customer/:id/cpe", "getCustomerCpe", "GET");
$this->addRoute("/investigator/customer/:id/address", "getCustomerAddress", "GET");
$this->addRoute("/investigator/search/customer", "searchCustomer", "GET");
}
protected function authenticated()
{
$this->registerRoutes();
}
public function getCustomer($customerId)
{
if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']);
$addresses = AddressModel::search(['customer_number' => $customerId]);
if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']);
$address = $addresses[0];
return mfResponse::Ok([
'customer' => [
'id' => $address->id,
'customerNumber' => $address->customer_number,
'company' => $address->company,
'firstName' => $address->firstname,
'lastName' => $address->lastname,
'email' => $address->email,
'phone' => $address->phone,
'street' => $address->street,
'zip' => $address->zip,
'city' => $address->city,
'country' => $address->country,
]
]);
}
public function getCustomerContract($customerId)
{
if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']);
$addresses = AddressModel::search(['customer_number' => $customerId]);
if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']);
$contracts = ContractModel::search(['owner_id' => $addresses[0]->id]);
$contractData = [];
foreach ($contracts as $contract) {
$contractData[] = [
'contractId' => $contract->contract_id,
'productName' => $contract->product_name,
'productExternal' => $contract->product_external,
'price' => $contract->price,
'billingPeriod' => $contract->billing_period,
'orderDate' => $contract->order_date,
'finishDate' => $contract->finish_date,
'cancelDate' => $contract->cancel_date,
'slaId' => $contract->sla_id,
'status' => $contract->cancel_date ? 'Cancelled' : ($contract->finish_date ? 'Active' : 'Pending'),
];
}
return mfResponse::Ok([
'customerId' => $customerId,
'contractCount' => count($contractData),
'contracts' => $contractData,
]);
}
public function getCustomerCpe($customerId)
{
if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']);
$addresses = AddressModel::search(['customer_number' => $customerId]);
if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']);
$db = $this->db();
$sql = "SELECT cp.* FROM Cpeprovisioning cp
INNER JOIN `Order` o ON cp.order_id = o.id
WHERE o.owner_id = " . intval($addresses[0]->id) . "
ORDER BY cp.id DESC LIMIT 10";
$res = $db->query($sql);
$cpeData = [];
while ($row = $db->fetch_object($res)) {
$cpeData[] = [
'orderId' => $row->order_id ?? null,
'routerType' => $row->routertype ?? null,
'routerConfigFinished' => (bool)($row->routerconfig_finished ?? false),
'mac' => $row->mac ?? null,
'ontSerial' => $row->ont_sn ?? null,
'wifiSsid' => $row->wifi_ssid ?? null,
'wifiPasswordSet' => !empty($row->wifi_pass),
'vlanPublic' => $row->vlan_public ?? null,
'vlanNat' => $row->vlan_nat ?? null,
'vlanIpv6' => $row->vlan_ipv6 ?? null,
'shipping' => (bool)($row->shipping ?? false),
];
}
return mfResponse::Ok([
'customerId' => $customerId,
'cpeCount' => count($cpeData),
'cpeProvisioning' => $cpeData,
]);
}
public function getCustomerAddress($customerId)
{
if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']);
$addresses = AddressModel::search(['customer_number' => $customerId]);
if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']);
$address = $addresses[0];
return mfResponse::Ok([
'customerId' => $customerId,
'address' => [
'company' => $address->company,
'name' => trim(($address->firstname ?? '') . ' ' . ($address->lastname ?? '')),
'street' => $address->street,
'zip' => $address->zip,
'city' => $address->city,
'country' => $address->country,
'email' => $address->email,
'phone' => $address->phone,
]
]);
}
public function searchCustomer()
{
$searchTerm = $this->get['q'] ?? '';
$limit = intval($this->get['limit'] ?? 10);
if (empty($searchTerm)) return mfResponse::BadRequest(['message' => 'Search term required']);
$db = $this->db();
$searchTerm = $db->escape($searchTerm);
$sql = "SELECT * FROM Address
WHERE customer_number LIKE '%$searchTerm%'
OR CONCAT(firstname, ' ', lastname) LIKE '%$searchTerm%'
OR company LIKE '%$searchTerm%'
OR email LIKE '%$searchTerm%'
OR street LIKE '%$searchTerm%'
LIMIT $limit";
$results = $db->queryRows($sql);
$customers = [];
foreach ($results as $row) {
$customers[] = [
'customerId' => $row['customer_number'],
'name' => trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')) ?: $row['company'],
'email' => $row['email'],
'city' => $row['city'],
];
}
return mfResponse::Ok([
'searchTerm' => $searchTerm,
'count' => count($customers),
'customers' => $customers,
]);
}
}

View File

@@ -85,13 +85,13 @@ class SnoppCitycom extends Modules\ApiControllerModule
$bb_down = $this->post["bb_down"];
$execution_date = false;
if($this->post["execution_date"]) {
/*if($this->post["execution_date"]) {
try {
$execution_date = new \DateTime($this->post["execution_date"]);
} catch(\Exception $e) {
return \mfResponse::BadRequest(["message" => "Invalid Timestamp format"]);
}
}
}*/
if(!is_numeric($bb_down) || !$bb_down || !is_numeric($bb_up) || !$bb_up || !$bb_down > 10000 || $bb_up > 10000) {
@@ -118,6 +118,10 @@ class SnoppCitycom extends Modules\ApiControllerModule
// if all services are ordered and active, finish order and return active
$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
if(!$ctag) {
return \mfResponse::NotFound(["message" => "Home not found"]);
}
if($ctag->ext_id && $ctag->ext_status == "finished") {
if($preorder->status->code < 500) {
$preorder->setNewStatusCode(500);
@@ -128,11 +132,11 @@ class SnoppCitycom extends Modules\ApiControllerModule
// Home must have Status 300, else return deferred
if($wohneinheit->status->code < 300) {
/*if($wohneinheit->status->code < 300) {
return \mfResponse::Ok(["message" => "ONT not yet installed. Deferred", "activation_status" => "deferred"]);
}
}*/
/*
$cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref);
$data["up"] = $bb_up;
$data["down"] = $bb_down;
@@ -159,10 +163,30 @@ class SnoppCitycom extends Modules\ApiControllerModule
// order Service at Citycom and set Preorder to 500 Finished
if(!$cc_api->orderServices($preorder, $cc_home_id, $data)) {
return \mfResponse::InternalServerError(["message" => "Error activating service"]);
}*/
// update product at citycom
//$cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref);
$data["up"] = $bb_up;
$data["down"] = $bb_down;
$data["product_name"] = false;
if($preorder->campaign->fulfillment == "citycom_oan") {
$data["product_name"] = "Estmk Greenstream OAN $bb_down/$bb_up";
}
// live check if service is active, if not return deferred
$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
$cc_api_client = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS);
$cc_api = new \Citycom_OanApiHelper($cc_api_client);
// try to update product with bandwidth provided by snopp.
// updateService() only updates if values are changed.
if(!$cc_api->updateService($ctag->ext_id, $data)) {
$this->log->error(__METHOD__.": Error updating service {$ctag->ext_id} for preorder {$preorder->id}");
//return \mfResponse::InternalServerError(["message" => "Error activating service"]);
}
// check if service is active, if not return deferred
//$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
if($ctag->ext_id && $ctag->ext_status == "finished") {
if($preorder->status->code < 500) {
$preorder->setNewStatusCode(500);

View File

@@ -43,22 +43,8 @@ class Cif extends Modules\ApiControllerModule {
return \mfResponse::NotFound(["message" => "Preorder not found"]);
}
// set status to 200
if($preorder->status->code < 200) {
$new_status = \PreorderstatusModel::getFirst(["code" => 200]);
if(!$new_status) {
return \mfResponse::InternalServerError();
}
$preorder->status_id = $new_status->id;
$preorder->save();
}
$sflag = \PreorderStatusflagModel::getFirst(["code" => 200]);
$sflag->preorder_id = $preorder->id;
if(!$sflag->value->value) {
$sflag->value->value = 1;
$sflag->value->save();
}
// set status flag 200
$preorder->setStatusFlag(200, 1);
return \mfResponse::Ok(["message" => "Status successfully updated."]);
@@ -134,22 +120,8 @@ class Cif extends Modules\ApiControllerModule {
return \mfResponse::NotFound(["message" => "Invalid ciftoken"]);
}
// set status to 200
if($preorder->status->code < 200) {
$new_status = \PreorderstatusModel::getFirst(["code" => 200]);
if(!$new_status) {
return \mfResponse::InternalServerError();
}
$preorder->status_id = $new_status->id;
$preorder->save();
}
$sflag = \PreorderStatusflagModel::getFirst(["code" => 200]);
$sflag->preorder_id = $preorder->id;
if(!$sflag->value->value) {
$sflag->value->value = 1;
$sflag->value->save();
}
// set status flag 200
$preorder->setStatusFlag(200, 1);
return \mfResponse::Ok(["message" => "Status successfully updated."]);

View File

@@ -7,7 +7,8 @@ class AssetManagementController extends TTCrud
// Simplified columns for better layout, details are in the 'assetDetails' slot
protected array $columns = [
['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search']],
['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]],
['key' => 'category', 'text' => 'Kategorie', 'modal' => false, 'table' => ['filter' => 'select', 'filterOptions' => []]],
['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']],
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
@@ -22,6 +23,15 @@ class AssetManagementController extends TTCrud
$this->additionalJSVariables['ASSET_ADMIN'] = '0';
$this->columns = array_filter($this->columns, fn($col) => $col['key'] !== 'actions');
}
$categories = AssetManagementModel::getDistinctCategories();
$categoryOptions = array_map(fn($cat) => ['value' => $cat, 'text' => $cat], $categories);
foreach ($this->columns as &$column) {
if ($column['key'] === 'category') {
$column['table']['filterOptions'] = $categoryOptions;
break;
}
}
}
/**
@@ -42,7 +52,12 @@ class AssetManagementController extends TTCrud
$json = json_decode(file_get_contents('php://input'), true);
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
$filters = $json['filters'] ?? [];
$order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC'];
$order = $json['order'] ?? ['key' => 'name', 'order' => 'ASC'];
// Map virtual column 'assetDetails' to actual 'name' column for sorting
if (isset($order['key']) && $order['key'] === 'assetDetails') {
$order['key'] = 'name';
}
// Fetch paginated assets
$assets = AssetManagementModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order);
@@ -277,6 +292,18 @@ class AssetManagementController extends TTCrud
self::returnJson(['success' => true, 'message' => 'Reservierung gelöscht.']);
}
protected function getCategoriesAction()
{
$searchTerm = $this->request->q ?? '';
$categories = AssetManagementModel::getDistinctCategories($searchTerm);
$result = array_map(function($category) {
return ['value' => $category, 'text' => $category];
}, $categories);
self::returnJson($result);
}
protected function printLabelAction() {
if (!$this->user->can('AssetAdmin')) {
self::sendError("Permission denied", 403);

View File

@@ -4,6 +4,7 @@ class AssetManagementModel extends TTCrudBaseModel {
public int $id;
public string $name;
public ?string $description;
public ?string $category;
public ?int $mainImageId; // Renamed from imageId
public ?string $imageIds; // Changed to JSON (will be a string in PHP)
public string $assetNumber;
@@ -35,8 +36,7 @@ class AssetManagementModel extends TTCrudBaseModel {
foreach ($searchTerms as $term) {
if (empty(trim($term))) continue;
$escapedTerm = $db->real_escape_string($term);
// For each term, search in name, assetNumber, and description.
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')";
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%' OR `category` LIKE '%$escapedTerm%')";
}
if (!empty($searchConditions)) {
@@ -99,8 +99,8 @@ class AssetManagementModel extends TTCrudBaseModel {
foreach ($searchTerms as $term) {
if (empty(trim($term))) continue;
$escapedTerm = $db->real_escape_string($term);
// For each term, search in name, assetNumber, and description.
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')";
// For each term, search in name, assetNumber, description, and category.
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%' OR `category` LIKE '%$escapedTerm%')";
}
if (!empty($searchConditions)) {
@@ -128,4 +128,26 @@ class AssetManagementModel extends TTCrudBaseModel {
return $result->fetch_assoc()['count'];
}
public static function getDistinctCategories(string $searchTerm = ''): array {
$db = self::getDB();
$table = self::getFullyQualifiedTable();
$sql = "SELECT DISTINCT `category` FROM $table WHERE `category` IS NOT NULL AND `category` != ''";
if (!empty($searchTerm)) {
$escapedTerm = $db->real_escape_string($searchTerm);
$sql .= " AND `category` LIKE '%$escapedTerm%'";
}
$sql .= " ORDER BY `category` ASC LIMIT 20";
$result = $db->query($sql);
$categories = [];
while ($row = $result->fetch_assoc()) {
$categories[] = $row['category'];
}
return $categories;
}
}

View File

@@ -549,23 +549,23 @@ class CpeprovisioningController extends mfBaseController {
"ORDER_URL" => $this->getUrl("Order"),
"NETWORKS" => NetworkModel::getAll(),
"ROUTER_OPTIONS" => [
['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'],
['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'],
['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'],
['value' => 'FritzBox 6670 Cable', 'text' => 'FritzBox 6670 Cable (Inet, Phone, IPTV)'],
// General Options
['value' => 'eigener Router', 'text' => 'Eigener Router'],
['value' => 'anderes CPE', 'text' => 'Anderes CPE'],
// PPPoE/DHCP Routers
['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'],
['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'],
['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'],
['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'],
['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'],
['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'],
['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'],
// Static Routers
['value' => 'Mikrotik HAP AC', 'text' => 'Mikrotik HAP AC (Inet, IPTV)'],
['value' => 'Mikrotik HEX S', 'text' => 'Mikrotik HEX S (Inet, IPTV)'],
['value' => 'Mikrotik RB3011', 'text' => 'Mikrotik RB3011 (Inet, IPTV)'],
// CMTS Routers
// Legacy
['value' => 'FritzBox 6490 Cable', 'text' => 'FritzBox 6490 Cable (Inet, Phone, IPTV)'],
['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'],
['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'],
['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'],
['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'],
],
"ROUTER_SHIPPING_DATA" => [
"TP-Link Archer C80" => ["weight" => 1, "length" => 35, "width" => 24, "height" => 8],

View File

@@ -32,7 +32,7 @@ class Emailnotification {
if($object_data !== false) $this->object_data = $object_data;
}
public function addAttachment($filepath = null, $content = null, $name = false, $c_type = "application/octet-stream", $disposition = "attachment", $encoding = "base64" , $charset = "utf-8") {
public function addAttachment($filepath = null, $content = null, $name = false, $c_type = "application/octet-stream", $disposition = "attachment", $encoding = "base64", $charset = "utf-8") {
$attachment = [
"file" => $filepath,
"content" => $content,

View File

@@ -681,6 +681,8 @@ class InvoiceController extends mfBaseController {
}
// save Invoiceposition
// first round price
$position->price_gross = round($position->price_gross, 4);
if (!$position->save()) {
$invoice->rollbackTransaction();
die("Error saving Invoiceposition");
@@ -703,7 +705,7 @@ class InvoiceController extends mfBaseController {
}
$invoice->total = $total_net;
$invoice->total_gross = $total_gross;
$invoice->total_gross = round($total_gross, 4);
//$invoice->total_vat = $total_vat;
if (!$invoice->save()) {
@@ -778,6 +780,207 @@ class InvoiceController extends mfBaseController {
}
protected function manualExportBmd() {
if(!$this->me->can("Billing")) {
$this->redirect("Dashboard");
}
//var_dump($this->request->get());
$csv_header = "\u{FEFF}satzart;konto;gkonto;belegnr;belegdatum;zziel;skontopz;skontotage;buchsymbol;buchcode;prozent;steuercode;betrag;steuer;text;";
$csv_header .= "bank-iban-nr;bank-swiftcode;bank-mandatsid;bank-mandatsdatum;bank-mandatskz;bank-letztereinzug;zvsperre;bankeinzug;";
$csv_header .= "kost;kobetrag";
$csv_out = "";
//var_dump($filter);exit;
$filter = [
"lock" => 1,
"exported" => 0,
];
if($this->request->manual_invoice_date_from) {
$date_from = Layout::dateToInt($this->request->manual_invoice_date_from);
if($date_from) {
$filter["invoice_date"] = ["from" => $date_from];
}
}
if($this->request->manual_invoice_date_to) {
$date_to = Layout::dateToInt($this->request->manual_invoice_date_to);
if($date_to) {
$filter["invoice_date"] = ["to" => $date_to];
}
}
//var_dump($filter);exit;
if(!ManualInvoiceModel::count($filter)) {
$this->layout()->setFlash("Keine Rechnungen zum Exportieren gefunden.");
$this->redirect("Invoice");
}
foreach(ManualInvoiceModel::getAll($filter) as $invoice) {
if($invoice->exported) {
die("wtf");
}
$billingaddress = new Address($invoice->billingaddress_id);
if(!$billingaddress->id) {
die("Billingaddresse für Rechnung {$invoice->invoice_number} not found");
}
$kostentraeger = [];
//var_dump($invoice->getProperty("positions"));
//$vat_total_gross = 0;
foreach($invoice->getProperty("positions") as $position) {
if(!array_key_exists($position->position_group, $kostentraeger)) {
$kostentraeger[$position->position_group] = 0;
}
//$kostentraeger[$position->position_group] += $position->price_gross;
//$vat_total_gross += $position->price_gross - $position->price_total;
$price = $position->price_total;
/*if($position->discount) {
$price -= ($price / 100) * $position->discount;
}*/
if($invoice->gesamtrabatt) {
$price -= ($price / 100) * $invoice->gesamtrabatt;
}
$kostentraeger[$position->position_group] += $price;
}
$total_gross = $invoice->total_gross;
/*if($invoice->gesamtrabatt) {
$total_gross -= round(($total_gross / 100) * $invoice->gesamtrabatt, 4);
}*/
$total = $invoice->total;
/*if($invoice->gesamtrabatt) {
$total -= round(($total / 100) * $invoice->gesamtrabatt, 4);
}*/
if($invoice->total_gross) {
$vatrate = 20;
}
if($invoice->total == $invoice->total_gross && $invoice->fibu_cost_area != "domestic") {
$vatrate = "0";
} else {
$vatrate = "20";
}
$vat = $total_gross - $total;
$vat *= -1;
//$vat_total_gross *= -1;
if($invoice->total < 0) {
$buchsymbol = "GU";
} else {
$buchsymbol = "AR";
}
$fibu_account = $invoice->fibu_account_number;
$buchungstext = "[".$invoice->customer_number."]";
if($invoice->company) {
$buchungstext .= " ".$invoice->company;
} elseif($invoice->firstname || $invoice->lastname) {
$buchungstext .= " ".$invoice->firstname." ".$invoice->lastname;
}
$buchungstext = str_replace(["\n","\r", ";"], "", $buchungstext);
$buchcode = "1";
$is_sepa = ($invoice->billing_type == "sepa");
$iban = "";
$bic = "";
$sepa_id = "";
$sepa_date = false;
$last_invoice_date = false;
$mandatskz = "";
if($is_sepa) {
$iban = $invoice->bank_account_iban;
$bic = $invoice->bank_account_bic;
$sepa_id = "R".$fibu_account;
if($billingaddress->sepa_date) {
$sepa_date = new DateTime("@".$billingaddress->sepa_date);
$sepa_date->setTimezone(new DateTimeZone("Europe/Vienna"));
if($billingaddress->last_invoice_date) {
$sepa_last_date = new DateTime("@".$billingaddress->last_invoice_date);
$data["sepa_last_date"] = $sepa_last_date->format("Y-m-d");
$last_invoice_date = new DateTime("@".$billingaddress->last_invoice_date);
$last_invoice_date->setTimezone(new DateTimeZone("Europe/Vienna"));
if($last_invoice_date->format("Y-m-d") < $sepa_date->format("Y-m-d")) {
$last_invoice_date = false;
}
}
}
$mandatskz = ($last_invoice_date ? "1" : "0");
$three_years_ago = new DateTime("now");
$three_years_ago->modify("-3 years");
if($mandatskz == "0") {
while($sepa_date->format("Y-m-d") < $three_years_ago->format("Y-m-d")) {
$sepa_date->modify("+1 year");
}
}
}
$kost = $invoice->fibu_cost_account;
$csv_out .= "0;";
$csv_out .= $fibu_account.";";
$csv_out .= $invoice->fibu_cost_account.";";
$csv_out .= $invoice->invoice_number.";";
$csv_out .= date("d.m.Y", $invoice->invoice_date).";";
$csv_out .= ($invoice->fibu_payment_due === null) ? ";" : $invoice->fibu_payment_due.";";
$csv_out .= ($invoice->fibu_payment_skonto) ? $invoice->fibu_payment_skonto.";" : ";";
$csv_out .= ($invoice->fibu_payment_skonto_rate) ? $invoice->fibu_payment_skonto_rate.";" : ";";
$csv_out .= $buchsymbol.";";
$csv_out .= $buchcode.";";
$csv_out .= $vatrate.";";
$csv_out .= $invoice->fibu_taxcode.";";
$csv_out .= number_format($total_gross, 2, ",", "").";";
$csv_out .= number_format($vat, 2, ",", "").";";
$csv_out .= $buchungstext.";";
$csv_out .= $iban.";";
$csv_out .= $bic.";";
$csv_out .= $sepa_id.";";
$csv_out .= ($sepa_date ? $sepa_date->format("d.m.Y") : "").";";
$csv_out .= $mandatskz.";";
$csv_out .= ($last_invoice_date ? $last_invoice_date->format("d.m.Y") : "").";";
$csv_out .= ($is_sepa ? 0 : 10).";";
$csv_out .= ($is_sepa ? 1 : 0);
if(count($kostentraeger) >= 2) {
foreach($kostentraeger as $kostelle => $kobetrag) {
$kobetrag_text = number_format($kobetrag, 2, ",", "");
$csv_out .= "\n1;;;;;;;;;;;;;;;;;;;;;;;$kostelle;$kobetrag_text;";
}
}
///var_dump($kostentraeger);
$csv_out .= "\n";
}
//exit;
/*$this->layout()->setFlash("Export erfolgreich abgeschlossen", "success");
$this->redirect("Invoice");*/
header("Content-type: text/csv; charset=utf-8");
header('Content-disposition: attachment; filename="tt-mrech-export-bmd-'.date('Y-m-d_H-i-s').'.csv"');
echo $csv_header."\n".$csv_out;
exit;
}
protected function exportBmdAction() {
if(!$this->me->can("Billing")) {
$this->redirect("Dashboard");

View File

@@ -105,10 +105,12 @@ class ManualInvoiceController extends TTCrud
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
"{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "<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>" : "",
"{{ leistungszeitraumHtml }}" => ($invoice->performance_period ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->performance_period) . "</td></tr>" : "",
"{{ externeReferenzHtml }}" => ($invoice->external_reference ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->external_reference) . "</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))
"{{ qrCodeHtml }}" => ($invoice->total_gross ?? 0) >= 0
? '<td style="vertical-align: top; padding-right: 10px;"><img alt="QR-Code" src="' . $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2)) . '" style="display: block; height: 100%; max-height: 3.5cm; width: auto;"></td>'
: ''
];
$headerHtml = str_replace(array_keys($replacements), array_values($replacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html"));
@@ -208,8 +210,6 @@ class ManualInvoiceController extends TTCrud
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
$recipientEmail = $post['email'] ?? null;
$subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH';
$bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team';
if (!$id || !$recipientEmail) {
self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']);
@@ -222,6 +222,19 @@ class ManualInvoiceController extends TTCrud
return;
}
// Format invoice date for display
$invoiceDateFormatted = date('d.m.Y', $invoice->invoice_date);
// Set default subject and body with invoice number and date
$defaultSubject = "Ihre Rechnung {$invoice->invoice_number} vom {$invoiceDateFormatted}";
$defaultBody = "Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung Nr. {$invoice->invoice_number} vom {$invoiceDateFormatted}.\n\nMit freundlichen Grüßen\nIhr XINON Team";
$subject = $post['subject'] ?? $defaultSubject;
$bodyText = $post['body'] ?? $defaultBody;
// Convert literal \n strings to actual newlines (in case frontend sends escaped strings)
$bodyText = str_replace('\n', "\n", $bodyText);
// Generate PDF
$pdf_filename = $this->createPDFAction(true);
if (!$pdf_filename || !file_exists($pdf_filename)) {
@@ -232,19 +245,33 @@ class ManualInvoiceController extends TTCrud
$pdfContent = file_get_contents($pdf_filename);
// --- HTML Email Generation ---
$logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
$logoToolExists = file_exists($logoToolPath);
$logoXinonExists = file_exists($logoXinonPath);
// Construct HTML Body
$html = '<!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);">';
// Construct HTML Body with Outlook compatibility
$html = '<!DOCTYPE html>';
$html .= '<html lang="de" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">';
$html .= '<head>';
$html .= '<meta charset="UTF-8">';
$html .= '<meta http-equiv="X-UA-Compatible" content="IE=edge">';
$html .= '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
$html .= '<title>Rechnung</title>';
$html .= '<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->';
$html .= '<style>body { font-family: Arial, sans-serif; color: #333; margin: 0; padding: 0; }</style>';
$html .= '</head>';
$html .= '<body style="margin:0;padding:20px;background-color:#f3f4f6;">';
// 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;">';
// Outlook-safe container table
$html .= '<!--[if mso]><table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" align="center"><tr><td><![endif]-->';
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;">';
// Logo with Outlook-safe sizing
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom:1px solid #e5e7eb;padding-bottom:15px;">';
if ($logoXinonExists) {
$html .= '<!--[if mso]><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td align="center"><![endif]-->';
$html .= '<img src="cid:logo_xinon" alt="XINON GmbH" width="150" height="50" style="display:block;width:150px;height:50px;max-width:150px;margin:0 auto;">';
$html .= '<!--[if mso]></td></tr></table><![endif]-->';
}
$html .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
@@ -254,7 +281,9 @@ class ManualInvoiceController extends TTCrud
$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>';
$html .= '</div></div>';
$html .= '<!--[if mso]></td></tr></table><![endif]-->';
$html .= '</body></html>';
$mail = new PHPMailer(true);
try {
@@ -269,12 +298,11 @@ class ManualInvoiceController extends TTCrud
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
// Logos
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
// Logo embedding
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
$mail->setFrom('thetool@xinon.at', 'XINON TheTool');
$mail->setFrom('thetool@xinon.at', 'XINON GmbH - Rechnungswesen');
$customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname);
$mail->addAddress($recipientEmail, $customerName);
@@ -283,7 +311,10 @@ class ManualInvoiceController extends TTCrud
$mail->Body = $html;
$mail->AltBody = strip_tags($bodyText);
$mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf');
// Attachment filename: YYYY-MM-DD_InvoiceNumber_Rechnung.pdf
$invoiceDateFile = date('Y-m-d', $invoice->invoice_date);
$attachmentFilename = "{$invoiceDateFile}_{$invoice->invoice_number}_Rechnung.pdf";
$mail->addStringAttachment($pdfContent, $attachmentFilename, 'base64', 'application/pdf');
$mail->send();
@@ -320,10 +351,17 @@ class ManualInvoiceController extends TTCrud
$me = new User();
$me->loadMe();
// Log download in journal
// Update status to 'gesendet' (same as email)
if ($invoice->status === 'erstellt') {
$invoice->status = 'gesendet';
$invoice->save();
}
// Log download in journal with status change
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id,
'text' => 'Rechnung heruntergeladen',
'statusChange' => 'gesendet',
'createBy' => $me->id,
'create' => time()
]);
@@ -349,20 +387,42 @@ class ManualInvoiceController extends TTCrud
$data['invoice_date'] = strtotime($data['invoice_date']);
}
$data = array_merge([
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => $data['invoice_date'] ?? time(),
'status' => 'erstellt',
'fibu_payment_skonto' => 0,
'fibu_payment_skonto_rate' => 0,
'gesamtrabatt' => 0,
'total' => 0,
'total_gross' => 0,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
'edit' => time()
], $data);
// Always generate invoice number (override any null from frontend)
$data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber();
$data['invoice_date'] = $data['invoice_date'] ?? time();
$data['status'] = 'erstellt';
$data['fibu_payment_skonto'] = $data['fibu_payment_skonto'] ?? 0;
$data['fibu_payment_skonto_rate'] = $data['fibu_payment_skonto_rate'] ?? 0;
$data['total_discount'] = $data['total_discount'] ?? $data['gesamtrabatt'] ?? 0;
$data['performance_period'] = $data['performance_period'] ?? $data['leistungszeitraum'] ?? null;
$data['introductory_text'] = $data['introductory_text'] ?? $data['einleitender_text'] ?? null;
$data['external_reference'] = $data['external_reference'] ?? $data['externe_referenz'] ?? null;
unset($data['gesamtrabatt'], $data['leistungszeitraum'], $data['einleitender_text'], $data['externe_referenz'], $data['billing_delivery']);
$data['total'] = $data['total'] ?? 0;
$data['total_gross'] = $data['total_gross'] ?? 0;
$data['lock'] = 0;
$data['exported'] = 0;
if (($data['billing_type'] ?? '') === 'sepa' && ($data['billingaddress_id'] ?? null)) {
$address = new Address($data['billingaddress_id']);
if ($address->id) {
$data['bank_account_bank'] = $address->bank_account_bank;
$data['bank_account_owner'] = $address->bank_account_owner;
$data['bank_account_iban'] = str_replace(' ', '', $address->bank_account_iban ?? '');
$data['bank_account_bic'] = str_replace(' ', '', $address->bank_account_bic ?? '');
if ($address->sepa_date) {
$data['sepa_date'] = date('Y-m-d', $address->sepa_date);
}
$data['sepa_id'] = 'R' . ($data['fibu_account_number'] ?? '');
}
}
$data['create_by'] = $me->id;
$data['edit_by'] = $me->id;
$data['create'] = time();
$data['edit'] = time();
return true;
}
@@ -389,9 +449,15 @@ class ManualInvoiceController extends TTCrud
unset($data['positions']);
}
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') {
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
return false;
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id']))) {
if ($invoice->lock == 1) {
$this->infoMessages['update'] = 'Rechnung ist gesperrt und kann nicht bearbeitet werden';
return false;
}
if ($invoice->status === 'exportiert') {
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
return false;
}
}
// Convert invoice_date from string to timestamp if needed
@@ -432,23 +498,39 @@ class ManualInvoiceController extends TTCrud
$me->loadMe();
foreach ($this->tempPositions as $position) {
// Skip empty positions
if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue;
$articleName = $position['warehousearticle_name'] ?? $position['product_name'] ?? '';
if (empty($articleName) || ($position['amount'] ?? 0) == 0) continue;
// Map _group to position_group
$groupName = $position['_group'] ?? null;
unset($position['_group']);
$amount = floatval($position['amount']);
$price = floatval($position['price']);
$discount = floatval($position['discount'] ?? 0);
$vatrate = floatval($position['vatrate'] ?? 0);
$priceAfterDiscount = $amount * $price * (1 - $discount / 100);
$priceGross = $priceAfterDiscount * (1 + $vatrate / 100);
ManualInvoicepositionModel::create(array_merge([
ManualInvoicepositionModel::create([
'manualinvoice_id' => $invoiceId,
'position_group' => $groupName,
'unit' => 'Stk.',
'discount' => 0,
'position_group' => $position['_group'] ?? null,
'matchcode' => $position['matchcode'] ?? null,
'warehousearticle_id' => $position['warehousearticle_id'] ?? $position['product_id'] ?? 0,
'warehousearticle_name' => $articleName,
'product_info' => $position['product_info'] ?? '',
'amount' => $amount,
'unit' => $position['unit'] ?? 'Stk.',
'price' => $price,
'discount' => $discount,
'price_total' => $priceAfterDiscount,
'price_gross' => $priceGross,
'vatrate' => $vatrate,
'fibu_cost_account' => $position['fibu_cost_account'] ?? null,
'fibu_cost_account_legacy' => $position['fibu_cost_account_legacy'] ?? null,
'fibu_taxcode' => $position['fibu_taxcode'] ?? null,
'options' => $position['options'] ?? null,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
'edit' => time()
], $position));
]);
}
$this->tempPositions = [];
}
@@ -458,17 +540,13 @@ class ManualInvoiceController extends TTCrud
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
$subtotal = array_sum(array_column($positions, 'price_total'));
$totalDiscount = $invoice->total_discount ?? 0;
$netTotal = $subtotal * (1 - $totalDiscount / 100);
// Apply gesamtrabatt (total discount) if exists
$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
$discountAmount = $subtotal * ($gesamtrabatt / 100);
$netTotal = $subtotal - $discountAmount;
// Calculate gross total with VAT applied after discount
$grossTotal = 0;
foreach ($positions as $pos) {
$positionNet = $pos->price_total;
$positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100);
$positionAfterDiscount = $positionNet * (1 - $totalDiscount / 100);
$grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100);
}
@@ -530,11 +608,11 @@ class ManualInvoiceController extends TTCrud
'id' => $pos->id,
'manualinvoice_id' => $pos->manualinvoice_id,
'_group' => $pos->position_group ?? '',
'billing_id' => $pos->billing_id,
'contract_id' => $pos->contract_id,
'matchcode' => $pos->matchcode,
'product_id' => $pos->product_id,
'product_name' => $pos->product_name,
'warehousearticle_id' => $pos->warehousearticle_id,
'warehousearticle_name' => $pos->warehousearticle_name,
'product_id' => $pos->warehousearticle_id,
'product_name' => $pos->warehousearticle_name,
'product_info' => $pos->product_info,
'amount' => $pos->amount,
'unit' => $pos->unit ?? 'Stk.',
@@ -580,19 +658,20 @@ class ManualInvoiceController extends TTCrud
foreach ($existingCredits as $credit) {
foreach ($credit->getProperty('positions') as $creditPos) {
$key = $creditPos->product_id . '_' . $creditPos->matchcode;
$key = $creditPos->warehousearticle_id . '_' . $creditPos->matchcode;
$creditedAmounts[$key] = ($creditedAmounts[$key] ?? 0) + abs($creditPos->amount);
}
}
$availablePositions = [];
foreach ($positions as $pos) {
$key = $pos->product_id . '_' . $pos->matchcode;
$key = $pos->warehousearticle_id . '_' . $pos->matchcode;
$availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0);
if ($availableAmount > 0) {
$availablePositions[] = [
'id' => $pos->id,
'product_name' => $pos->product_name,
'warehousearticle_name' => $pos->warehousearticle_name,
'product_name' => $pos->warehousearticle_name,
'product_info' => $pos->product_info,
'original_amount' => $pos->amount,
'credited_amount' => $creditedAmounts[$key] ?? 0,
@@ -600,7 +679,8 @@ class ManualInvoiceController extends TTCrud
'unit' => $pos->unit ?? 'Stk.',
'price' => $pos->price,
'vatrate' => $pos->vatrate,
'product_id' => $pos->product_id,
'warehousearticle_id' => $pos->warehousearticle_id,
'product_id' => $pos->warehousearticle_id,
'matchcode' => $pos->matchcode,
'fibu_cost_account' => $pos->fibu_cost_account,
'fibu_taxcode' => $pos->fibu_taxcode
@@ -626,6 +706,12 @@ class ManualInvoiceController extends TTCrud
if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) {
self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']);
return;
}
if ($originalInvoice->lock == 1) {
self::returnJson(['success' => false, 'message' => 'Originalrechnung ist gesperrt und kann nicht gutgeschrieben werden']);
return;
}
$me = new User();
@@ -634,10 +720,10 @@ class ManualInvoiceController extends TTCrud
$invoiceData = [
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => time(),
'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null,
'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
'externe_referenz' => $originalInvoice->externe_referenz ?? null,
'gesamtrabatt' => 0,
'performance_period' => $originalInvoice->performance_period ?? null,
'introductory_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
'external_reference' => $originalInvoice->external_reference ?? null,
'total_discount' => 0,
'owner_id' => $originalInvoice->owner_id,
'billingaddress_id' => $originalInvoice->billingaddress_id,
'customer_number' => $originalInvoice->customer_number,
@@ -663,7 +749,6 @@ class ManualInvoiceController extends TTCrud
'email' => $originalInvoice->email,
'uid' => $originalInvoice->uid,
'billing_type' => $originalInvoice->billing_type,
'billing_delivery' => $originalInvoice->billing_delivery,
'bank_account_bank' => $originalInvoice->bank_account_bank,
'bank_account_owner' => $originalInvoice->bank_account_owner,
'bank_account_iban' => $originalInvoice->bank_account_iban,
@@ -673,6 +758,8 @@ class ManualInvoiceController extends TTCrud
'vatgroup_id' => $originalInvoice->vatgroup_id,
'credit_for_invoice_id' => $originalInvoiceId,
'status' => 'erstellt',
'lock' => 0,
'exported' => 0,
'create' => time(),
'edit' => time(),
'create_by' => $me->id,
@@ -681,6 +768,7 @@ class ManualInvoiceController extends TTCrud
if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) {
self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']);
return;
}
foreach ($positions as $pos) {
@@ -688,8 +776,8 @@ class ManualInvoiceController extends TTCrud
ManualInvoicepositionModel::create([
'manualinvoice_id' => $creditInvoiceId,
'position_group' => null,
'product_id' => $pos['product_id'],
'product_name' => $pos['product_name'],
'warehousearticle_id' => $pos['warehousearticle_id'] ?? $pos['product_id'] ?? 0,
'warehousearticle_name' => $pos['warehousearticle_name'] ?? $pos['product_name'] ?? '',
'product_info' => $pos['product_info'] ?? '',
'amount' => -abs($pos['amount']),
'unit' => $pos['unit'] ?? 'Stk.',
@@ -701,8 +789,6 @@ class ManualInvoiceController extends TTCrud
'matchcode' => $pos['matchcode'] ?? null,
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
'contract_id' => 0,
'billing_id' => null,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
@@ -718,7 +804,11 @@ class ManualInvoiceController extends TTCrud
protected function beforeDelete(): bool {
if ($id = $this->request->id) {
$invoice = ManualInvoiceModel::get($id);
if ($invoice && $invoice->status === 'exported') {
if ($invoice && $invoice->lock == 1) {
$this->infoMessages['delete'] = 'Rechnung ist gesperrt und kann nicht gelöscht werden';
return false;
}
if ($invoice && ($invoice->status === 'exported' || $invoice->status === 'exportiert')) {
$this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden';
return false;
}
@@ -732,4 +822,119 @@ class ManualInvoiceController extends TTCrud
}
return true;
}
protected function getArticleVatInfoAction() {
$articleId = $_GET['article_id'] ?? null;
$vatarea = $_GET['vatarea'] ?? 'domestic';
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Article ID required']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Article not found']);
return;
}
$vatgroupId = $article->vatgroup_id;
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
if (!$vatrate) {
self::returnJson(['success' => false, 'message' => 'Vatrate not found for vatgroup ' . $vatgroupId . ' and area ' . $vatarea]);
return;
}
$prices = [];
if (!empty($article->cheapestSellPrice)) {
$pricesData = json_decode($article->cheapestSellPrice, true);
if (is_array($pricesData)) {
$prices = $pricesData;
}
}
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'title' => $article->title,
'articleNumber' => $article->articleNumber,
'description' => $article->description,
'vatgroup_id' => $article->vatgroup_id,
'unit' => $article->unit
],
'prices' => $prices,
'vatgroup_id' => $vatgroupId,
'fibu_cost_account' => $vatrate->account,
'fibu_cost_account_legacy' => $vatrate->legacy_account,
'fibu_taxcode' => $vatrate->taxcode,
'vatrate' => $vatrate->rate
]);
}
protected function getCustomerBillingInfoAction() {
$addressId = $_GET['address_id'] ?? null;
$vatgroupId = $_GET['vatgroup_id'] ?? 2;
if (!$addressId) {
self::returnJson(['success' => false, 'message' => 'Address ID required']);
return;
}
$address = new Address($addressId);
if (!$address->id) {
self::returnJson(['success' => false, 'message' => 'Address not found']);
return;
}
$vatarea = 'domestic';
if ($address->country_id) {
$country = new Country($address->country_id);
if ($country->id && $country->isocode != TT_HOMECOUNTRY_ISOCODE) {
$vatarea = $country->is_eu ? 'eu' : 'other';
}
}
if ($address->uid && substr(strtolower(preg_replace('/[^a-z0-9]/i', '', $address->uid)), 0, 3) == 'atu') {
$vatarea = 'domestic';
}
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
$taxText = $vatrate ? $vatrate->invoice_text : '';
$db = $this->db();
$sepaLimit = null;
$res = $db->query("SELECT manual_invoice_sepa_limit FROM Address WHERE id = " . intval($addressId));
if ($res && $row = $res->fetch_assoc()) {
$sepaLimit = $row['manual_invoice_sepa_limit'] ? floatval($row['manual_invoice_sepa_limit']) : null;
}
self::returnJson([
'success' => true,
'billing_type' => $address->billing_type ?: 'invoice',
'manual_invoice_sepa_limit' => $sepaLimit,
'vatarea' => $vatarea,
'tax_text' => $taxText,
'bank_account_bank' => $address->bank_account_bank,
'bank_account_owner' => $address->bank_account_owner,
'bank_account_iban' => $address->bank_account_iban,
'bank_account_bic' => $address->bank_account_bic,
'sepa_date' => $address->sepa_date,
'sepa_id' => $address->sepa_id
]);
}
protected function getTaxTextAction() {
$vatgroupId = $_GET['vatgroup_id'] ?? 2;
$vatarea = $_GET['vatarea'] ?? 'domestic';
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
self::returnJson([
'success' => true,
'tax_text' => $vatrate ? $vatrate->invoice_text : '',
'vatrate' => $vatrate ? $vatrate->rate : 20
]);
}
}

View File

@@ -4,10 +4,10 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public int $id;
public ?string $invoice_number;
public int $invoice_date;
public ?string $leistungszeitraum;
public ?string $einleitender_text;
public ?string $externe_referenz;
public float $gesamtrabatt;
public ?string $performance_period;
public ?string $introductory_text;
public ?string $external_reference;
public float $total_discount;
public int $owner_id;
public int $billingaddress_id;
public int $customer_number;
@@ -33,7 +33,6 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public ?string $email;
public ?string $uid;
public string $billing_type;
public string $billing_delivery;
public ?string $bank_account_bank;
public ?string $bank_account_owner;
public ?string $bank_account_iban;
@@ -44,6 +43,8 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public ?int $bmd_export_date;
public ?int $date_delivered;
public string $status;
public int $lock = 0;
public int $exported = 0;
public ?int $credit_for_invoice_id;
public int $create_by;
public int $edit_by;

View File

@@ -4,11 +4,9 @@ class ManualInvoicepositionModel extends TTCrudBaseModel {
public int $id;
public ?int $manualinvoice_id;
public ?string $position_group;
public ?int $billing_id;
public int $contract_id;
public ?string $matchcode;
public int $product_id;
public string $product_name;
public int $warehousearticle_id;
public string $warehousearticle_name;
public ?string $product_info;
public float $amount;
public string $unit;

View File

@@ -0,0 +1,474 @@
<?php
/**
* MobileApp Controller
*
* Main dispatcher for the Mobile PWA application.
*
* URL Structure:
* - /MobileApp → Main app (Vue SPA)
* - /MobileApp/auth/{action} → Auth endpoints (login/logout/check)
* - /MobileApp/{module}/{submodule} → Module view (handled by Vue SPA)
* - /MobileApp/{module}/{submodule}/{action} → API endpoints
*
* Example:
* - /MobileApp → Shows main menu
* - /MobileApp/Lager/Inventur → Shows stocktake (handled by Vue)
* - /MobileApp/Lager/Inventur/getActiveStocktakes → API call
*/
class MobileAppController extends mfBaseController {
protected $user;
protected function init() {
$this->needlogin = false;
$me = mfValuecache::singleton()->get("me");
if (!$me) {
if (mfLoginController::isLoggedIn()) {
$me = new User();
$me->loadMe();
mfValuecache::singleton()->set("me", $me);
}
}
$this->user = $me;
}
/**
* Main dispatcher
*/
public function indexAction() {
$module = $this->request->module ?? null;
$submodule = $this->request->submodule ?? null;
$endpoint = $this->request->endpoint ?? null;
// Auth endpoints: /MobileApp/auth/{action}
if (strtolower($module) === 'auth') {
return $this->handleAuth($submodule ?? 'check');
}
// API call: /MobileApp/{module}/{submodule}/{endpoint}
if ($module && $submodule && $endpoint) {
return $this->handleApiCall($module, $submodule, $endpoint);
}
// Everything else: render the main Vue SPA
// The Vue app handles internal routing for /MobileApp, /MobileApp/Lager, /MobileApp/Lager/Inventur, etc.
return $this->renderApp();
}
/**
* Render the main Vue SPA
*/
protected function renderApp() {
$this->layout()->setTemplate("MobileApp/App");
$this->layout()->set("JSGlobals", [
'BASE_PATH' => '/MobileApp',
'USER' => $this->user ? [
'id' => $this->user->id,
'name' => $this->user->name,
'username' => $this->user->username,
] : null,
'INITIAL_PATH' => $_SERVER['REQUEST_URI'] ?? '/MobileApp',
]);
}
/**
* Handle authentication endpoints
*/
protected function handleAuth($action) {
switch (strtolower($action)) {
case 'login':
return $this->authLogin();
case 'verify2fa':
return $this->authVerify2FA();
case 'resend2fa':
return $this->authResend2FA();
case 'logout':
return $this->authLogout();
case 'check':
return $this->authCheck();
default:
self::returnJson(['success' => false, 'error' => 'Unknown auth endpoint'], 404);
}
}
/**
* POST /MobileApp/auth/login
*
* Step 1 of authentication. If 2FA is required, returns requires2FA: true
* and the frontend should proceed to verify2fa endpoint.
*/
protected function authLogin() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
$username = $postData['username'] ?? '';
$password = $postData['password'] ?? '';
$rememberMe = $postData['rememberMe'] ?? false;
if (!$username || !$password) {
self::returnJson(['success' => false, 'message' => 'Benutzername und Passwort erforderlich']);
return;
}
$db = FronkDB::singleton();
$escapedUsername = $db->escape($username);
$res = $db->select(MFUSERTABLE, "*", "username='$escapedUsername'");
if (!$db->num_rows($res)) {
sleep(1);
self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
return;
}
$userRow = $db->fetch_object($res);
if ($userRow->active == 0) {
self::returnJson(['success' => false, 'message' => 'Benutzer ist deaktiviert']);
return;
}
$hash = $userRow->password;
$salt = substr($hash, 0, 16);
$passhash = mfLoginController::generatePasswordHash($password, $salt);
if ($passhash !== $hash) {
sleep(1);
self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
return;
}
// Check if 2FA is required
if ($userRow->twofactor !== "0") {
// Generate and send 2FA code
$twoFactor = new UserTwofactor($userRow->id);
$twoFactor->sendCode();
// Store pending auth in session for 2FA verification
$_SESSION['mobileapp_2fa_pending'] = [
'user_id' => $userRow->id,
'username' => $userRow->username,
'remember_me' => $rememberMe,
'timestamp' => time()
];
// Determine delivery method for UI feedback
$deliveryMethod = $userRow->twofactor == 1 ? 'email' : 'sms';
$maskedTarget = $deliveryMethod === 'email'
? $this->maskEmail($userRow->email)
: $this->maskPhone($userRow->mobile);
self::returnJson([
'success' => false,
'requires2FA' => true,
'deliveryMethod' => $deliveryMethod,
'maskedTarget' => $maskedTarget,
'message' => 'Verifizierungscode wurde gesendet'
]);
return;
}
// No 2FA - complete login directly
$this->completeLogin($userRow, $rememberMe);
}
/**
* POST /MobileApp/auth/verify2fa
*
* Step 2 of authentication - verify the 2FA code
*/
protected function authVerify2FA() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
$code = $postData['code'] ?? '';
// Check for pending 2FA session
if (!isset($_SESSION['mobileapp_2fa_pending'])) {
self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
return;
}
$pending = $_SESSION['mobileapp_2fa_pending'];
// Check if pending session is expired (10 minutes max)
if (time() - $pending['timestamp'] > 600) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
return;
}
if (!$code || strlen($code) !== 5) {
self::returnJson(['success' => false, 'message' => 'Bitte gib den 5-stelligen Code ein']);
return;
}
$db = FronkDB::singleton();
$userId = intval($pending['user_id']);
// Get user's 2FA code and timestamp
$res = $db->select(MFUSERTABLE, "twofactorcode, twofactortimestamp, username", "id = {$userId}");
if (!$db->num_rows($res)) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Benutzer nicht gefunden']);
return;
}
$userRow = $db->fetch_object($res);
$storedCode = $userRow->twofactorcode;
$codeTimestamp = intval($userRow->twofactortimestamp);
// Check if code is expired (5 minutes)
if (time() - $codeTimestamp > 300) {
self::returnJson(['success' => false, 'message' => 'Code abgelaufen. Bitte neuen Code anfordern.', 'codeExpired' => true]);
return;
}
// Verify code
if ($code !== $storedCode) {
sleep(1); // Rate limiting
self::returnJson(['success' => false, 'message' => 'Ungültiger Code']);
return;
}
// Clear the 2FA code
$twoFactor = new UserTwofactor($userId);
$twoFactor->removeCode();
// Clear pending session
unset($_SESSION['mobileapp_2fa_pending']);
// Get full user row for login completion
$res = $db->select(MFUSERTABLE, "*", "id = {$userId}");
$userRow = $db->fetch_object($res);
// Complete login
$this->completeLogin($userRow, $pending['remember_me']);
}
/**
* POST /MobileApp/auth/resend2fa
*
* Resend the 2FA code
*/
protected function authResend2FA() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
// Check for pending 2FA session
if (!isset($_SESSION['mobileapp_2fa_pending'])) {
self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
return;
}
$pending = $_SESSION['mobileapp_2fa_pending'];
// Check if pending session is expired (10 minutes max)
if (time() - $pending['timestamp'] > 600) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
return;
}
// Resend 2FA code
$twoFactor = new UserTwofactor($pending['user_id']);
$twoFactor->sendCode();
self::returnJson([
'success' => true,
'message' => 'Neuer Code wurde gesendet'
]);
}
/**
* Complete the login process after password (and optionally 2FA) verification
*/
protected function completeLogin($userRow, $rememberMe) {
$db = FronkDB::singleton();
$db->update(MFUSERTABLE, [
'ip' => $_SERVER['REMOTE_ADDR'],
'sessionid' => session_id()
], "id = {$userRow->id}");
$_SESSION[MFAPPNAME . '_username'] = $userRow->username;
$_SESSION[MFAPPNAME . '_ip'] = $_SERVER['REMOTE_ADDR'];
if ($rememberMe) {
UserToken::generateToken($userRow->id);
}
$user = new User();
$user->loadMe();
self::returnJson([
'success' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
}
/**
* Mask email address for privacy (e.g., j***@example.com)
*/
protected function maskEmail($email) {
if (!$email) return '***';
$parts = explode('@', $email);
if (count($parts) !== 2) return '***';
$local = $parts[0];
$domain = $parts[1];
$masked = strlen($local) > 1 ? $local[0] . str_repeat('*', min(5, strlen($local) - 1)) : '*';
return $masked . '@' . $domain;
}
/**
* Mask phone number for privacy (e.g., +43***123)
*/
protected function maskPhone($phone) {
if (!$phone) return '***';
$phone = preg_replace('/\s+/', '', $phone);
if (strlen($phone) < 6) return '***';
return substr($phone, 0, 3) . str_repeat('*', strlen($phone) - 6) . substr($phone, -3);
}
/**
* POST /MobileApp/auth/logout
*/
protected function authLogout() {
mfLoginController::staticLogout();
self::returnJson(['success' => true]);
}
/**
* GET /MobileApp/auth/check
*/
protected function authCheck() {
if (mfLoginController::isLoggedIn()) {
$user = new User();
$user->loadMe();
if ($user->id) {
self::returnJson([
'authenticated' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
return;
}
}
UserToken::checkToken();
if (isset($_SESSION[MFAPPNAME . '_username']) && $_SESSION[MFAPPNAME . '_username']) {
$user = new User();
$user->loadMe();
if ($user->id) {
self::returnJson([
'authenticated' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
return;
}
}
self::returnJson(['authenticated' => false]);
}
/**
* Handle API calls to module endpoints
* /MobileApp/{module}/{submodule}/{endpoint}
*/
protected function handleApiCall($module, $submodule, $endpoint) {
// Check authentication for API calls
if (!$this->user || !$this->user->id) {
self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401);
return;
}
// Find module directory (case-insensitive)
$moduleName = $this->findModuleDirectory(APPDIR . "MobileApp/Modules", $module);
if (!$moduleName) {
self::returnJson(['success' => false, 'error' => "Module not found: {$module}"], 404);
return;
}
// Find submodule directory (case-insensitive)
$submoduleName = $this->findModuleDirectory(APPDIR . "MobileApp/Modules/{$moduleName}", $submodule);
if (!$submoduleName) {
self::returnJson(['success' => false, 'error' => "Submodule not found: {$module}/{$submodule}"], 404);
return;
}
// Build handler path
$handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php";
if (!file_exists($handlerFile)) {
self::returnJson(['success' => false, 'error' => "Handler not found: {$moduleName}/{$submoduleName}"], 404);
return;
}
require_once $handlerFile;
$handlerClass = "{$submoduleName}Handler";
if (!class_exists($handlerClass)) {
self::returnJson(['success' => false, 'error' => "Handler class not found"], 500);
return;
}
$handler = new $handlerClass($this->request, $this->user, $this);
// Check permissions
if (!$handler->checkPermission()) {
self::returnJson(['success' => false, 'error' => 'Permission denied'], 403);
return;
}
// Route to method
$method = $endpoint . 'Action';
if (method_exists($handler, $method)) {
return $handler->$method();
}
if (method_exists($handler, $endpoint)) {
return $handler->$endpoint();
}
self::returnJson(['success' => false, 'error' => "Endpoint not found: {$endpoint}"], 404);
}
/**
* Find directory with case-insensitive matching
* Required for Linux compatibility where filesystem is case-sensitive
*/
protected function findModuleDirectory($basePath, $name) {
if (!is_dir($basePath)) return null;
$dirs = scandir($basePath);
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') continue;
if (strtolower($dir) === strtolower($name) && is_dir($basePath . '/' . $dir)) {
return $dir;
}
}
return null;
}
}

View File

@@ -0,0 +1,409 @@
<?php
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
class InventurHandler extends MobileAppBaseHandler {
protected $requiredPermission = 'WarehouseUser';
public function getActiveStocktakesAction() {
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
$result = [];
foreach ($stocktakes as $stocktake) {
$location = $stocktake->getLocation();
$result[] = [
'id' => $stocktake->id,
'stocktakeNumber' => $stocktake->stocktakeNumber,
'title' => $stocktake->title,
'locationName' => $location ? $location->title : 'Unbekannt',
'totalScannedItems' => $stocktake->totalScannedItems,
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
];
}
self::returnJson(['success' => true, 'stocktakes' => $result]);
}
public function getStocktakeAction() {
$id = intval($this->request->id);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($id);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
$location = $stocktake->getLocation();
self::returnJson([
'success' => true,
'stocktake' => [
'id' => $stocktake->id,
'stocktakeNumber' => $stocktake->stocktakeNumber,
'title' => $stocktake->title,
'status' => $stocktake->status,
'locationId' => $stocktake->warehouseLocationId,
'locationName' => $location ? $location->title : 'Unbekannt',
'totalScannedItems' => $stocktake->totalScannedItems,
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
]
]);
}
public function getArticleAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
$articleId = null;
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]);
} else {
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
if ($article) {
$articleId = $article->id;
}
}
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$category = WarehouseCategory::get($article->category_id);
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'articleNumber' => $article->articleNumber,
'title' => $article->title,
'description' => $article->description ?? '',
'unit' => $article->unit ?? 'Stk.',
'categoryName' => $category ? $category->name : '',
]
]);
}
public function searchArticlesAction() {
$query = $this->request->query ?? '';
$categoryId = intval($this->request->categoryId ?? 0);
$db = $this->db();
$conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
if ($query && strlen($query) >= 2) {
$escapedQuery = $db->escape($query);
$conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
}
if ($categoryId > 0) {
$conditions[] = "category_id = {$categoryId}";
}
if (count($conditions) === 1 && !$categoryId) {
self::returnJson(['success' => true, 'articles' => []]);
return;
}
$whereClause = implode(' AND ', $conditions);
$result = $db->query("SELECT id, articleNumber, title, unit, category_id
FROM WarehouseArticle
WHERE {$whereClause}
ORDER BY title ASC
LIMIT 50");
$articles = [];
while ($row = $result->fetch_assoc()) {
$articles[] = [
'id' => intval($row['id']),
'articleNumber' => $row['articleNumber'],
'title' => $row['title'],
'unit' => $row['unit'] ?? 'Stk.',
'categoryId' => intval($row['category_id'] ?? 0),
];
}
self::returnJson(['success' => true, 'articles' => $articles]);
}
public function getCategoriesAction() {
$db = $this->db();
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
$categories = [];
while ($row = $res->fetch_assoc()) {
$categories[] = [
'id' => intval($row['id']),
'name' => $row['name'],
];
}
self::returnJson(['success' => true, 'categories' => $categories]);
}
public function checkAlreadyScannedAction() {
$stocktakeId = intval($this->request->stocktakeId);
$articleId = intval($this->request->articleId);
if (!$stocktakeId || !$articleId) {
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
return;
}
$existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId,
'articleId' => $articleId,
'overwrittenById' => null
]);
if ($existing) {
$db = $this->db();
$scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
$scannedByRow = $scannedByResult->fetch_assoc();
self::returnJson([
'success' => true,
'alreadyScanned' => true,
'existingItem' => [
'id' => $existing->id,
'countedQuantity' => $existing->countedQuantity,
'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
]
]);
} else {
self::returnJson(['success' => true, 'alreadyScanned' => false]);
}
}
public function submitScanAction() {
$postData = $this->getPostData();
$stocktakeId = intval($postData['stocktakeId'] ?? 0);
$articleId = intval($postData['articleId'] ?? 0);
$quantity = floatval($postData['quantity'] ?? 0);
$rack = $postData['rack'] ?? null;
$shelf = $postData['shelf'] ?? null;
$note = $postData['note'] ?? null;
$overwrite = boolval($postData['overwrite'] ?? false);
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
if (!$stocktakeId || !$articleId) {
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
return;
}
if ($quantity <= 0) {
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
return;
}
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
if ($stocktake->status !== 'in_progress') {
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$db = $this->db();
if ($overwrite && $overwriteItemId) {
$db->query("INSERT INTO WarehouseStocktakeItem
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
$itemId = $db->insert_id;
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
$finalQuantity = $quantity;
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'overwrittenItemId' => $overwriteItemId,
]);
$stocktake->updateProgress();
self::returnJson([
'success' => true,
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
'item' => [
'id' => $itemId,
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'countedQuantity' => $finalQuantity,
'unit' => $article->unit ?? 'Stk.',
'rack' => $rack,
'shelf' => $shelf,
'isOverwrite' => true,
]
]);
return;
}
$existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId,
'articleId' => $articleId,
'overwrittenById' => null
]);
if ($existing) {
$newQuantity = $existing->countedQuantity + $quantity;
$db->query("UPDATE WarehouseStocktakeItem SET
countedQuantity = {$newQuantity},
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
scannedAt = " . time() . ",
scannedBy = {$this->user->id}
WHERE id = {$existing->id}");
$itemId = $existing->id;
$finalQuantity = $newQuantity;
$isUpdate = true;
} else {
$db->query("INSERT INTO WarehouseStocktakeItem
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
$itemId = $db->insert_id;
$finalQuantity = $quantity;
$isUpdate = false;
}
$stocktake->updateProgress();
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'totalQuantity' => $finalQuantity,
'rack' => $rack,
'shelf' => $shelf,
'isUpdate' => $isUpdate,
]);
self::returnJson([
'success' => true,
'message' => $isUpdate
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
'item' => [
'id' => $itemId,
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'countedQuantity' => $finalQuantity,
'unit' => $article->unit ?? 'Stk.',
'rack' => $rack,
'shelf' => $shelf,
'isUpdate' => $isUpdate,
]
]);
}
public function getMyScansAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$db = $this->db();
$result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
FROM WarehouseStocktakeItem si
JOIN WarehouseArticle wa ON wa.id = si.articleId
WHERE si.stocktakeId = {$stocktakeId}
AND si.scannedBy = {$this->user->id}
ORDER BY si.scannedAt DESC
LIMIT 50");
$items = [];
while ($row = $result->fetch_assoc()) {
$items[] = [
'id' => intval($row['id']),
'articleId' => intval($row['articleId']),
'articleNumber' => $row['articleNumber'],
'articleTitle' => $row['articleTitle'],
'countedQuantity' => floatval($row['countedQuantity']),
'unit' => $row['unit'] ?? 'Stk.',
'rack' => $row['rack'],
'shelf' => $row['shelf'],
'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
];
}
self::returnJson(['success' => true, 'items' => $items]);
}
public function getProgressAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
$db = $this->db();
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
$totalRow = $totalResult->fetch_assoc();
$totalScanned = intval($totalRow['count']);
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
$myRow = $myResult->fetch_assoc();
$myScanned = intval($myRow['count']);
self::returnJson([
'success' => true,
'progress' => [
'totalScanned' => $totalScanned,
'myScanned' => $myScanned,
'status' => $stocktake->status,
]
]);
}
}

View File

@@ -0,0 +1,581 @@
<?php
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
class MovementHandler extends MobileAppBaseHandler {
protected $requiredPermission = 'WarehouseUser';
public function getLocationsAction() {
$allLocations = WarehouseLocationModel::getAll();
$locations = [];
foreach ($allLocations as $location) {
$title = strtolower($location->title);
if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') {
$locations[] = [
'id' => $location->id,
'title' => $location->title,
];
}
}
self::returnJson(['success' => true, 'locations' => $locations]);
}
public function getArticleAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
$articleId = null;
// Check for QR code format WA:ID: or WH:ID:
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]);
} else {
// Try to find by article number
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
if ($article) {
$articleId = $article->id;
}
}
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$category = WarehouseCategory::get($article->category_id);
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'articleNumber' => $article->articleNumber,
'title' => $article->title,
'description' => $article->description ?? '',
'unit' => $article->unit ?? 'Stk.',
'categoryName' => $category ? $category->name : '',
]
]);
}
public function searchArticlesAction() {
$query = $this->request->query ?? '';
$db = $this->db();
$conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
if ($query && strlen($query) >= 2) {
$escapedQuery = $db->escape($query);
$conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
} else {
self::returnJson(['success' => true, 'articles' => []]);
return;
}
$whereClause = implode(' AND ', $conditions);
$result = $db->query("SELECT id, articleNumber, title, unit, category_id
FROM WarehouseArticle
WHERE {$whereClause}
ORDER BY title ASC
LIMIT 50");
$articles = [];
while ($row = $result->fetch_assoc()) {
$articles[] = [
'id' => intval($row['id']),
'articleNumber' => $row['articleNumber'],
'title' => $row['title'],
'unit' => $row['unit'] ?? 'Stk.',
];
}
self::returnJson(['success' => true, 'articles' => $articles]);
}
public function getReasonCategoriesAction() {
$type = $this->request->type ?? null;
$categories = WarehouseMovementModel::getReasonCategories($type);
if ($type && is_array($categories)) {
$items = [];
foreach ($categories as $key => $label) {
$items[] = ['value' => $key, 'text' => $label];
}
self::returnJson(['success' => true, 'categories' => $items]);
} else {
self::returnJson(['success' => true, 'categories' => $categories]);
}
}
public function getCurrentStockAction() {
$articleId = intval($this->request->articleId ?? 0);
$locationId = intval($this->request->locationId ?? 0);
if (!$articleId || !$locationId) {
self::returnJson(['success' => true, 'currentStock' => 0]);
return;
}
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0;
self::returnJson(['success' => true, 'currentStock' => $currentStock]);
}
public function submitMovementAction() {
$postData = $this->getPostData();
$movementType = $postData['movementType'] ?? '';
$articleId = intval($postData['articleId'] ?? 0);
$locationId = intval($postData['locationId'] ?? 0);
$quantity = floatval($postData['quantity'] ?? 0);
$reasonCategory = $postData['reasonCategory'] ?? '';
$note = $postData['note'] ?? null;
// Validate required fields
if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) {
self::returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']);
return;
}
if ($articleId <= 0) {
self::returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']);
return;
}
if ($locationId <= 0) {
self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
return;
}
if ($quantity <= 0) {
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
return;
}
if (empty($reasonCategory)) {
self::returnJson(['success' => false, 'message' => 'Bitte Grund auswählen']);
return;
}
// Get article info
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$db = $this->db();
// Find or create WarehouseItem for this article at this location
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
$currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
// Calculate new quantity based on movement type
// Note: Negative stock is allowed (items can be taken out even if stock is 0)
switch ($movementType) {
case 'IN':
$newQty = $currentQty + $quantity;
break;
case 'OUT':
$newQty = $currentQty - $quantity;
// Negative stock is allowed - no validation needed
break;
case 'ADJUSTMENT':
// For adjustment, quantity is the new absolute value
$newQty = $quantity;
break;
default:
$newQty = $currentQty;
}
// Update or create WarehouseItem
$warehouseItemId = null;
if ($warehouseItem) {
$db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
$warehouseItemId = $warehouseItem->id;
} else {
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
$warehouseItemId = $db->insert_id;
}
// Create the movement record
$noteEscaped = $note ? "'" . $db->escape($note) . "'" : "NULL";
$db->query("INSERT INTO WarehouseMovement
(movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, note, userId, createBy, `create`)
VALUES ('{$movementType}', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, '{$db->escape($reasonCategory)}', {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")");
$movementId = $db->insert_id;
// Generate movement number
$movementNumber = WarehouseMovementModel::generateMovementNumber();
$db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}");
// Get type label for message
$typeLabels = ['IN' => 'Einbuchung', 'OUT' => 'Ausbuchung', 'ADJUSTMENT' => 'Korrektur'];
$typeLabel = $typeLabels[$movementType] ?? $movementType;
self::returnJson([
'success' => true,
'message' => "{$typeLabel} erfolgreich: {$quantity} x {$article->title}",
'movement' => [
'id' => $movementId,
'movementNumber' => $movementNumber,
'movementType' => $movementType,
'articleId' => $articleId,
'articleTitle' => $article->title,
'quantity' => $quantity,
'quantityBefore' => $currentQty,
'quantityAfter' => $newQty,
]
]);
}
public function getMyMovementsAction() {
$locationId = intval($this->request->locationId ?? 0);
$limit = intval($this->request->limit ?? 20);
$db = $this->db();
$whereClause = "m.userId = {$this->user->id}";
if ($locationId > 0) {
$whereClause .= " AND m.warehouseLocationId = {$locationId}";
}
$result = $db->query("SELECT m.*, wa.articleNumber, wa.title as articleTitle, wa.unit, wl.title as locationTitle
FROM WarehouseMovement m
LEFT JOIN WarehouseArticle wa ON wa.id = m.articleId
LEFT JOIN WarehouseLocation wl ON wl.id = m.warehouseLocationId
WHERE {$whereClause}
ORDER BY m.`create` DESC
LIMIT {$limit}");
$movements = [];
while ($row = $result->fetch_assoc()) {
$movements[] = [
'id' => intval($row['id']),
'movementNumber' => $row['movementNumber'],
'movementType' => $row['movementType'],
'articleId' => intval($row['articleId']),
'articleNumber' => $row['articleNumber'],
'articleTitle' => $row['articleTitle'],
'unit' => $row['unit'] ?? 'Stk.',
'locationTitle' => $row['locationTitle'],
'quantity' => floatval($row['quantity']),
'quantityBefore' => floatval($row['quantityBefore']),
'quantityAfter' => floatval($row['quantityAfter']),
'reasonCategory' => $row['reasonCategory'],
'note' => $row['note'],
'create' => date('d.m.Y H:i', $row['create']),
];
}
self::returnJson(['success' => true, 'movements' => $movements]);
}
public function getMovementTypesAction() {
$types = [
['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'plus-circle', 'color' => 'green'],
['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'minus-circle', 'color' => 'red'],
['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'edit', 'color' => 'yellow'],
];
self::returnJson(['success' => true, 'types' => $types]);
}
public function getPendingOrdersAction() {
$db = $this->db();
$result = $db->query("SELECT wo.*, wd.name as distributorName
FROM WarehouseOrder wo
LEFT JOIN WarehouseDistributor wd ON wd.id = wo.distributorId
WHERE wo.status IN ('sent', 'partiallyDelivered')
ORDER BY wo.`create` DESC");
$orders = [];
while ($row = $result->fetch_assoc()) {
$positions = json_decode($row['positions'], true) ?: [];
$totalItems = array_sum(array_column($positions, 'amount'));
// Calculate days since sent
$daysSinceSent = 0;
if (!empty($row['create'])) {
$daysSinceSent = floor((time() - intval($row['create'])) / 86400);
}
$orders[] = [
'id' => intval($row['id']),
'orderNumber' => $row['orderNumber'],
'distributorName' => $row['distributorName'] ?? 'Unbekannt',
'status' => $row['status'],
'statusLabel' => $row['status'] === 'sent' ? 'Versendet' : 'Teilweise geliefert',
'totalItems' => $totalItems,
'positionCount' => count($positions),
'daysSinceSent' => $daysSinceSent,
'create' => date('d.m.Y', $row['create']),
];
}
self::returnJson(['success' => true, 'orders' => $orders]);
}
public function getOrderForReceivingAction() {
$orderId = intval($this->request->orderId ?? 0);
if ($orderId <= 0) {
self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']);
return;
}
$order = WarehouseOrderModel::get($orderId);
if (!$order) {
self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']);
return;
}
if (!in_array($order->status, ['sent', 'partiallyDelivered'])) {
self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']);
return;
}
$distributor = WarehouseDistributorModel::get($order->distributorId);
$positions = json_decode($order->positions, true) ?: [];
// Get already delivered quantities from linked movements
$linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
$deliveredByArticle = [];
foreach ($linkedMovementIds as $movementId) {
$movement = WarehouseMovementModel::get($movementId);
if ($movement && $movement->movementType === 'IN') {
if (!isset($deliveredByArticle[$movement->articleId])) {
$deliveredByArticle[$movement->articleId] = 0;
}
$deliveredByArticle[$movement->articleId] += $movement->quantity;
}
}
// Enrich positions with article details and delivered quantities
$enrichedPositions = [];
foreach ($positions as $index => $pos) {
$articleId = intval($pos['article']);
$article = WarehouseArticleModel::get($articleId);
$orderedQty = floatval($pos['amount']);
$deliveredQty = $deliveredByArticle[$articleId] ?? 0;
$remainingQty = max(0, $orderedQty - $deliveredQty);
$enrichedPositions[] = [
'index' => $index,
'articleId' => $articleId,
'articleNumber' => $article ? $article->articleNumber : '',
'articleTitle' => $article ? $article->title : ($pos['article_text'] ?? 'Unbekannt'),
'unit' => $article ? ($article->unit ?? 'Stk.') : 'Stk.',
'orderedQty' => $orderedQty,
'deliveredQty' => $deliveredQty,
'remainingQty' => $remainingQty,
'receivingQty' => $remainingQty, // Default to remaining
];
}
self::returnJson([
'success' => true,
'order' => [
'id' => $order->id,
'orderNumber' => $order->orderNumber,
'distributorName' => $distributor ? $distributor->name : 'Unbekannt',
'status' => $order->status,
'note' => $order->note,
'create' => date('d.m.Y H:i', $order->create),
],
'positions' => $enrichedPositions
]);
}
public function submitOrderReceivingAction() {
$postData = $this->getPostData();
$orderId = intval($postData['orderId'] ?? 0);
$locationId = intval($postData['locationId'] ?? 0);
$positions = $postData['positions'] ?? [];
$deliveryNoteFileId = $postData['deliveryNoteFileId'] ?? null;
$note = $postData['note'] ?? null;
// Validation
if ($orderId <= 0) {
self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']);
return;
}
if ($locationId <= 0) {
self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
return;
}
if (empty($positions)) {
self::returnJson(['success' => false, 'message' => 'Keine Positionen angegeben']);
return;
}
$order = WarehouseOrderModel::get($orderId);
if (!$order) {
self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']);
return;
}
if (!in_array($order->status, ['sent', 'partiallyDelivered'])) {
self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']);
return;
}
$db = $this->db();
$createdMovementIds = [];
$totalReceived = 0;
// Create movements for each position with quantity > 0
foreach ($positions as $pos) {
$articleId = intval($pos['articleId'] ?? 0);
$quantity = floatval($pos['quantity'] ?? 0);
if ($articleId <= 0 || $quantity <= 0) {
continue;
}
// Find or create WarehouseItem
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
$currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
$newQty = $currentQty + $quantity;
// Update or create WarehouseItem
if ($warehouseItem) {
$db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
$warehouseItemId = $warehouseItem->id;
} else {
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
$warehouseItemId = $db->insert_id;
}
// Create movement record
$movementNote = "Lagereingang aus Bestellung {$order->orderNumber}";
if ($note) {
$movementNote .= " - " . $note;
}
$noteEscaped = "'" . $db->escape($movementNote) . "'";
$db->query("INSERT INTO WarehouseMovement
(movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, linkedOrderId, note, userId, createBy, `create`)
VALUES ('IN', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, 'Warenlieferung', {$orderId}, {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")");
$movementId = $db->insert_id;
// Generate movement number
$movementNumber = WarehouseMovementModel::generateMovementNumber();
$db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}");
$createdMovementIds[] = $movementId;
$totalReceived += $quantity;
}
if (empty($createdMovementIds)) {
self::returnJson(['success' => false, 'message' => 'Keine Mengen eingegeben']);
return;
}
// Update order with linked movement IDs
$existingMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
$allMovementIds = array_merge($existingMovementIds, $createdMovementIds);
// Update delivery note file IDs if provided
$existingFileIds = $order->deliveryNoteFileIds ? json_decode($order->deliveryNoteFileIds, true) : [];
if ($deliveryNoteFileId) {
$existingFileIds[] = $deliveryNoteFileId;
}
// Determine new status - check if all items are now fully delivered
$orderPositions = json_decode($order->positions, true) ?: [];
$allFullyDelivered = true;
// Get all delivered quantities including new ones
$deliveredByArticle = [];
foreach ($allMovementIds as $movementId) {
$movement = WarehouseMovementModel::get($movementId);
if ($movement && $movement->movementType === 'IN') {
if (!isset($deliveredByArticle[$movement->articleId])) {
$deliveredByArticle[$movement->articleId] = 0;
}
$deliveredByArticle[$movement->articleId] += $movement->quantity;
}
}
foreach ($orderPositions as $pos) {
$articleId = intval($pos['article']);
$orderedQty = floatval($pos['amount']);
$deliveredQty = $deliveredByArticle[$articleId] ?? 0;
if ($deliveredQty < $orderedQty) {
$allFullyDelivered = false;
break;
}
}
$newStatus = $allFullyDelivered ? 'fullyDelivered' : 'partiallyDelivered';
// Update order
$orderAsArray = (array)$order;
$orderAsArray['linkedMovementIds'] = json_encode($allMovementIds);
$orderAsArray['deliveryNoteFileIds'] = json_encode($existingFileIds);
$orderAsArray['status'] = $newStatus;
WarehouseOrderModel::update($orderAsArray);
// Create log entry
$logMessage = count($createdMovementIds) . " Lagerbewegung(en) erstellt via Mobile App.";
if ($note) {
$logMessage .= "\n" . $note;
}
WarehouseLogModel::create([
'table' => 'WarehouseOrder',
'rowId' => $orderId,
'type' => 'statusChange',
'message' => "Status geändert auf " . ($newStatus === 'fullyDelivered' ? 'Geliefert' : 'Teilweise geliefert') . ".\n" . $logMessage,
'createBy' => $this->user->id,
'create' => time()
]);
self::returnJson([
'success' => true,
'message' => "{$totalReceived} Artikel empfangen. " . count($createdMovementIds) . " Lagerbewegung(en) erstellt.",
'newStatus' => $newStatus,
'createdMovementIds' => $createdMovementIds
]);
}
}

View File

@@ -0,0 +1,821 @@
<?php
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
/**
* ShippingNote (Lieferschein) Handler
*
* Handles all endpoints for the Lager > ShippingNote module.
* API Base: /MobileApp/Lager/ShippingNote/{action}
*/
class ShippingNoteHandler extends MobileAppBaseHandler {
protected $requiredPermission = 'WarehouseUser';
// Office coordinates for distance calculation
const OFFICE_LAT = 46.99552810791587;
const OFFICE_LNG = 15.7751923956463;
public function initializeAction() {
$db = $this->db();
$userId = $this->user->id;
$userCar = null;
$sql = "SELECT id, number_plate, brand, model
FROM TimerecordingCar
WHERE user_id = {$userId}
AND (retired IS NULL OR retired = 0)
LIMIT 1";
$result = $db->query($sql);
if ($result && $row = $result->fetch_assoc()) {
$carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
if (!$carName) $carName = $row['number_plate'];
$userCar = [
'id' => intval($row['id']),
'name' => $carName,
'plate' => $row['number_plate'],
];
}
$allCars = [];
$sql = "SELECT id, number_plate, brand, model
FROM TimerecordingCar
WHERE (retired IS NULL OR retired = 0)
ORDER BY brand, model ASC";
$result = $db->query($sql);
while ($row = $result->fetch_assoc()) {
$carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
if (!$carName) $carName = $row['number_plate'];
$allCars[] = [
'id' => intval($row['id']),
'name' => $carName,
'plate' => $row['number_plate'],
];
}
$hourTypes = [
['id' => '', 'name' => 'Normal'],
['id' => '50', 'name' => '+50%'],
['id' => '100', 'name' => '+100%'],
['id' => 'regie', 'name' => 'Regie'],
];
$currentUser = [
'id' => $this->user->id,
'name' => $this->user->name,
'firstname' => $this->user->firstname ?? '',
'lastname' => $this->user->lastname ?? '',
];
self::returnJson([
'success' => true,
'userCar' => $userCar,
'allCars' => $allCars,
'hourTypes' => $hourTypes,
'currentUser' => $currentUser,
]);
}
/**
* Get customer by GPS location (nearest within radius)
* GET /MobileApp/Lager/ShippingNote/getCustomerByLocation?lat=X&lng=Y
*/
public function getCustomerByLocationAction() {
$lat = floatval($this->request->lat ?? 0);
$lng = floatval($this->request->lng ?? 0);
$radius = intval($this->request->radius ?? 200); // default 200 meters
if (!$lat || !$lng) {
self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
return;
}
$db = $this->db();
// Haversine formula for distance in meters
$sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long,
(6371000 * acos(
cos(radians({$lat})) * cos(radians(gps_lat)) *
cos(radians(gps_long) - radians({$lng})) +
sin(radians({$lat})) * sin(radians(gps_lat))
)) AS distance
FROM Address
WHERE gps_lat IS NOT NULL
AND gps_long IS NOT NULL
AND customer_number > 0
HAVING distance < {$radius}
ORDER BY distance ASC
LIMIT 1";
$result = $db->query($sql);
if ($result && $row = $result->fetch_assoc()) {
// Build display name
$displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']);
self::returnJson([
'success' => true,
'found' => true,
'customer' => [
'id' => intval($row['id']),
'customerNumber' => $row['customer_number'],
'displayName' => $displayName,
'company' => $row['company'],
'firstname' => $row['firstname'],
'lastname' => $row['lastname'],
'street' => $row['street'],
'zip' => $row['zip'],
'city' => $row['city'],
'email' => $row['email'],
'phone' => $row['phone'],
'distance' => round(floatval($row['distance'])),
]
]);
} else {
self::returnJson([
'success' => true,
'found' => false,
'message' => 'Kein Kunde in der Nähe gefunden'
]);
}
}
/**
* Reverse geocode coordinates to address
* GET /MobileApp/Lager/ShippingNote/reverseGeocode?lat=X&lng=Y
*/
public function reverseGeocodeAction() {
$lat = floatval($this->request->lat ?? 0);
$lng = floatval($this->request->lng ?? 0);
if (!$lat || !$lng) {
self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
return;
}
// Use Google Maps Geocoding API
$apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : '';
if (!$apiKey) {
self::returnJson(['success' => false, 'message' => 'Google Maps API nicht konfiguriert']);
return;
}
$url = "https://maps.googleapis.com/maps/api/geocode/json?latlng={$lat},{$lng}&key={$apiKey}&language=de";
$response = @file_get_contents($url);
if (!$response) {
self::returnJson(['success' => false, 'message' => 'Geocoding fehlgeschlagen']);
return;
}
$data = json_decode($response, true);
if ($data['status'] !== 'OK' || empty($data['results'])) {
self::returnJson(['success' => false, 'message' => 'Keine Adresse gefunden']);
return;
}
// Parse address components
$result = $data['results'][0];
$components = $result['address_components'];
$street = '';
$streetNumber = '';
$zip = '';
$city = '';
foreach ($components as $comp) {
if (in_array('route', $comp['types'])) {
$street = $comp['long_name'];
}
if (in_array('street_number', $comp['types'])) {
$streetNumber = $comp['long_name'];
}
if (in_array('postal_code', $comp['types'])) {
$zip = $comp['long_name'];
}
if (in_array('locality', $comp['types'])) {
$city = $comp['long_name'];
}
}
$fullStreet = trim($street . ' ' . $streetNumber);
self::returnJson([
'success' => true,
'address' => [
'street' => $fullStreet,
'zip' => $zip,
'city' => $city,
'formatted' => $result['formatted_address'] ?? '',
]
]);
}
/**
* Search customers by name/company
* GET /MobileApp/Lager/ShippingNote/searchCustomers?query=X
*/
public function searchCustomersAction() {
$query = trim($this->request->query ?? '');
if (strlen($query) < 1) {
self::returnJson(['success' => true, 'customers' => []]);
return;
}
$db = $this->db();
// Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst")
$words = preg_split('/\s+/', trim($query));
$wordConditions = [];
foreach ($words as $word) {
if (strlen($word) < 1) continue;
$escapedWord = $db->escape($word);
$wordConditions[] = "(company LIKE '%{$escapedWord}%'
OR firstname LIKE '%{$escapedWord}%'
OR lastname LIKE '%{$escapedWord}%'
OR customer_number LIKE '%{$escapedWord}%')";
}
if (empty($wordConditions)) {
self::returnJson(['success' => true, 'customers' => []]);
return;
}
$whereClause = implode(' AND ', $wordConditions);
$sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long
FROM Address
WHERE customer_number > 0
AND ({$whereClause})
ORDER BY company, lastname, firstname
LIMIT 20";
$result = $db->query($sql);
$customers = [];
while ($row = $result->fetch_assoc()) {
$displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']);
$customers[] = [
'id' => intval($row['id']),
'customerNumber' => $row['customer_number'],
'displayName' => $displayName,
'company' => $row['company'],
'firstname' => $row['firstname'],
'lastname' => $row['lastname'],
'street' => $row['street'],
'zip' => $row['zip'],
'city' => $row['city'],
'email' => $row['email'],
'phone' => $row['phone'],
'gpsLat' => $row['gps_lat'] ? floatval($row['gps_lat']) : null,
'gpsLong' => $row['gps_long'] ? floatval($row['gps_long']) : null,
];
}
self::returnJson(['success' => true, 'customers' => $customers]);
}
/**
* Search articles
* GET /MobileApp/Lager/ShippingNote/searchArticles?query=X
*/
public function searchArticlesAction() {
$query = trim($this->request->query ?? '');
if (strlen($query) < 1) {
self::returnJson(['success' => true, 'articles' => []]);
return;
}
$db = $this->db();
$escapedQuery = $db->escape($query);
$sql = "SELECT id, articleNumber, title, unit
FROM WarehouseArticle
WHERE (isEndOfLife IS NULL OR isEndOfLife = 0)
AND (articleNumber LIKE '%{$escapedQuery}%'
OR title LIKE '%{$escapedQuery}%')
ORDER BY title ASC
LIMIT 30";
$result = $db->query($sql);
$articles = [];
while ($row = $result->fetch_assoc()) {
$articles[] = [
'id' => intval($row['id']),
'articleNumber' => $row['articleNumber'],
'title' => $row['title'],
'unit' => $row['unit'] ?? 'Stk.',
];
}
self::returnJson(['success' => true, 'articles' => $articles]);
}
/**
* Get article by QR code or article number
* GET /MobileApp/Lager/ShippingNote/getArticle?code=X
*/
public function getArticleAction() {
$code = $this->request->code ?? '';
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
$articleId = null;
// Check for QR code format WA:ID: or WH:ID:
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]);
} else {
// Try to find by article number
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
if ($article) {
$articleId = $article->id;
}
}
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'articleNumber' => $article->articleNumber,
'title' => $article->title,
'unit' => $article->unit ?? 'Stk.',
]
]);
}
/**
* Get user's assigned car
* GET /MobileApp/Lager/ShippingNote/getUserCar?userId=X
*/
public function getUserCarAction() {
$userId = intval($this->request->userId ?? $this->user->id);
$db = $this->db();
// Get user's assigned car from TimerecordingCar (user_id is on TimerecordingCar)
$sql = "SELECT id, number_plate, brand, model
FROM TimerecordingCar
WHERE user_id = {$userId}
AND (retired IS NULL OR retired = 0)
LIMIT 1";
$result = $db->query($sql);
if ($result && $row = $result->fetch_assoc()) {
$carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
if (!$carName) $carName = $row['number_plate'];
self::returnJson([
'success' => true,
'car' => [
'id' => intval($row['id']),
'name' => $carName,
'plate' => $row['number_plate'],
]
]);
} else {
self::returnJson([
'success' => true,
'car' => null
]);
}
}
/**
* Get all available cars for selection
* GET /MobileApp/Lager/ShippingNote/getAllCars
*/
public function getAllCarsAction() {
$db = $this->db();
$sql = "SELECT id, number_plate, brand, model
FROM TimerecordingCar
WHERE (retired IS NULL OR retired = 0)
ORDER BY brand, model ASC";
$result = $db->query($sql);
$cars = [];
while ($row = $result->fetch_assoc()) {
$carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
if (!$carName) $carName = $row['number_plate'];
$cars[] = [
'id' => intval($row['id']),
'name' => $carName,
'plate' => $row['number_plate'],
];
}
self::returnJson(['success' => true, 'cars' => $cars]);
}
/**
* Get hour types for selection
* GET /MobileApp/Lager/ShippingNote/getHourTypes
*/
public function getHourTypesAction() {
// Hour types matching desktop modal
$hourTypes = [
['id' => '', 'name' => 'Normal'],
['id' => '50', 'name' => '+50%'],
['id' => '100', 'name' => '+100%'],
['id' => 'regie', 'name' => 'Regie'],
];
self::returnJson(['success' => true, 'hourTypes' => $hourTypes]);
}
/**
* Calculate round-trip distance from office to coordinates
* GET /MobileApp/Lager/ShippingNote/calculateDistance?lat=X&lng=Y
*/
public function calculateDistanceAction() {
$lat = floatval($this->request->lat ?? 0);
$lng = floatval($this->request->lng ?? 0);
if (!$lat || !$lng) {
self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
return;
}
// Use estimation on localhost for development
$isLocalhost = in_array($_SERVER['HTTP_HOST'] ?? '', ['localhost', '127.0.0.1']);
// Use Google Distance Matrix API for accurate driving distance
$apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : '';
if (!$apiKey || $isLocalhost) {
// Fallback to straight-line distance * 1.3 (rough road factor)
$distance = $this->haversineDistance(self::OFFICE_LAT, self::OFFICE_LNG, $lat, $lng);
$kmOneWay = round($distance / 1000 * 1.3, 1);
$kmRoundTrip = $kmOneWay * 2;
self::returnJson([
'success' => true,
'distanceOneWay' => $kmOneWay,
'distanceRoundTrip' => $kmRoundTrip,
'estimated' => true
]);
return;
}
$origin = self::OFFICE_LAT . ',' . self::OFFICE_LNG;
$destination = "{$lat},{$lng}";
$url = "https://maps.googleapis.com/maps/api/distancematrix/json?origins={$origin}&destinations={$destination}&mode=driving&key={$apiKey}";
$response = @file_get_contents($url);
if (!$response) {
self::returnJson(['success' => false, 'message' => 'Distanzberechnung fehlgeschlagen']);
return;
}
$data = json_decode($response, true);
if ($data['status'] !== 'OK' || empty($data['rows'][0]['elements'][0]['distance'])) {
self::returnJson(['success' => false, 'message' => 'Keine Route gefunden']);
return;
}
$distanceMeters = $data['rows'][0]['elements'][0]['distance']['value'];
$kmOneWay = round($distanceMeters / 1000, 1);
$kmRoundTrip = $kmOneWay * 2;
self::returnJson([
'success' => true,
'distanceOneWay' => $kmOneWay,
'distanceRoundTrip' => $kmRoundTrip,
'estimated' => false
]);
}
/**
* Create new shipping note
* POST /MobileApp/Lager/ShippingNote/create
*/
public function createAction() {
$postData = $this->getPostData();
// Validate required fields
$requiredFields = ['deliveryAddressName', 'deliveryAddressLine', 'deliveryAddressPLZ', 'deliveryAddressCity', 'note'];
foreach ($requiredFields as $field) {
if (empty($postData[$field])) {
self::returnJson(['success' => false, 'message' => "Feld '{$field}' ist erforderlich"]);
return;
}
}
// Must have at least positions OR hoursEntries
$positions = $postData['positions'] ?? [];
$hoursEntries = $postData['hoursEntries'] ?? [];
if (empty($positions) && empty($hoursEntries)) {
self::returnJson(['success' => false, 'message' => 'Mindestens eine Position oder Stundenbuchung erforderlich']);
return;
}
$db = $this->db();
// Prepare data
$data = [
'status' => 'new',
'type' => null,
'billingAddressId' => null,
'deliveryAddressName' => $db->escape($postData['deliveryAddressName']),
'deliveryAddressLine' => $db->escape($postData['deliveryAddressLine']),
'deliveryAddressPLZ' => $db->escape($postData['deliveryAddressPLZ']),
'deliveryAddressCity' => $db->escape($postData['deliveryAddressCity']),
'deliveryAddressEMail' => $db->escape($postData['deliveryAddressEMail'] ?? ''),
'note' => $db->escape($postData['note']),
'positions' => json_encode($positions),
'hoursEntries' => json_encode($hoursEntries),
'textElements' => json_encode($postData['textElements'] ?? []),
'metadata' => json_encode($postData['metadata'] ?? []),
'create' => time(),
'createBy' => $this->user->id,
];
// Generate shipping note number
$shippingNoteNumber = WarehouseShippingNoteModel::generateShippingNoteNumber();
$data['shippingNoteNumber'] = $shippingNoteNumber;
// Build INSERT query
$columns = implode(', ', array_map(function($k) { return "`{$k}`"; }, array_keys($data)));
$values = implode(', ', array_map(function($v) {
return $v === null ? 'NULL' : "'{$v}'";
}, array_values($data)));
$db->query("INSERT INTO WarehouseShippingNote ({$columns}) VALUES ({$values})");
$id = $db->insert_id;
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Fehler beim Speichern']);
return;
}
self::returnJson([
'success' => true,
'message' => 'Lieferschein erstellt',
'shippingNote' => [
'id' => $id,
'shippingNoteNumber' => $shippingNoteNumber,
]
]);
}
/**
* Sign a shipping note
* POST /MobileApp/Lager/ShippingNote/sign?id=X
*/
public function signAction() {
$id = intval($this->request->id ?? 0);
$postData = $this->getPostData();
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Lieferschein-ID fehlt']);
return;
}
$shippingNote = WarehouseShippingNoteModel::get($id);
if (!$shippingNote) {
self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']);
return;
}
// Check if already signed
if (!empty($shippingNote->signature) || !empty($shippingNote->signatureName)) {
self::returnJson(['success' => false, 'message' => 'Bereits unterschrieben']);
return;
}
// Validate signature data
$signature = $postData['signature'] ?? '';
$signatureName = $postData['signatureName'] ?? '';
if (empty($signature) || empty($signatureName)) {
self::returnJson(['success' => false, 'message' => 'Unterschrift und Name erforderlich']);
return;
}
$db = $this->db();
$signatureEscaped = $db->escape($signature);
$signatureNameEscaped = $db->escape($signatureName);
$signatureDate = date('Y-m-d');
$db->query("UPDATE WarehouseShippingNote
SET signature = '{$signatureEscaped}',
signatureName = '{$signatureNameEscaped}',
signatureDate = '{$signatureDate}'
WHERE id = {$id}");
self::returnJson([
'success' => true,
'message' => 'Unterschrift gespeichert'
]);
}
/**
* Get my unsigned shipping notes
* GET /MobileApp/Lager/ShippingNote/getMyShippingNotes
*/
public function getMyShippingNotesAction() {
$onlyUnsigned = ($this->request->unsigned ?? '1') === '1';
$limit = intval($this->request->limit ?? 20);
$db = $this->db();
$whereClause = "createBy = {$this->user->id}";
if ($onlyUnsigned) {
$whereClause .= " AND (signature IS NULL OR signature = '')";
}
$sql = "SELECT id, shippingNoteNumber, status, type, deliveryAddressName, deliveryAddressLine,
deliveryAddressPLZ, deliveryAddressCity, note,
signature, signatureName, signatureDate, `create`
FROM WarehouseShippingNote
WHERE {$whereClause}
ORDER BY `create` DESC
LIMIT {$limit}";
$result = $db->query($sql);
$shippingNotes = [];
while ($row = $result->fetch_assoc()) {
$shippingNotes[] = [
'id' => intval($row['id']),
'shippingNoteNumber' => $row['shippingNoteNumber'],
'status' => $row['status'],
'type' => $row['type'],
'deliveryAddressName' => $row['deliveryAddressName'],
'deliveryAddressLine' => $row['deliveryAddressLine'],
'deliveryAddressPLZ' => $row['deliveryAddressPLZ'],
'deliveryAddressCity' => $row['deliveryAddressCity'],
'note' => $row['note'],
'isSigned' => !empty($row['signature']),
'signatureName' => $row['signatureName'],
'signatureDate' => $row['signatureDate'],
'create' => date('d.m.Y H:i', $row['create']),
];
}
self::returnJson(['success' => true, 'shippingNotes' => $shippingNotes]);
}
/**
* Get a single shipping note by ID
* GET /MobileApp/Lager/ShippingNote/getShippingNote?id=X
*/
public function getShippingNoteAction() {
$id = intval($this->request->id ?? 0);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'ID fehlt']);
return;
}
$shippingNote = WarehouseShippingNoteModel::get($id);
if (!$shippingNote) {
self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']);
return;
}
self::returnJson([
'success' => true,
'shippingNote' => [
'id' => intval($shippingNote->id),
'shippingNoteNumber' => $shippingNote->shippingNoteNumber,
'status' => $shippingNote->status,
'type' => $shippingNote->type,
'billingAddressId' => $shippingNote->billingAddressId,
'deliveryAddressName' => $shippingNote->deliveryAddressName,
'deliveryAddressLine' => $shippingNote->deliveryAddressLine,
'deliveryAddressPLZ' => $shippingNote->deliveryAddressPLZ,
'deliveryAddressCity' => $shippingNote->deliveryAddressCity,
'deliveryAddressEMail' => $shippingNote->deliveryAddressEMail,
'note' => $shippingNote->note,
'positions' => json_decode($shippingNote->positions, true) ?? [],
'hoursEntries' => json_decode($shippingNote->hoursEntries, true) ?? [],
'isSigned' => !empty($shippingNote->signature),
'signatureName' => $shippingNote->signatureName,
'signatureDate' => $shippingNote->signatureDate,
'create' => date('d.m.Y H:i', $shippingNote->create),
]
]);
}
/**
* Get shipping note types
* GET /MobileApp/Lager/ShippingNote/getTypes
*/
public function getTypesAction() {
$types = [
['value' => 'V', 'text' => 'Verrechnen'],
['value' => 'XI', 'text' => 'Xinon Intern'],
['value' => 'XH', 'text' => 'Xinon Hersteller'],
['value' => 'SNOPP', 'text' => 'SNOPP'],
['value' => 'ESTMK', 'text' => 'Energie Steiermark'],
['value' => 'SBIDI', 'text' => 'SBIDI'],
];
self::returnJson(['success' => true, 'types' => $types]);
}
/**
* Get current user info (for pre-filling forms)
* GET /MobileApp/Lager/ShippingNote/getCurrentUser
*/
public function getCurrentUserAction() {
self::returnJson([
'success' => true,
'user' => [
'id' => $this->user->id,
'name' => $this->user->name,
'firstname' => $this->user->firstname ?? '',
'lastname' => $this->user->lastname ?? '',
]
]);
}
/**
* Search employees for multi-employee selection
* GET /MobileApp/Lager/ShippingNote/searchEmployees?query=X
*/
public function searchEmployeesAction() {
$query = trim($this->request->query ?? '');
$db = $this->db();
// Base query: active workers who have TimerecordingEmployee entry (= employees)
$sql = "SELECT w.id, w.name, w.email
FROM Worker w
INNER JOIN TimerecordingEmployee te ON te.user_id = w.id
WHERE w.active = 1";
// Add search filter if query provided
if (strlen($query) >= 1) {
// Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst")
$words = preg_split('/\s+/', trim($query));
$wordConditions = [];
foreach ($words as $word) {
if (strlen($word) < 1) continue;
$escapedWord = $db->escape($word);
$wordConditions[] = "(w.name LIKE '%{$escapedWord}%' OR w.email LIKE '%{$escapedWord}%')";
}
if (!empty($wordConditions)) {
$sql .= " AND " . implode(' AND ', $wordConditions);
}
}
$sql .= " ORDER BY w.name ASC LIMIT 20";
$result = $db->query($sql);
$employees = [];
while ($row = $result->fetch_assoc()) {
$employees[] = [
'id' => intval($row['id']),
'name' => $row['name'],
'email' => $row['email'],
];
}
self::returnJson(['success' => true, 'employees' => $employees]);
}
/**
* Helper: Calculate Haversine distance in meters
*/
private function haversineDistance($lat1, $lng1, $lat2, $lng2) {
$earthRadius = 6371000; // meters
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLng / 2) * sin($dLng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadius * $c;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
<?php
/**
* Base Handler for Mobile App endpoints
*
* All app handlers should extend this class.
* Provides common functionality for authentication, permissions, and responses.
*/
abstract class MobileAppBaseHandler {
/** @var object Request object */
protected $request;
/** @var User|null Current user */
protected $user;
/** @var MobileAppController Parent controller */
protected $controller;
/** @var string Required permission for this app (override in subclass) */
protected $requiredPermission = null;
/** @var string App name (used for view rendering) */
protected $appName = '';
/** @var string View template path */
protected $viewTemplate = '';
/**
* Constructor
*/
public function __construct($request, $user, $controller) {
$this->request = $request;
$this->user = $user;
$this->controller = $controller;
}
/**
* Check if user has required permission
* @return bool
*/
public function checkPermission() {
// If no permission required, allow access
if (!$this->requiredPermission) {
return true;
}
// If no user, deny access
if (!$this->user || !$this->user->id) {
return false;
}
// Check permission
return $this->user->can($this->requiredPermission);
}
/**
* Render the app view
* Override in subclass if custom rendering needed
*/
public function renderView() {
$layout = $this->controller->layout();
// Set template
if ($this->viewTemplate) {
$layout->setTemplate($this->viewTemplate);
} else {
$layout->setTemplate("MobileApp/{$this->appName}");
}
// Set default JS globals
$layout->set("JSGlobals", $this->getJSGlobals());
}
/**
* Get JS globals to pass to frontend
* Override in subclass to add app-specific globals
*/
protected function getJSGlobals() {
$globals = [
'BASE_PATH' => '/MobileApp/' . $this->appName,
'APP_NAME' => $this->appName,
];
if ($this->user && $this->user->id) {
$globals['USER_ID'] = $this->user->id;
$globals['USER_NAME'] = $this->user->name;
}
return $globals;
}
/**
* Return JSON response (shorthand)
*/
protected static function returnJson($data, $statusCode = 200) {
mfBaseController::returnJson($data, $statusCode);
}
/**
* Get POST data from JSON body
*/
protected function getPostData() {
return json_decode(file_get_contents('php://input'), true) ?? [];
}
/**
* Get database instance
*/
protected function db() {
return FronkDB::singleton();
}
}

View File

@@ -173,7 +173,12 @@ class OpenAccessId extends mfBaseModel {
$resp_data = Rimoapi::assignOaid($this->oaid, $ftu_data['id']);
// update OAID export data
$exp_data_update = json_decode($this->export_data);
//$exp_data_update = json_decode($this->export_data);
$exp_data_update = $this->getExportData();
if(!property_exists($exp_data_update, "rimo")) {
$exp_data_update->rimo = new StdClass();
}
$exp_data_update->rimo->ftu_id = $ftu_data['id'];
$exp_data_update->rimo->ftu_name = $ftu_data['name'];
$exp_data_update->rimo->ftu_assigned_date = date("U");
@@ -306,11 +311,11 @@ class OpenAccessId extends mfBaseModel {
public function getExportData($key = false) {
if(!$this->export_data) {
return [];
return new StdClass();
} else {
$exdata = json_decode($this->export_data);
if(!is_object($exdata)) {
return [];
return new StdClass();
}
if(!$key) {

View File

@@ -68,6 +68,27 @@ class Order extends mfBaseModel {
return $terminations;
}
public function getSnoppProduct() {
foreach($this->getProperty("products") as $product) {
if($product->snopp_order_id) return $product;
}
return null;
}
public function getOaidProduct() {
foreach($this->getProperty("products") as $product) {
if($product->oaid) return $product;
}
return null;
}
public function getPreorderProduct() {
foreach($this->getProperty("products") as $product) {
if($product->preorder_id) return $product;
}
return null;
}
public function getShippingdate() {
if(!$this->id) {
return false;

View File

@@ -359,7 +359,7 @@ class OrderController extends mfBaseController {
return $new_filter;
}
protected function addAction() {
public function addAction() {
//var_dump($this->request->filter);exit;
@@ -969,6 +969,12 @@ class OrderController extends mfBaseController {
}
$product_data = [];
if(array_key_exists("preorder_id", $p) && $p["preorder_id"]) {
$product_data["preorder_id"] = $p["preorder_id"];
}
if(array_key_exists("oaid", $p) && $p["oaid"]) {
$product_data["oaid"] = $p["oaid"];
}
$product_data["order_id"] = $new_id;
$product_data["product_id"] = $p["product_id"];
$product_data['amount'] = (!empty($p['amount'])) ? $p['amount'] : 1;
@@ -1367,6 +1373,145 @@ class OrderController extends mfBaseController {
$this->returnJson(["status" => "OK", "order" => ['id' => $order_id]]);
}
protected function createSnoppOrderAction() {
$order_id = $this->request->id;
if(!$order_id || $order_id < 1) {
$this->layout()->setFlash("Bestellung nicht gefunden.", "error");
$this->redirect("Order");
}
$order = new Order($order_id);
if(!$order->id) {
$this->layout()->setFlash("Bestellung nicht gefunden.", "error");
$this->redirect("Order");
}
$order_product = false;
$product_snopp_id = false;
$products_noterm = false;
// find product
foreach($order->products as $op) {
// check for valid internet access product
if(!in_array($op->product->producttech_id, TT_PRODUCTTECH_IDS_INTERNET_ACCESSS)) {
continue;
}
if($op->oaid) {
$order_product = $op;
break;
}
if($op->preorder_id) {
$order_product = $op;
break;
}
// if product has a snopp product_id, then this must be it
if($op->product->getAttributeValue("oan_pid_snopp")) {
$order_product = $op;
break;
}
}
if(!$order_product) {
$this->layout()->setFlash("Kein für SNOPP-Bestellungen geeignetes Produkt in dieser Bestellung gefunden.", "error");
$this->redirect("Order", "Index", ["id" => $order->id]);
}
// order in snopp
$snopp_prod_id = $op->product->getAttributeValue("oan_pid_snopp");
if(!$snopp_prod_id) {
$this->layout()->setFlash("SNOPP Product ID fehlt im Produkt (".$order_product->product->name.").", "error");
$this->redirect("Order", "Index", ["id" => $order->id]);
}
// find snopp api credentials
$api_creds = $order_product->product->getOwnerSnoppApiCredentials();
$this->log->debug(__METHOD__.": Snopp Api Creds: ".print_r($api_creds, true));
if(!$api_creds) {
$this->layout()->setFlash("Produktbesitzer hat keinen SNOPP Api Key", "error");
$this->redirect("Order");
}
$baseurl = $api_creds["prod"]["url"];
$apikey = $api_creds["prod"]["key"];
$snopp = new Snoppapi($baseurl, $apikey);
$ext_id = false;
if($order_product->oaid) {
$ext_id = $order_product->oaid;
} elseif($order_product->preorder_id) {
$preorder = new Preorder($order_product->preorder_id);
if($preorder->id && $preorder->campaign->fulfillment == "citycom_oan") {
$ext_id = "SDIHome_xtc{$preorder->adb_wohneinheit_id}_1700000000";
} elseif($preorder->id) {
if($preorder->adb_wohneinheit->oaid) {
$ext_id = $preorder->adb_wohneinheit->oaid;
} elseif($preorder->adb_wohneinheit->extref) {
$ext_id = $preorder->adb_wohneinheit->extref;
}
}
} else {
// search for address in snopp
$search_data = [
"street" => $order->owner->street,
"zip" => $order->owner->zip,
"city" => $order->owner->city,
];
$homes = $snopp->searchAddress($search_data);
if(!$homes) {
$this->layout()->setFlash("Home in Snopp nicht gefunden", "error");
$this->redirect("Order", "Index", ["id" => $order->id]);
}
$home = reset($homes);
$ext_id = $home->oan_id;
}
if(!$ext_id) {
$this->layout()->setFlash("Konnte keine OAID oder External ID zur Adresse finden.", "error");
$this->redirect("Order", "Index", ["id" => $order->id]);
}
$data = [
"oan_id" => ($order_product->oaid) ?: $ext_id,
"product_id" => $snopp_prod_id,
"extref" => ($op->order->partner_number) ?: $op->order->owner->customer_number,
"execution_date" => (new DateTime("now"))->setTimezone(new DateTimeZone("Europe/Vienna"))->format("c"),
"name" => $order->owner->getCompanyOrName(),
"street" => $order->owner->street,
"zip" => $order->owner->zip,
"city" => $order->owner->city,
"phone" => $order->owner->phone,
"mobile" => $order->owner->mobile,
"email" => $order->owner->email,
];
$resp = $snopp->submitOrder($data);
if(!$resp) {
$this->layout()->setFlash("Fehler beim Bestellen im Snopp.", "error");
$this->redirect("Order", "Index", ["id" => $order->id]);
}
if($resp->status != "Created") {
$this->layout()->setFlash("Konnte nicht bestellt werden: '{$resp->result->message}'", "error");
$this->redirect("Order", "Index", ["id" => $order->id]);
}
if($resp->result->order_id) {
$order_product->snopp_order_id = $resp->result->order_id;
} else {
$order_product->snopp_order_id = 1;
}
$order_product->save();
$this->layout()->setFlash("Bestellung erfolgreich in SNOPP erstellt.", "success");
$this->redirect("Order", "Index", ["id" => $order->id]);
}
protected function deleteAction() {
if(!$this->me->is(["Admin","salespartner"])) {
$this->layout()->setFlash("Keine Berechtigung", "error");

View File

@@ -49,10 +49,6 @@ class OrderProduct extends mfBaseModel {
public function getProperty($name) {
if($this->$name == null) {
if(!$this->id) {
return null;
}
if($name == "cpeprovisioning") {
$this->cpeprovisioning = CpeprovisioningModel::getFirst(["orderproduct_id" => $this->id]);
return $this->cpeprovisioning;

View File

@@ -5,6 +5,8 @@ class OrderProductModel
public $order_id;
public $product_id;
public $termination_id;
public $oaid;
public $preorder_id;
public $voicenumber;
public $voiceplan_id;
public $domain;
@@ -212,6 +214,13 @@ class OrderProductModel
}
}
if (array_key_exists("preorder_id", $filter)) {
$preorder_id = $filter['preorder_id'];
if (is_numeric($preorder_id)) {
$where .= " AND preorder_id=$preorder_id";
}
}
if (array_key_exists("voicenumber", $filter)) {
$voicenumber = FronkDB::singleton()->escape($filter['voicenumber']);
if ($voicenumber) {

View File

@@ -26,6 +26,7 @@ class Preorder extends mfBaseModel {
private $statusjournals;
private $cancel_request_status;
private $cancel_request_creator;
private $orderproduct;
protected function beforeUpdate($data) {
if(!array_key_exists("edit_by", $data)) {
@@ -736,19 +737,27 @@ class Preorder extends mfBaseModel {
$first_ctag = $search_ctag - ($search_ctag % $ctags_per_home);
$last_ctag = $first_ctag + $ctags_per_home - 1;
$mgmt_ctag_exists = false;
$mgmt_ctag = null;
$ctag_range = [];
for($i = $first_ctag; $i <= $last_ctag; $i++) {
if(!PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name])) {
if($i == $last_ctag) {
$ctag = PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name]);
if(!$ctag) {
if($i == $last_ctag && !$mgmt_ctag_exists) {
// mgmt ctag should be the last in range
$mgmt_ctag = $i;
continue;
}
$ctag_range[] = $i;
} else {
if($ctag->service_type == "mgmt") {
$this->log->debug(__METHOD__.": mgmt ctag ($i / stag $stag) exists already\n");
$mgmt_ctag_exists = true;
}
}
}
return [$ctag_range, $mgmt_ctag];
}
@@ -1682,6 +1691,13 @@ class Preorder extends mfBaseModel {
return $this->editor;
}
if($name == "orderproduct") {
$op = OrderProductModel::getFirst(["preorder_id" => $this->id]);
if(!$op) return null;
$this->orderproduct = $op;
return $this->orderproduct;
}
if($name == "creator") {
$user = mfValuecache::singleton()->get("Worker-id-" . $this->create_by);
if($user) {

View File

@@ -1048,6 +1048,171 @@ class PreorderController extends mfBaseController {
$this->layout()->set("no_filename", false);
}
protected function createOrderFromPreorderAction() {
$preorder_id = $this->request->preorder_id;
if(!is_numeric($preorder_id) || $preorder_id < 1) {
$this->layout()->setFlash("Vorbestellung nicht gefunden!", "error");
$this->redirect("Preorder", "Index");
}
$preorder = new Preorder($preorder_id);
if(!$preorder->id) {
$this->layout()->setFlash("Vorbestellung nicht gefunden!", "error");
$this->redirect("Preorder", "Index");
}
$order_data = [];
$order_data["preorder_id"] = $preorder->id;
$owner_data = [];
foreach(["company","uid","firstname","lastname","street","zip","city","phone","email"] as $field) {
if(!trim($preorder->$field)) {
$owner_data[$field] = "";
}
$owner_data[$field] = trim($preorder->$field);
}
// search owner in Address and add owner_id ...
$owner = false;
$owners = AddressModel::search($owner_data);
foreach($owners as $o) {
if(!$this->me->is("employee")) {
// external salespartners must not use addresses with customer_number
if($o->customer_number) continue;
// otherwise use with address
$owner = $o;
} else {
// every address can be used as fallback
$owner = $o;
// if we are employees, customers with customer_number and fibu_primary_account have precedence
// but still use addresses with only customer_number as fallback
if($o->customer_number) {
$owner = $o;
if($o->fibu_primary_account) {
break;
}
}
}
}
if($owner && $owner->id) {
$order_data["owner_id"] = $owner->id;
$order_data["owner"] = $owner;
} else {
foreach($owner_data as $field => $value) {
if(!$preorder->$field) continue;
$order_data["owner_".$field] = $value;
}
$order_data["new_owner"] = 1;
}
if($preorder->order_date) {
$order_data["order_date"] = $preorder->order_date;
} else {
$order_data["order_date"] = $preorder->create;
}
$operator = false;
$campaign = $preorder->campaign;
if(is_array($campaign->active_operators) && count($campaign->active_operators)) {
$campaign_operator = reset($campaign->active_operators);
$operator = $campaign_operator->operator;
}
if(!$operator) {
$this->layout()->setFlash("Kampagne hat keinen Netzbetreiber!", "error");
$this->redirect("Preorder", "Index", ["filter" => ["preordercampaign_id" => $campaign->id]], "preorder=$preorder_id");
}
// try product with correct network id
$product = ProductModel::getFirst([
"external_id" => $operator->id,
"network_id" => $campaign->network_id,
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
"name" => "%OAN%",
"active" => true
]);
if(!$product) {
// else use any product from operator
$product = ProductModel::getFirst([
"external_id" => $operator->id,
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
"name" => "%OAN%",
"active" => true
]);
}
if(!$product) {
// else use any product from operator
$product = ProductModel::getFirst([
"external_id" => $operator->id,
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
"active" => true
]);
}
if($operator->id == 1) {
if(!$product) {
$product = ProductModel::getFirst([
"external" => 0,
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
"network_id" => $campaign->network_id,
"active" => true
]);
}
if(!$product) {
$product = ProductModel::getFirst([
"external" => 0,
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
"name" => "%OAN%",
"active" => true
]);
}
}
//var_dump($product);exit;
if(!$product) {
$this->layout()->setFlash("Keine Produkte für Netzbetreiber gefunden!", "error");
$this->redirect("Preorder", "Index", ["filter" => ["preordercampaign_id" => $campaign->id]], "preorder=$preorder_id");
}
$product_data = [];
$product_data["preorder_id"] = $preorder->id;
$product_data["oaid"] = $preorder->oaid;
$product_data["product_id"] = $product->id;
$product_data['amount'] = 1;
$product_data["pos"] = 1;
$product_data["description"] = "";
$product_data["price"] = trim($product->price) ? Layout::commaToDot(trim($product->price)) : 0;
$product_data["price_setup"] = trim($product->price_setup) ? Layout::commaToDot(trim($product->price_setup)) : 0;
$product_data["billing_delay"] = ($product->billing_delay) ? $product->billing_delay : 0;
if($product_data["billing_delay"] > 6) {
$product_data["billing_delay"] = 6;
}
$product_data["billing_period"] = $product->billing_period;
$product_data["contract_term"] = $product->contract_term;
if($this->me->is("Admin")) {
$product_data["price_nne"] = $product->price_nne;
$product_data["price_nbe"] = $product->price_nbe;
}
$order_data["products"] = [1 => OrderProductModel::create($product_data)];
//var_dump($order_data["products"]);exit;
$order = new Order();
$order->update($order_data);
//var_dump($owner_data);exit;
$oc = new OrderController();
$this->layout()->set("order", $order);
return $oc->addAction();
}
protected function apiAction() {
$do = $this->request->do;
$data = [];
@@ -1420,8 +1585,14 @@ class PreorderController extends mfBaseController {
$new_remark = date("d.m.Y").": ".$new_remark;
$api_creds = $preorder->getNetownerRimoApiCredentials();
$this->log->debug(__METHOD__.": Rimo Api Creds: ".print_r($api_creds, true));
if(!$api_creds) return false;
$apikey = $api_creds["prod"]["key"];
// upload remark to Rimo
if(!Rimoapi::addRemark($workorder->rimo_id, $new_remark)) {
if(!Rimoapi::addRemark($apikey, $workorder->rimo_id, $new_remark)) {
return false;
}

View File

@@ -6,6 +6,7 @@ use chillerlan\QRCode\Output\QROutputInterface;
class PreorderBillingInvoice extends mfBaseModel {
protected $forcestr = ["company", "zip", "email", "phone"];
private $owner;
private $netowner;
private $positions;
private $pdf;

View File

@@ -1,10 +1,8 @@
<?php
class PreorderCtag extends mfBaseModel {
protected $forcestr = ["product_name","product_info","matchcode"];
protected $forcestr = ["network","ext_id","ext_name"];
private $preorder;
private $invoice;
private $adb_wohneinheit;
private $creator;
private $editor;
@@ -153,7 +151,7 @@ class PreorderCtag extends mfBaseModel {
$model = new PreorderCtag();
$table_fields = [
"preorder_id", "network", "stag", "ctag", "service_type", "ext_id", "ext_status", "deleted",
"preorder_id", "network", "stag", "ctag", "service_type", "ext_id", "ext_name", "ext_status", "deleted",
"create_by","edit_by","create","edit"
];
@@ -203,7 +201,7 @@ class PreorderCtag extends mfBaseModel {
$where = self::getSqlFilter($filter);
$sql = "SELECT PreorderCtag.* FROM PreorderCtag
WHERE $where
ORDER BY preorder_id,stag,ctag LIMIT 1";
ORDER BY ctag LIMIT 1";
//var_dump($sql);exit;
mfLoghandler::singleton()->debug($sql);
$res = $db->query($sql);
@@ -230,7 +228,7 @@ class PreorderCtag extends mfBaseModel {
$where = self::getSqlFilter($filter);
$sql = "SELECT PreorderCtag.* FROM PreorderCtag
WHERE $where
ORDER BY preorder_id DESC,stag DESC,ctag DESC LIMIT 1";
ORDER BY ctag DESC LIMIT 1";
//var_dump($sql);exit;
mfLoghandler::singleton()->debug($sql);
$res = $db->query($sql);

View File

@@ -337,6 +337,16 @@ class PreorderlogisticsController extends mfBaseController {
}
}
// Date filter for sent date (Versanddatum range)
if (!empty($filter['sent_date']) && is_array($filter['sent_date'])) {
if (!empty($filter['sent_date']['from'])) {
$new_filter['add-where'] .= " AND Preorderlogistics.sent >= " . intval($filter['sent_date']['from']);
}
if (!empty($filter['sent_date']['to'])) {
$new_filter['add-where'] .= " AND Preorderlogistics.sent <= " . intval($filter['sent_date']['to']);
}
}
$new_filter["status_code"] = 140;
$new_filter["deleted"] = 0;
$new_filter["unit_count<="] = 2;

View File

@@ -54,6 +54,28 @@ class Product extends mfBaseModel {
}
public function getAttributeValue($name) {
$attributes = $this->getProperty("attributes");
if(!array_key_exists($name, $attributes) || !$attributes[$name]->value) {
return null;
}
return $attributes[$name]->value;
}
public function getOwnerSnoppApiCredentials() {
$owner = $this->getProperty("owner");
if(!$owner) return false;
foreach(TT_SNOPP_API_CREDS as $api_creds) {
if($api_creds["address_id"] == $owner->id) {
return $api_creds;
}
}
return null;
}
public function getProperty($name) {
if($this->$name == null) {

View File

@@ -105,6 +105,7 @@ class ProductModel {
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
WHERE $where
GROUP BY Product.id
ORDER BY Productgroup.name,Producttech.name,Product.name LIMIT 1
@@ -135,6 +136,7 @@ class ProductModel {
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
WHERE $where
GROUP BY Product.id
) as p
@@ -160,6 +162,7 @@ class ProductModel {
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
WHERE $where
GROUP BY Product.id
ORDER BY Productgroup.name,Producttech.name,Product.name
@@ -169,7 +172,7 @@ class ProductModel {
if(is_array($limit) && count($limit)) {
if(is_numeric($limit['start']) && is_numeric($limit['count'])) {
$sql .= " LIMIT ".$limit['start'].", ".$limit['count'];
} elseif(is_numeric($count)) {
} elseif(is_numeric($limit['count'])) {
$sql .= " LIMIT ".$limit['count'];
}
}
@@ -233,6 +236,15 @@ class ProductModel {
}
}
if(array_key_exists("network_id", $filter)) {
$network_id = $filter['network_id'];
if(is_numeric($network_id)) {
$where .= " AND ProductNetwork.network_id=$network_id";
} elseif(is_array($network_id) && count($network_id)) {
$where .= " AND ProductNetwork.network_id IN (". implode(",", $network_id).")";
}
}
if(array_key_exists("name", $filter)) {
$name = $db->escape($filter['name']);
if($name) {
@@ -284,7 +296,7 @@ class ProductModel {
if(array_key_exists("attributevalue", $filter)) {
$attributevalue = $db->escape($filter['attributevalue']);
if($attributevalue) {
if(strlen($attributevalue)) {
$where .= " AND ProductAttribute.value = '$attributevalue'";
}
}

View File

@@ -4,9 +4,45 @@ use PHPMailer\PHPMailer\Exception;
class RadiusController extends mfBaseController {
private User $me;
private bool $isApiCall = false;
private array $apiAllowedActions = [
'ProxyUnsecureHTTPRequestToRadius',
'GenieacsRunSpeedtest',
'GenieacsGetSpeedtestResult',
'GenieacsGetDeviceByIp',
'GenieacsGetDeviceByMac',
'GenieacsRefreshDevice',
'GenieacsRebootDevice',
'GenieacsGetDeviceInfo',
'GenieacsPing',
'GenieacsRemoteAccess',
'GenieacsEventLog',
'GenieacsNetworkStructure',
];
protected function init(): void {
$this->needlogin=true;
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
if ($apiKey && in_array($this->action, $this->apiAllowedActions)) {
$me = new User();
$me->loadByApikey($apiKey);
if ($me->id) {
$this->me = $me;
$this->isApiCall = true;
$this->needlogin = false;
if (!defined('INTERNAL_USER_ID')) {
define('INTERNAL_USER_ID', $me->id);
}
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, X-API-Key");
return;
}
}
$this->needlogin = true;
$me = new User();
$me->loadMe();
$this->layout()->set("me", $me);
@@ -51,20 +87,32 @@ class RadiusController extends mfBaseController {
$acs = $this->getGenieACS();
// Set speedtest parameters on the device
$acs->setParameterValues($deviceId, [
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
if (!$resolvedId) self::sendError("Device not found in GenieACS");
$acs->getParameterValues($resolvedId, [
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start',
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect',
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess',
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'
]);
sleep(2);
$acs->setParameterValues($resolvedId, [
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start' => 1,
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1,
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true
]);
// Get device and extract IP
$device = $acs->getDevice($deviceId);
$ip = GenieACS::getExternalIP($device);
sleep(3);
$device = $acs->getDevice($resolvedId);
$managementIp = GenieACS::getManagementIP($device);
$externalIp = GenieACS::getExternalIP($device);
$ip = $externalIp ?: $managementIp;
if (!$ip) self::sendError("Could not determine device IP");
// Trigger speedtest via external API
$url = "http://acs.xinon.at:5000/run-speedtest";
$apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ";
$data = json_encode(['ip' => $ip]);
@@ -84,9 +132,8 @@ class RadiusController extends mfBaseController {
if ($response === false) self::sendError("Failed to connect to speedtest server");
self::returnJson(['success' => true, 'message' => 'Speedtest started']);
self::returnJson(['success' => true, 'message' => 'Speedtest started', 'ip' => $ip, 'serverResponse' => json_decode($response, true)]);
} catch (Exception $e) {
$this->log->debug("Speedtest Error", ['error' => $e->getMessage()]);
self::sendError("Error running speedtest: " . $e->getMessage());
}
}
@@ -101,11 +148,12 @@ class RadiusController extends mfBaseController {
$acs = $this->getGenieACS();
// Request parameter refresh
$acs->getParameterValues($deviceId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']);
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
if (!$resolvedId) self::sendError("Device not found in GenieACS");
// Get device info with full data
$device = $acs->getDevice($deviceId);
$acs->getParameterValues($resolvedId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']);
$device = $acs->getDevice($resolvedId);
if (!$device) self::sendError("Device not found");
@@ -178,6 +226,19 @@ class RadiusController extends mfBaseController {
return new GenieACS($host, $username, $password);
}
private function resolveDeviceId(string $deviceId, GenieACS $acs): ?string {
if (strpos($deviceId, ':') !== false) {
$device = $acs->getDeviceByMac($deviceId);
if ($device) {
$resolvedId = GenieACS::getDeviceId($device);
if ($resolvedId) return $resolvedId;
if (isset($device['_id'])) return $device['_id'];
}
return null;
}
return $deviceId;
}
protected function genieacsGetDeviceByIpAction() {
try {
$ip = $_GET['ip'] ?? null;
@@ -218,6 +279,73 @@ class RadiusController extends mfBaseController {
}
}
protected function genieacsGetDeviceByMacAction() {
try {
$mac = $_GET['mac'] ?? null;
$this->log->debug("genieacsGetDeviceByMacAction", ['mac' => $mac]);
if (!$mac) self::sendError("MAC address is required");
$acs = $this->getGenieACS();
$matchedDevice = $acs->getDeviceByMac($mac);
if (!$matchedDevice) {
self::returnJson(['success' => false, 'message' => 'No device found with this MAC address']);
return;
}
self::returnJson([
'success' => true,
'deviceId' => GenieACS::getDeviceId($matchedDevice),
'deviceInfo' => GenieACS::getDeviceInfo($matchedDevice),
'mac' => $mac,
'externalIp' => GenieACS::getExternalIP($matchedDevice),
'managementIp' => GenieACS::getManagementIP($matchedDevice)
]);
} catch (Exception $e) {
$this->log->debug("GetDeviceByMac Error", ['error' => $e->getMessage()]);
self::sendError("Error fetching device: " . $e->getMessage());
}
}
protected function genieacsRefreshDeviceAction() {
try {
$input = json_decode(file_get_contents('php://input'), true);
$deviceId = $input['deviceId'] ?? null;
$this->log->debug("genieacsRefreshDeviceAction", ['deviceId' => $deviceId]);
if (!$deviceId) self::sendError("Device ID is required");
$acs = $this->getGenieACS();
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
if (!$resolvedId) self::sendError("Device not found in GenieACS");
$acs->getParameterValues($resolvedId, [
'InternetGatewayDevice.DeviceInfo.HardwareVersion',
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.MACAddress',
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.ExternalIPAddress',
'InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.SSID',
'InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.KeyPassphrase',
'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.HostName',
'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.IPAddress',
'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.MACAddress'
]);
$device = $acs->getDevice($resolvedId);
self::returnJson([
'success' => true,
'deviceInfo' => GenieACS::getDeviceInfo($device),
'externalIp' => GenieACS::getExternalIP($device),
'managementIp' => GenieACS::getManagementIP($device)
]);
} catch (Exception $e) {
$this->log->debug("RefreshDevice Error", ['error' => $e->getMessage()]);
self::sendError("Error refreshing device: " . $e->getMessage());
}
}
protected function genieacsRebootDeviceAction() {
try {
$input = json_decode(file_get_contents('php://input'), true);
@@ -310,8 +438,11 @@ class RadiusController extends mfBaseController {
if (!$deviceId) self::sendError("Device ID is required");
$acs = $this->getGenieACS();
$creds = $acs->createRemoteUser($deviceId);
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
if (!$resolvedId) self::sendError("Device not found in GenieACS");
$creds = $acs->createRemoteUser($resolvedId);
if (!$creds) self::sendError("Could not obtain credentials for FritzBox");
$url = "http://acs.xinon.at:5000/read-fritz-eventlog";
@@ -362,8 +493,11 @@ class RadiusController extends mfBaseController {
if (!$deviceId) self::sendError("Device ID is required");
$acs = $this->getGenieACS();
$creds = $acs->createRemoteUser($deviceId);
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
if (!$resolvedId) self::sendError("Device not found in GenieACS");
$creds = $acs->createRemoteUser($resolvedId);
if (!$creds) self::sendError("Could not obtain credentials for FritzBox");
$url = "http://acs.xinon.at:5000/read-fritz";
@@ -564,6 +698,261 @@ class RadiusController extends mfBaseController {
}
// ========== AVM Scanner Methods ==========
private static $avmScannerStateFile = null;
private static $avmPrefixes = [
'00:04:0E', '00:15:0C', '00:1A:4F', '00:1C:4A', '00:1F:3F', '00:24:FE',
'04:B4:FE', '08:96:D7', '08:B6:57', '0C:72:74',
'1C:ED:6F',
'24:65:11', '2C:3A:FD', '2C:91:AB',
'34:31:C4', '34:81:C4', '34:E1:A9', '38:10:D5', '3C:37:12', '3C:A6:2F',
'44:4E:6D', '48:5D:35',
'50:E6:36', '5C:49:79',
'60:B5:8D',
'74:42:7F', '7C:FF:4D',
'80:23:95',
'98:9B:CB', '98:A9:65', '9C:C7:A6',
'B0:F2:08', 'B4:FC:7D', 'BC:05:43',
'C0:25:06', 'C8:0E:14', 'CC:CE:1E',
'D0:12:CB', 'D4:24:DD', 'DC:15:C8', 'DC:39:6F',
'E0:08:55', 'E0:28:6D', 'E8:DF:70',
'F0:B0:14'
];
private function getAvmScannerStatePath(): string {
return BASEDIR . '/files/avm_scanner.json';
}
private function loadAvmScannerState(): array {
$path = $this->getAvmScannerStatePath();
if (file_exists($path)) {
$content = file_get_contents($path);
$state = json_decode($content, true);
if (is_array($state)) return $state;
}
return [
'scanning' => false,
'stopRequested' => false,
'progress' => ['current' => 0, 'total' => 0],
'currentDevice' => null,
'startedAt' => null,
'startedBy' => null,
'lastUpdated' => date('c'),
'devices' => []
];
}
private function saveAvmScannerState(array $state): void {
$dir = dirname($this->getAvmScannerStatePath());
if (!is_dir($dir)) @mkdir($dir, 0777, true);
$state['lastUpdated'] = date('c');
file_put_contents($this->getAvmScannerStatePath(), json_encode($state, JSON_PRETTY_PRINT));
}
private function isAvmMac(string $mac): bool {
$mac = strtoupper(trim($mac));
$prefix = substr($mac, 0, 8);
return in_array($prefix, self::$avmPrefixes);
}
private function fetchRadiusUsersFromApi(): array {
$url = "http://radius.xinon.at/api.php";
$opts = [
"http" => [
"method" => "GET",
"header" => "Authorization: Basic " . base64_encode("admin:saveman"),
"timeout" => 120
]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response === false) return [];
$data = json_decode($response, true);
return is_array($data) ? $data : [];
}
private function fetchRadacctForUser(string $username): ?array {
$url = "http://radius.xinon.at/api.php?action2=fetchRadacct&username=" . urlencode($username);
$opts = [
"http" => [
"method" => "GET",
"header" => "Authorization: Basic " . base64_encode("admin:saveman"),
"timeout" => 10
]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response === false) return null;
$data = json_decode($response, true);
return is_array($data) ? $data : null;
}
protected function avmScannerGetStateAction() {
$state = $this->loadAvmScannerState();
header('Content-Type: application/json');
echo json_encode($state);
die();
}
protected function avmScannerGetUsersAction() {
$users = $this->fetchRadiusUsersFromApi();
$debug = $_GET['debug'] ?? false;
$macUsers = 0;
$avmMacUsers = 0;
$notInAcs = 0;
$macPrefixCounts = [];
$avmUsers = [];
foreach ($users as $user) {
$username = trim($user['username'] ?? '');
if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue;
$macUsers++;
$prefix = strtoupper(substr($username, 0, 8));
$macPrefixCounts[$prefix] = ($macPrefixCounts[$prefix] ?? 0) + 1;
if (!$this->isAvmMac($username)) continue;
$avmMacUsers++;
$info = $user['info'] ?? '';
if (stripos($info, 'ACS') !== false) continue;
$notInAcs++;
$user['username'] = $username;
$avmUsers[] = $user;
}
if ($debug) {
arsort($macPrefixCounts);
header('Content-Type: application/json');
echo json_encode([
'totalFromApi' => count($users),
'macAddressUsers' => $macUsers,
'avmMacUsers' => $avmMacUsers,
'notInAcs' => $notInAcs,
'topMacPrefixes' => array_slice($macPrefixCounts, 0, 30, true),
'avmPrefixes' => self::$avmPrefixes
]);
die();
}
header('Content-Type: application/json');
echo json_encode(['users' => $avmUsers, 'count' => count($avmUsers)]);
die();
}
protected function avmScannerStartAction() {
$state = $this->loadAvmScannerState();
if ($state['scanning']) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Scan already running']);
die();
}
// Count how many users will be scanned (for immediate feedback)
$users = $this->fetchRadiusUsersFromApi();
$count = 0;
foreach ($users as $user) {
$username = trim($user['username'] ?? '');
if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue;
if (!$this->isAvmMac($username)) continue;
$info = $user['info'] ?? '';
if (stripos($info, 'ACS') !== false) continue;
$count++;
}
// Spawn background script using nohup to ensure it runs independently
$scriptPath = BASEDIR . '/scripts/avm_scanner.php';
$logPath = BASEDIR . '/files/avm_scanner.log';
$cmd = "nohup php " . escapeshellarg($scriptPath) . " >> " . escapeshellarg($logPath) . " 2>&1 &";
shell_exec($cmd);
header('Content-Type: application/json');
echo json_encode(['success' => true, 'message' => 'Scan started', 'total' => $count]);
die();
}
protected function avmScannerStopAction() {
$state = $this->loadAvmScannerState();
$state['stopRequested'] = true;
$this->saveAvmScannerState($state);
header('Content-Type: application/json');
echo json_encode(['success' => true, 'message' => 'Stop requested']);
die();
}
protected function avmScannerToggleErledigtAction() {
$input = json_decode(file_get_contents('php://input'), true);
$mac = $input['mac'] ?? null;
if (!$mac) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['error' => 'MAC address required']);
die();
}
$state = $this->loadAvmScannerState();
foreach ($state['devices'] as &$device) {
if ($device['mac'] === $mac) {
$device['erledigt'] = !($device['erledigt'] ?? false);
break;
}
}
$this->saveAvmScannerState($state);
header('Content-Type: application/json');
echo json_encode(['success' => true]);
die();
}
private function detectFritzPort(string $ip): ?int {
$url = "https://acs.xinon.at/detect-port";
$data = json_encode(['fritz_ip' => $ip, 'timeout' => 3]);
$opts = [
"http" => [
"method" => "POST",
"header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n",
"content" => $data,
"timeout" => 15
],
"ssl" => ["verify_peer" => false, "verify_peer_name" => false]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response) {
$json = json_decode($response, true);
if ($json && $json['success'] && isset($json['port'])) {
return (int)$json['port'];
}
}
return null;
}
private function detectFritzDevice(string $ip, int $port): ?array {
$url = "https://acs.xinon.at/detect-device";
$data = json_encode(['fritz_ip' => $ip, 'fritz_port' => (string)$port]);
$opts = [
"http" => [
"method" => "POST",
"header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n",
"content" => $data,
"timeout" => 15
],
"ssl" => ["verify_peer" => false, "verify_peer_name" => false]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response) {
$json = json_decode($response, true);
if ($json && $json['success'] && isset($json['device'])) {
return $json['device'];
}
}
return null;
}
}

View File

@@ -1,5 +1,7 @@
<?php
use Smalot\PdfParser\Parser;
class RimoWorkorder extends mfBaseModel {
private $adb_wohneinheit;
private $termination;
@@ -57,6 +59,113 @@ class RimoWorkorder extends mfBaseModel {
return $ah;
}
public function parseAha(): array {
if (!$this->id || !$this->adb_wohneinheit_id) return ['success' => false, 'message' => 'Missing ID'];
$preorder = PreorderModel::getFirstActive(["adb_wohneinheit_id" => $this->adb_wohneinheit_id]);
if (!$preorder?->id) return ['success' => false, 'message' => 'No active Preorder'];
$workorder = WorkorderModel::getFirst(['preorderId' => $preorder->id]);
if (!$workorder || !($pdf = $this->getAha())) return ['success' => false, 'message' => 'No Workorder or PDF'];
try {
$dropkabel = $this->parseDropkabelFromPdf($pdf);
$map = $this->extractMapFromPdf($pdf);
$meta = json_decode($workorder->metadata ?: '{}', true) ?: [];
$mapFileId = null;
if ($map) {
if ($oldId = ($meta['dropcable']['map_file_id'] ?? null)) {
$old = new File($oldId); if ($old->id) try { $old->delete(); } catch (Exception $e) {}
}
$fn = 'aha_lageplan_' . $this->id . '_' . time() . '.png';
$file = FileModel::create(['name' => 'AHA Lageplan ' . $this->rimo_name, 'filename' => $fn,
'store_filename' => $fn, 'orig_filename' => 'AHA_Lageplan_' . $this->rimo_name . '.png',
'mimetype' => 'image/png', 'subfolder' => 'aha_maps', 'create_by' => 1]);
if ($file->save()) {
$dir = MFUPLOAD_FILE_SAVE_PATH . '/aha_maps';
if (!is_dir($dir)) mkdir($dir, 0755, true);
if (file_put_contents("$dir/$fn", $map)) $mapFileId = $file->id;
}
}
$meta['dropcable'] = ['rimo_workorder_id' => $this->id, 'rimo_name' => $this->rimo_name,
'parsed_at' => time(), 'entries' => $dropkabel, 'map_file_id' => $mapFileId];
$workorder->metadata = json_encode($meta);
WorkorderModel::update((array)$workorder);
return ['success' => true, 'dropkabel_count' => count($dropkabel), 'has_map' => (bool)$mapFileId];
} catch (Exception $e) {
$this->log->error(__METHOD__ . ": " . $e->getMessage());
return ['success' => false, 'message' => $e->getMessage()];
}
}
public static function autoParseForWorkorder(int $workorderId, bool $force = false): void {
$wo = WorkorderModel::get($workorderId);
if (!$wo) return;
$meta = json_decode($wo->metadata ?? '{}', true);
if (($force || empty($meta['dropcable']['parsed_at'])) && $wo->preorderId) {
$pre = new Preorder($wo->preorderId);
$rimos = $pre->adb_wohneinheit_id ? RimoWorkorderModel::search(['adb_wohneinheit_id' => $pre->adb_wohneinheit_id]) : [];
if (!empty($rimos[0])) (new self($rimos[0]->id))->parseAha();
}
}
private function parseDropkabelFromPdf(string $pdf): array {
$result = [];
$text = (new Parser())->parseContent($pdf)->getPages()[0]?->getText() ?? '';
if (!preg_match('/Dropkabel:\s*\n(.+?)(?:Lage:|$)/s', $text, $m)) {
// Try alternative pattern without strict newline requirement
if (!preg_match('/Dropkabel[:\s]*(.+?)(?:Lage|Anschluss|$)/si', $text, $m)) {
return $result;
}
}
$started = false;
foreach (explode("\n", $m[1]) as $line) {
$line = trim($line);
if (!$line) continue;
// Check for header line (ID and Type columns)
if (stripos($line, 'ID') !== false && (stripos($line, 'Type') !== false || stripos($line, 'Typ') !== false)) {
$started = true;
continue;
}
// Flexible cable ID pattern - matches F26-K009, F-ABC123-K01, F-XYZ(1)-K02, etc.
if ($started && preg_match('#^([A-Z][A-Z0-9()/_-]*-K\d+)\s+(.+)$#i', $line, $p)) {
$rest = $p[2]; $status = '';
foreach (['Planfreigabe', 'Plan released', 'Grobplanung', 'Executed', 'Ausgeführt', 'Detailed planning', 'Detailplanung'] as $s)
if (preg_match('/\b' . preg_quote($s, '/') . '\s*$/i', $rest)) {
$status = $s; $rest = trim(preg_replace('/\b' . preg_quote($s, '/') . '\s*$/i', '', $rest)); break;
}
$lp = $li = '';
if (preg_match_all('/(\d+)\s*m\b/', $rest, $lens, PREG_SET_ORDER)) {
$lp = ($lens[0][1] ?? '') . ' m'; $li = ($lens[1][1] ?? '') . ' m';
$rest = preg_replace('/\d+\s*m\b/', '', $rest);
}
$result[] = ['cable_id' => trim($p[1]), 'type' => trim(preg_replace('/\s+/', ' ', $rest)),
'laenge_plan' => $lp, 'laenge_ist' => $li, 'status' => $status];
}
}
return $result;
}
private function extractMapFromPdf(string $pdf): ?string {
$tmp = tempnam(sys_get_temp_dir(), 'aha_'); file_put_contents($tmp, $pdf);
$out = tempnam(sys_get_temp_dir(), 'aha_img_'); unlink($out);
exec(sprintf('pdftoppm -png -f 1 -l 1 -r 150 %s %s 2>&1', escapeshellarg($tmp), escapeshellarg($out)), $_, $ret);
@unlink($tmp);
$outFile = file_exists("$out-1.png") ? "$out-1.png" : "$out.png";
if ($ret !== 0 || !file_exists($outFile)) return null;
$img = @imagecreatefromstring(file_get_contents($outFile)); @unlink($outFile);
if (!$img) return null;
$h = imagesy($img); $cropY = (int)($h * 0.42); $cropH = (int)($h * 0.84) - $cropY;
$cropped = imagecrop($img, ['x' => 60, 'y' => $cropY, 'width' => imagesx($img) - 90, 'height' => $cropH]);
imagedestroy($img); if (!$cropped) return null;
ob_start(); imagepng($cropped, null, 6); $content = ob_get_clean(); imagedestroy($cropped);
return $content;
}
public function getProperty($name) {
if($this->$name == null) {

View File

@@ -13,6 +13,7 @@ class RimoWorkorderController extends mfBaseController {
protected function downloadAhaAction() {
$workorder_id = $this->request->id;
$inline = !empty($this->request->inline);
if(!$workorder_id || $workorder_id < 1) {
header("HTTP/1.1 400 Bad Request");
@@ -34,10 +35,33 @@ class RimoWorkorderController extends mfBaseController {
exit;
}
header("Content-type: text/pdf");
header('Content-disposition: attachment; filename="'.$workorder->rimo_name.'_AHA.pdf"');
$filename = $workorder->rimo_name.'_AHA.pdf';
$disposition = $inline ? 'inline' : 'attachment';
header("Content-type: application/pdf");
header('Content-disposition: '.$disposition.'; filename="'.$filename.'"');
echo $return;
exit;
}
protected function parseAhaAction() {
header('Content-Type: application/json');
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? $this->request->id ?? null;
if (!$id || $id < 1) {
echo json_encode(['success' => false, 'message' => 'Invalid workorder id.']);
exit;
}
$wo = new RimoWorkorder($id);
if (!$wo->id) {
echo json_encode(['success' => false, 'message' => 'RimoWorkorder nicht gefunden.']);
exit;
}
echo json_encode($wo->parseAha());
exit;
}
}

View File

@@ -12,6 +12,9 @@ class UserController extends mfBaseController
{
private $me;
// User IDs allowed to manage (add/edit/delete) users
private const ALLOWED_USER_MANAGER_IDS = [2, 5, 9, 6, 89, 145, 24];
protected function init($request = null)
{
$this->needlogin = true;
@@ -24,6 +27,11 @@ class UserController extends mfBaseController
if ($_SERVER['REQUEST_METHOD'] === 'POST') $this->postData = json_decode(file_get_contents('php://input'), true);
}
private function canManageUsers(): bool
{
return in_array($this->me->id, self::ALLOWED_USER_MANAGER_IDS);
}
protected function indexAction($request)
{
if (!$this->isAdmin()) {
@@ -32,6 +40,7 @@ class UserController extends mfBaseController
Helper::renderVue($this, "User", "Benutzer", [
"IS_ADMIN" => $this->me->isAdmin(),
"CAN_MANAGE_USERS" => $this->canManageUsers(),
"USERS" => array_map(fn($user) => [
"username" => $user->username,
"name" => $user->name,
@@ -53,6 +62,7 @@ class UserController extends mfBaseController
protected function formAction() {
if (!$this->isAdmin()) $this->redirect("Dashboard");
if (!$this->canManageUsers()) $this->redirect("User");
$id = $this->request->id;
$user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User();
@@ -178,6 +188,7 @@ class UserController extends mfBaseController
protected function generateApikeyAction($request) {
if (!$this->isAdmin()) $this->redirect("Dashboard");
if (!$this->canManageUsers()) $this->redirect("User");
$id = $request['id'];
if (!is_numeric($id) || $id < 1) {
@@ -207,6 +218,11 @@ class UserController extends mfBaseController
unset($r->address_id);
}
// Only allowed users can create/edit other users
if ($this->isAdmin() && !$this->canManageUsers()) {
self::redirect('User');
}
if (!$id && !$r->username) self::redirect('User');
$user = new User($id);
@@ -569,7 +585,7 @@ class UserController extends mfBaseController
}
protected function impersonateAction() {
if(!$this->me->isAdmin() || $this->me->address_id != 1) {
if(!$this->me->isAdmin() || $this->me->address_id != 1 || !$this->canManageUsers()) {
header("HTTP/1.1 403 Forbidden");
exit;
}
@@ -590,6 +606,10 @@ class UserController extends mfBaseController
protected function sendLoginEmailAction()
{
if (!$this->canManageUsers()) {
self::sendError("Keine Berechtigung.");
}
$id = $this->request->id;
if (!$id || !is_numeric($id)) {
self::sendError("Benutzer-ID fehlt oder ist ungültig.");

View File

@@ -114,8 +114,11 @@ class VoicenumberController extends mfBaseController {
$number_data['port_out_date'] = self::dateToTimestamp($r->port_out_date);
}
if($r->disabled === "1") {
$number_data['disabled'] = 1;
if($r->disabled == "1") {
if(!$number->disabled) {
$number_data['disabled'] = date('U');
$number_data['disabled_by'] = $this->me->id;
}
switch($r->disabled_reason) {
case "ported_out":
$number_data['disabled_reason'] = "ported_out";
@@ -123,6 +126,9 @@ class VoicenumberController extends mfBaseController {
case "ported_back":
$number_data['disabled_reason'] = "ported_back";
break;
case "contract_cancelled":
$number_data['disabled_reason'] = "contract_cancelled";
break;
case "legacy":
$number_data['disabled_reason'] = "legacy";
break;

View File

@@ -17,6 +17,7 @@ class VoicenumberModel {
public $ported_out;
public $disabled;
public $disabled_reason;
public $disabled_by;
public $enable_on_date;
public $comment;

View File

@@ -13,7 +13,7 @@ class WarehouseArticleController extends TTCrud {
['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]],
['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']],
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]],
['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false],
['key' => 'vatgroup_id', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 2, 'text' => 'Dienstleistungen'], ['value' => 3, 'text' => 'Handelswaren']]], 'table' => false],
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'warningAmount', 'text' => 'Warnmenge', 'required' => true,'modal' => ['type' => 'number'], 'table' => false],
@@ -111,7 +111,7 @@ class WarehouseArticleController extends TTCrud {
if ($categoryId) {
$category = WarehouseCategory::get($categoryId);
if ($category && $category->articleNumberPrefix) {
$expectedPrefix = $category->articleNumberPrefix;
$expectedPrefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
$articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix));
if ($articlePrefix !== $expectedPrefix) {
self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen.");
@@ -178,7 +178,7 @@ class WarehouseArticleController extends TTCrud {
if (!$category) self::sendError("Kategorie nicht gefunden");
if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix");
$prefix = $category->articleNumberPrefix;
$prefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
$db = FronkDB::singleton();
// Get all existing article numbers with this prefix, sorted
@@ -253,7 +253,7 @@ class WarehouseArticleController extends TTCrud {
];
$pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars);
$wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$filename = $pdf->render($wkhtmltopdfArgs);
@@ -262,4 +262,30 @@ class WarehouseArticleController extends TTCrud {
readfile($filename);
die();
}
protected function printLabelsByCategoryAction() {
$categoryId = intval($this->request->categoryId);
if (!$categoryId) {
self::sendError("Kategorie nicht angegeben", 400);
}
$articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']);
if (empty($articles)) {
self::sendError("Keine Artikel in dieser Kategorie gefunden", 404);
}
$pdf_vars = ['articles' => $articles];
$pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars);
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$filename = $pdf->render($wkhtmltopdfArgs);
$category = WarehouseCategory::get($categoryId);
$categoryName = $category ? $category->name : 'category-' . $categoryId;
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"');
readfile($filename);
die();
}
}

View File

@@ -17,7 +17,7 @@ class WarehouseArticleModel extends TTCrudBaseModel {
public ?int $isEndOfLife;
public string $unit;
public ?int $isSerialDocumentation;
public int $revenueAccount;
public int $vatgroup_id;
public int $create;
public int $createBy;
}

View File

@@ -5,7 +5,7 @@ class WarehouseCategory extends TTCrudBaseModel {
public string $description;
public ?string $articleNumberPrefix;
public int $create;
public int $create_by;
public ?int $create_by;
public ?int $edit;
public ?int $edit_by;
}

View File

@@ -16,7 +16,39 @@ class WarehouseCategoryController extends TTCrud {
];
// @formatter:on
protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']];
protected array $additionalActions = [
['key' => 'printLabels', 'title' => 'Labels drucken', 'class' => 'fas fa-print text-primary'],
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']
];
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
public function printLabelsAction() {
$categoryId = intval($this->request->id);
$articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']);
if (empty($articles)) {
echo "Keine Artikel in dieser Kategorie.";
die();
}
$pdf_vars = [
'articles' => $articles
];
$pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars);
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$filename = $pdf->render($wkhtmltopdfArgs);
$category = WarehouseCategory::get($categoryId);
$categoryName = $category ? $category->name : 'category-' . $categoryId;
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"');
readfile($filename);
die();
}
protected function beforeCreate(): bool {
$this->postData['articleNumberPrefix'] = $this->getNextFreePrefix();

View File

@@ -0,0 +1,245 @@
<?php
class WarehouseMovementController extends TTCrud {
protected string $headerTitle = 'Lagerbewegung';
protected string $createText = 'Bewegung erstellen';
protected bool $reopenOnCreate = true;
protected array $columns = [
['key' => 'movementNumber', 'text' => 'Bewegungs-Nr.', 'required' => false,
'modal' => false,
'table' => ['priority' => 10]],
['key' => 'movementType', 'text' => 'Typ', 'required' => true,
'modal' => ['type' => 'select', 'items' => []],
'table' => ['priority' => 9, 'filter' => 'iconSelect', 'filterOptions' => [
['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'fas fa-plus-circle text-success'],
['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'fas fa-minus-circle text-danger'],
['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'fas fa-edit text-warning'],
]]],
['key' => 'articleId', 'text' => 'Artikel', 'required' => true,
'modal' => ['type' => 'articleSelect'],
'table' => ['priority' => 8, 'sortable' => false, 'filter' => 'text']],
['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true,
'modal' => ['type' => 'select', 'items' => []],
'table' => ['priority' => 7, 'filter' => 'select']],
['key' => 'quantity', 'text' => 'Menge', 'required' => true,
'modal' => ['type' => 'number', 'step' => '0.01', 'min' => '0.01'],
'table' => ['priority' => 6, 'filter' => false]],
['key' => 'quantityBefore', 'text' => 'Bestand vorher', 'required' => false,
'modal' => false,
'table' => ['priority' => 5, 'filter' => false]],
['key' => 'quantityAfter', 'text' => 'Bestand nachher', 'required' => false,
'modal' => false,
'table' => ['priority' => 4, 'filter' => false]],
['key' => 'reasonCategory', 'text' => 'Grund', 'required' => true,
'modal' => ['type' => 'select', 'items' => [], 'dependsOn' => 'movementType'],
'table' => ['priority' => 3, 'filter' => false]],
['key' => 'note', 'text' => 'Notiz', 'required' => false,
'modal' => ['type' => 'textarea'],
'table' => ['priority' => 2, 'filter' => false]],
['key' => 'create', 'text' => 'Erstellt', 'required' => false,
'modal' => false,
'table' => ['priority' => 1, 'filter' => 'dateRange']],
];
protected array $additionalActions = [];
protected array $permissionCheck = ['WarehouseUser'];
protected array $infoMessages = [
'create' => 'Lagerbewegung wurde erstellt',
'update' => 'Lagerbewegung wurde aktualisiert',
'delete' => 'Lagerbewegung wurde gelöscht',
'noChanges' => 'Keine Änderungen',
];
public function prepareCrudConfig() {
// Populate movement type dropdown
$movementTypes = [
['value' => 'IN', 'text' => 'Einbuchung'],
['value' => 'OUT', 'text' => 'Ausbuchung'],
['value' => 'ADJUSTMENT', 'text' => 'Korrektur'],
];
// Populate locations dropdown (Office + Außenlager only)
$allLocations = WarehouseLocationModel::getAll();
$locations = [];
foreach ($allLocations as $location) {
$title = strtolower($location->title);
if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') {
$locations[] = ['value' => $location->id, 'text' => $location->title];
}
}
// Get all reason categories for initial load
$allReasons = WarehouseMovementModel::getReasonCategories();
$reasonItems = [];
foreach ($allReasons as $type => $categories) {
foreach ($categories as $key => $label) {
$reasonItems[] = ['value' => $key, 'text' => $label, 'group' => $type];
}
}
foreach ($this->columns as &$col) {
if ($col['key'] === 'movementType') {
$col['modal']['items'] = $movementTypes;
}
if ($col['key'] === 'warehouseLocationId') {
$col['modal']['items'] = $locations;
$col['table']['filterOptions'] = $locations;
}
if ($col['key'] === 'reasonCategory') {
$col['modal']['items'] = $reasonItems;
}
}
$this->additionalJSVariables['REASON_CATEGORIES'] = $allReasons;
}
protected function beforeCreate(): bool {
// Validate required fields
$movementType = $this->postData['movementType'] ?? '';
$articleId = intval($this->postData['articleId'] ?? 0);
$locationId = intval($this->postData['warehouseLocationId'] ?? 0);
$quantity = floatval($this->postData['quantity'] ?? 0);
if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) {
$this->returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']);
return false;
}
if ($articleId <= 0) {
$this->returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']);
return false;
}
if ($locationId <= 0) {
$this->returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
return false;
}
if ($quantity <= 0) {
$this->returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
return false;
}
// Find or create WarehouseItem for this article at this location
$db = FronkDB::singleton();
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
$currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
// Calculate new quantity based on movement type
// Note: Negative stock is allowed (items can be taken out even if stock is 0)
switch ($movementType) {
case 'IN':
$newQty = $currentQty + $quantity;
break;
case 'OUT':
$newQty = $currentQty - $quantity;
// Negative stock is allowed - no validation needed
break;
case 'ADJUSTMENT':
// For adjustment, quantity is the new absolute value
$newQty = $quantity;
break;
default:
$newQty = $currentQty;
}
// Store before/after quantities
$this->postData['quantityBefore'] = $currentQty;
$this->postData['quantityAfter'] = $newQty;
$this->postData['userId'] = $this->user->id;
// Update or create WarehouseItem
if ($warehouseItem) {
$db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
$this->postData['warehouseItemId'] = $warehouseItem->id;
} else {
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
$this->postData['warehouseItemId'] = $db->insert_id();
}
return true;
}
protected function afterCreate($postData) {
// Generate movement number
$movement = WarehouseMovementModel::get($postData['id']);
if ($movement) {
$movementNumber = WarehouseMovementModel::generateMovementNumber();
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movement->id}");
}
}
protected function customRowsHandler($rows) {
return array_map(fn($row) => $this->formatRow((array)$row), $rows);
}
protected function formatRow($row) {
$rawType = $row['movementType'];
if (!empty($row['articleId'])) {
$article = WarehouseArticleModel::get($row['articleId']);
if ($article) {
$row['articleId'] = "<strong>{$article->articleNumber}</strong><br><small class='text-muted'>{$article->title}</small>";
}
}
$row['quantityBefore'] = $row['quantityBefore'] !== null ? number_format((float)$row['quantityBefore'], 2, ',', '.') : '-';
$row['quantityAfter'] = $row['quantityAfter'] !== null ? number_format((float)$row['quantityAfter'], 2, ',', '.') : '-';
$row['quantity'] = number_format((float)$row['quantity'], 2, ',', '.');
$allCategories = WarehouseMovementModel::getReasonCategories();
$row['reasonCategory'] = $allCategories[$rawType][$row['reasonCategory']] ?? $row['reasonCategory'];
return $row;
}
/**
* Get reason categories for a specific movement type
*/
protected function getReasonCategoriesAction() {
$type = $this->request->type ?? null;
$categories = WarehouseMovementModel::getReasonCategories($type);
if ($type && is_array($categories)) {
$items = [];
foreach ($categories as $key => $label) {
$items[] = ['value' => $key, 'text' => $label];
}
self::returnJson(['success' => true, 'categories' => $items]);
} else {
self::returnJson(['success' => true, 'categories' => $categories]);
}
}
/**
* Get current stock for an article at a location
*/
protected function getCurrentStockAction() {
$articleId = intval($this->request->articleId ?? 0);
$locationId = intval($this->request->locationId ?? 0);
if (!$articleId || !$locationId) {
self::returnJson(['success' => false, 'currentStock' => 0]);
return;
}
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0;
self::returnJson(['success' => true, 'currentStock' => $currentStock]);
}
}

View File

@@ -0,0 +1,140 @@
<?php
class WarehouseMovementModel extends TTCrudBaseModel {
public int $id;
public ?string $movementNumber = null;
public string $movementType;
public int $articleId;
public int $warehouseLocationId;
public ?int $warehouseItemId = null;
public float $quantity;
public ?float $quantityBefore = null;
public ?float $quantityAfter = null;
public string $reasonCategory;
public ?string $note = null;
public ?int $linkedOrderId = null;
public int $userId;
public int $createBy;
public int $create;
public static function generateMovementNumber(): string {
$year = date('Y');
$prefix = "LB{$year}-X";
$db = FronkDB::singleton();
$result = $db->query("SELECT movementNumber FROM WarehouseMovement
WHERE movementNumber LIKE '{$prefix}%'
ORDER BY movementNumber DESC LIMIT 1");
if ($row = $result->fetch_assoc()) {
$lastNumber = intval(substr($row['movementNumber'], -6));
$nextNumber = $lastNumber + 1;
} else {
$nextNumber = 1;
}
return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT);
}
/**
* Get reason categories for a movement type
*/
public static function getReasonCategories(?string $type = null): array {
$categories = [
'IN' => [
'Warenlieferung' => 'Warenlieferung',
'Rueckgabe' => 'Rückgabe',
'Gefunden' => 'Gefunden/Inventurdifferenz',
'UmlagerungEingang' => 'Umlagerung (Eingang)',
'Erstbestand' => 'Erstbestand',
'Sonstiges' => 'Sonstiges'
],
'OUT' => [
'Verbrauch' => 'Verbrauch',
'Beschaedigung' => 'Beschädigung/Defekt',
'Verlust' => 'Verlust/Schwund',
'UmlagerungAusgang' => 'Umlagerung (Ausgang)',
'Entsorgung' => 'Entsorgung',
'Sonstiges' => 'Sonstiges'
],
'ADJUSTMENT' => [
'Inventurkorrektur' => 'Inventurkorrektur',
'Buchungsfehler' => 'Buchungsfehler',
'Systemkorrektur' => 'Systemkorrektur',
'SonstigeKorrektur' => 'Sonstige Korrektur'
]
];
if ($type && isset($categories[$type])) {
return $categories[$type];
}
return $categories;
}
/**
* Get movement type labels
*/
public static function getMovementTypes(): array {
return [
'IN' => 'Einbuchung',
'OUT' => 'Ausbuchung',
'ADJUSTMENT' => 'Korrektur'
];
}
public function getArticle(): ?WarehouseArticleModel {
return WarehouseArticleModel::get($this->articleId);
}
/**
* Get location object
*/
public function getLocation(): ?WarehouseLocationModel {
return WarehouseLocationModel::get($this->warehouseLocationId);
}
/**
* Get user who made the movement
*/
public function getUser(): ?UserModel {
return UserModel::get($this->userId);
}
/**
* Get warehouse item if linked
*/
public function getWarehouseItem(): ?WarehouseItemModel {
if (!$this->warehouseItemId) return null;
return WarehouseItemModel::get($this->warehouseItemId);
}
/**
* Get linked order if this movement was created from an order delivery
*/
public function getLinkedOrder(): ?WarehouseOrderModel {
if (!$this->linkedOrderId) return null;
return WarehouseOrderModel::get($this->linkedOrderId);
}
/**
* Get formatted movement type label
*/
public function getMovementTypeLabel(): string {
$types = self::getMovementTypes();
return $types[$this->movementType] ?? $this->movementType;
}
/**
* Get formatted reason category label
*/
public function getReasonCategoryLabel(): string {
$allCategories = self::getReasonCategories();
foreach ($allCategories as $typeCategories) {
if (isset($typeCategories[$this->reasonCategory])) {
return $typeCategories[$this->reasonCategory];
}
}
return $this->reasonCategory;
}
}

View File

@@ -56,7 +56,7 @@ class WarehouseOfferController extends TTCrud
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
$this->postData['status'] = 'new';
$this->postData['version'] = 1;
$this->postData['validity'] = 14;
$this->postData['validity'] = 31;
$this->postData['alternativePositions'] = json_encode([]);
return true;
}
@@ -366,10 +366,13 @@ class WarehouseOfferController extends TTCrud
$version = $this->request->version ?? null;
$offerData = null;
$versionDate = null; // Date when this version was created (for validity calculation)
if ($version) {
$historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version);
if ($historyEntry && !empty($historyEntry->data)) {
$offerData = json_decode($historyEntry->data);
$versionDate = $historyEntry->create; // Use version creation date
}
}
@@ -377,6 +380,10 @@ class WarehouseOfferController extends TTCrud
$offer = WarehouseOfferModel::get($id);
if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden');
$offerData = $offer;
// Get latest history entry for current version's date
$latestHistory = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $offer->version);
$versionDate = $latestHistory ? $latestHistory->create : $offer->create;
}
@@ -432,11 +439,12 @@ class WarehouseOfferController extends TTCrud
"alternativeTotal" => $alternativeTotal,
"offerNumber" => $offerData->offerNumber,
"offerDate" => $offerData->create,
"versionDate" => $versionDate ?? $offerData->create, // Date for validity calculation
"offerEditorName" => $editor ? $editor->name : 'Unbekannt',
"includeTax" => true,
"vatRate" => 0.20,
"offerText" => $offerData->notes ?? '',
"validity" => $offerData->validity ?? 14,
"validity" => $offerData->validity ?? 31,
"closingText" => $offerData->closingText ?? '',
"bank_iban" => TT_INVOICE_BANK_IBAN,
"bank_bic" => TT_INVOICE_BANK_BIC,

View File

@@ -406,9 +406,72 @@ $appendToBody
];
try {
$createdMovementIds = [];
// Create warehouse movements for delivery statuses
if (in_array($postData['status'], ['partiallyDelivered', 'fullyDelivered'])
&& isset($postData['deliveryData']) && is_array($postData['deliveryData'])) {
// Get location ID from request or use default (K1 Fladnitz 150)
$locationId = intval($postData['locationId'] ?? 0);
if ($locationId <= 0) {
// Default to K1 Fladnitz 150
$allLocations = WarehouseLocationModel::getAll();
$defaultLocation = null;
foreach ($allLocations as $loc) {
if ($loc->title === 'K1 Fladnitz 150') {
$defaultLocation = $loc;
break;
}
}
$locationId = $defaultLocation ? $defaultLocation->id : 1;
}
// Prepare delivery data with articleId from order positions
$positions = json_decode($order->positions, true) ?: [];
$deliveryDataWithArticleIds = [];
foreach ($postData['deliveryData'] as $index => $delivery) {
if (isset($positions[$index])) {
$delivery['articleId'] = $positions[$index]['article'];
}
$deliveryDataWithArticleIds[] = $delivery;
}
$createdMovementIds = $this->createMovementsForDelivery(
intval($postData['orderId']),
$deliveryDataWithArticleIds,
$locationId
);
if (!empty($createdMovementIds)) {
// Update order with linked movement IDs
$existingMovementIds = $order->linkedMovementIds
? json_decode($order->linkedMovementIds, true) : [];
$allMovementIds = array_merge($existingMovementIds, $createdMovementIds);
$orderAsArray['linkedMovementIds'] = json_encode($allMovementIds);
// Add movement info to log message
$fullLogMessage .= ($fullLogMessage ? "\n\n" : "") .
count($createdMovementIds) . " Lagerbewegung(en) erstellt.";
$log['message'] = trim($fullLogMessage);
}
}
// Store delivery note file IDs
if (!empty($postData['deliveryNoteFileIds'])) {
$existingFileIds = $order->deliveryNoteFileIds
? json_decode($order->deliveryNoteFileIds, true) : [];
$allFileIds = array_merge($existingFileIds, $postData['deliveryNoteFileIds']);
$orderAsArray['deliveryNoteFileIds'] = json_encode($allFileIds);
}
if ($postData['status'] !== 'noChanges') {
$orderAsArray['status'] = $postData['status'];
WarehouseOrderModel::update($orderAsArray);
} elseif (!empty($orderAsArray['linkedMovementIds']) || !empty($orderAsArray['deliveryNoteFileIds'])) {
// Update even if status didn't change but we added linked data
WarehouseOrderModel::update($orderAsArray);
}
// Only create a log entry if there's actually something to log
@@ -416,7 +479,11 @@ $appendToBody
WarehouseLogModel::create($log);
}
self::returnJson(['success' => true, 'message' => 'Log entry created']);
self::returnJson([
'success' => true,
'message' => 'Log entry created',
'createdMovementIds' => $createdMovementIds
]);
} catch (Exception $e) {
self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]);
}
@@ -485,6 +552,107 @@ $appendToBody
}
}
protected function createMovementsForDelivery(int $orderId, array $deliveryData, int $locationId): array {
$order = WarehouseOrderModel::get($orderId);
$createdMovementIds = [];
foreach ($deliveryData as $delivery) {
$deliveredAmount = floatval($delivery['amount']);
$articleId = intval($delivery['articleId']);
// Only create movements for items actually delivered
if ($deliveredAmount <= 0 || $articleId <= 0) {
continue;
}
// Find or create WarehouseItem for article + location
$existingItems = WarehouseItemModel::getAll([
'articleId' => $articleId,
'warehouseLocationId' => $locationId
]);
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
if (!$warehouseItem) {
// Create new warehouse item with zero quantity
$warehouseItemId = WarehouseItemModel::create([
'articleId' => $articleId,
'warehouseLocationId' => $locationId,
'quantity' => 0
]);
$warehouseItem = WarehouseItemModel::get($warehouseItemId);
}
$quantityBefore = $warehouseItem->quantity;
$quantityAfter = $quantityBefore + $deliveredAmount;
// Create warehouse movement
$movementData = [
'movementNumber' => WarehouseMovementModel::generateMovementNumber(),
'movementType' => 'IN',
'articleId' => $articleId,
'warehouseLocationId' => $locationId,
'warehouseItemId' => $warehouseItem->id,
'quantity' => $deliveredAmount,
'quantityBefore' => $quantityBefore,
'quantityAfter' => $quantityAfter,
'reasonCategory' => 'Warenlieferung',
'linkedOrderId' => $orderId,
'note' => "Lagereingang aus Bestellung {$order->orderNumber}",
'userId' => $this->user->id,
'createBy' => $this->user->id,
'create' => time()
];
$movementId = WarehouseMovementModel::create($movementData);
$createdMovementIds[] = $movementId;
// Update warehouse item quantity
$warehouseItem->quantity = $quantityAfter;
WarehouseItemModel::update((array)$warehouseItem);
}
return $createdMovementIds;
}
protected function getLinkedMovementsAction() {
$orderId = $this->request->orderId;
if (empty($orderId)) {
self::returnJson(['error' => 'Order ID is required']);
return;
}
$order = WarehouseOrderModel::get($orderId);
$linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
$movements = [];
foreach ($linkedMovementIds as $movementId) {
$movement = WarehouseMovementModel::get($movementId);
if ($movement) {
$article = $movement->getArticle();
$location = $movement->getLocation();
$movements[] = [
'id' => $movement->id,
'movementNumber' => $movement->movementNumber,
'quantity' => $movement->quantity,
'articleName' => $article ? $article->title : 'Unbekannt',
'locationName' => $location ? $location->title : 'Unbekannt',
'create' => $movement->create
];
}
}
self::returnJson($movements);
}
protected function getLocationsAction() {
$locations = WarehouseLocationModel::getAll();
$result = array_map(function($loc) {
return [
'value' => $loc->id,
'text' => $loc->title
];
}, $locations);
self::returnJson($result);
}
}

View File

@@ -32,6 +32,8 @@ class WarehouseOrderModel extends TTCrudBaseModel {
public string $delAddrPLZ;
public int $editor;
public ?string $note;
public ?string $linkedMovementIds = null;
public ?string $deliveryNoteFileIds = null;
public string $positions;
public ?int $sendShippingNote;
public int $create;

View File

@@ -6,7 +6,7 @@ class WarehouseShippingNoteController extends TTCrud {
//@formatter:off
protected array $columns = [
['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']],
['key' => 'shippingNoteNumber', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap', 'filter' => 'search']],
['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true],
['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']],
['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [
@@ -56,6 +56,7 @@ class WarehouseShippingNoteController extends TTCrud {
]);
$this->postData['positions'] = json_encode($this->postData['positions']);
if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']);
$this->postData['shippingNoteNumber'] = WarehouseShippingNoteModel::generateShippingNoteNumber();
return true;
}
@@ -130,7 +131,10 @@ class WarehouseShippingNoteController extends TTCrud {
// Get billing address info
$billingAddress = null;
if ($shippingNote->billingAddressId) {
$billingAddress = Address::getOne($shippingNote->billingAddressId);
$billingAddress = new Address($shippingNote->billingAddressId);
if (!$billingAddress->id) {
$billingAddress = null;
}
}
// Determine price type ONCE (not in loop for performance)
@@ -486,10 +490,6 @@ class WarehouseShippingNoteController extends TTCrud {
"bank_bank" => TT_INVOICE_BANK_BANK,
"bank_owner" => TT_INVOICE_BANK_OWNER];
// Replace placeholders in header
// create shipping note in this format LS2024-X0001
// pad number on the left side with zeros
$shippingNoteNumber = "LS" . date("Y", $shippingNote->create) . "-" . str_pad($shippingNote->id, 4, "0", STR_PAD_LEFT);
$headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html");
$headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml);
$headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $headerHtml);
@@ -504,7 +504,7 @@ class WarehouseShippingNoteController extends TTCrud {
$headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml);
$headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml);
$headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml);
$headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNoteNumber, $headerHtml);
$headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNote->shippingNoteNumber, $headerHtml);
$headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml);
$headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html";

View File

@@ -2,6 +2,7 @@
class WarehouseShippingNoteModel extends TTCrudBaseModel {
public int $id;
public ?string $shippingNoteNumber = null;
public ?int $billingAddressId;
public ?string $type;
public ?string $metadata;
@@ -21,4 +22,23 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel {
public ?int $eShopOrderId;
public ?int $create;
public ?int $createBy;
public static function generateShippingNoteNumber(): string {
$year = date('Y');
$prefix = "LS{$year}-X";
$db = FronkDB::singleton();
$result = $db->query("SELECT shippingNoteNumber FROM WarehouseShippingNote
WHERE shippingNoteNumber LIKE '{$prefix}%'
ORDER BY shippingNoteNumber DESC LIMIT 1");
if ($row = $result->fetch_assoc()) {
$lastNumber = intval(substr($row['shippingNoteNumber'], -4));
$nextNumber = $lastNumber + 1;
} else {
$nextNumber = 1;
}
return $prefix . str_pad((string)$nextNumber, 4, '0', STR_PAD_LEFT);
}
}

View File

@@ -16,6 +16,7 @@ class WorkorderModel extends TTCrudBaseModel
public ?string $additionalInfo;
public ?string $cableLength;
public ?string $cableType;
public ?string $metadata;
public int $create;
public int $createBy;
@@ -199,4 +200,62 @@ class WorkorderModel extends TTCrudBaseModel
$result = $db->query($sql);
return $result ? $result->fetch_assoc()['count'] : 0;
}
public static function getTechnicalData(int $workorderId): ?array {
$workorder = self::get($workorderId);
if (!$workorder || !$workorder->preorderId) return null;
$preorder = new Preorder($workorder->preorderId);
if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null;
$wohneinheit = $preorder->adb_wohneinheit;
if (!$wohneinheit) return null;
$defaultCluster = '';
if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) {
$defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? '';
}
$patchposition = [
'equipmentName' => $wohneinheit->getPatchEqString(),
'equipmentPort' => $wohneinheit->patch_port,
'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster,
'shelf' => $wohneinheit->patch_shelf,
'module' => $wohneinheit->patch_module,
];
// Get dropcable data from metadata
$dropkabelData = [];
$ahaParsed = null;
$mapFile = null;
if (!empty($workorder->metadata)) {
$metadata = json_decode($workorder->metadata, true);
if (!empty($metadata['dropcable'])) {
$ahaParsed = $metadata['dropcable']['parsed_at'] ?? null;
$dropkabelData = $metadata['dropcable']['entries'] ?? [];
if ($mapFileId = $metadata['dropcable']['map_file_id'] ?? null) {
$file = new File($mapFileId);
if ($file->id) {
$mapFile = ['id' => $file->id, 'name' => $file->name, 'download_url' => '/File/show?id=' . $file->id];
}
}
}
}
$rimoWorkorders = [];
if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) {
foreach ($wohneinheit->rimo_workorders as $wo) {
$rimoWorkorders[] = [
'id' => $wo->id, 'rimoName' => $wo->rimo_name, 'rimoId' => $wo->rimo_id,
'rimoStatus' => $wo->rimo_status, 'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id,
];
}
}
return [
'patchposition' => $patchposition,
'rimoWorkorders' => $rimoWorkorders,
'dropcable' => ['parsed_at' => $ahaParsed, 'entries' => $dropkabelData, 'map_file' => $mapFile],
];
}
}

View File

@@ -54,6 +54,7 @@ class WorkorderAdminController extends WorkorderBaseController
public function indexAction()
{
$this->createWorkordersFromPreorders();
$this->autoCompleteDocumentedWorkorders();
$this->archiveWorkorders();
parent::indexAction();
}

View File

@@ -60,6 +60,10 @@ class WorkorderBaseController extends TTCrud
$translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap);
}
// Auto-parse AHA if enabled and not yet parsed
if ($tenantConfig?->showTechnicalData) {
RimoWorkorder::autoParseForWorkorder((int)$this->request->workorderId);
}
$responseDocs = [];
$typeCounts = [];
@@ -141,6 +145,13 @@ class WorkorderBaseController extends TTCrud
return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]) ?? null;
}
/**
* Retrieves technical data (patchposition and AHA Blatt info) for a workorder.
*/
protected function getTechnicalData(int $workorderId): ?array {
return WorkorderModel::getTechnicalData($workorderId);
}
//region BACKGROUND TASKS
/**
* Creates new workorders from preorders based on tenant configurations.
@@ -272,5 +283,50 @@ class WorkorderBaseController extends TTCrud
}
file_put_contents($lockFile, time());
}
protected function autoCompleteDocumentedWorkorders()
{
$lockFile = TEMP_DIR . "/task_auto_complete_workorders.lock";
if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) return;
foreach (WorkorderTenantConfigModel::getAll() as $config) {
$filter = json_decode($config->autoCompleteFilter ?? '', true);
if (empty($filter)) continue;
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
if (empty($networks)) continue;
$networkIds = array_map(fn($n) => $n->id, $networks);
$campaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
if (empty($campaignIds)) continue;
$filter['preordercampaign_id'] = $campaignIds;
$matchingPreorders = PreorderModel::searchActive($filter);
if (empty($matchingPreorders)) continue;
$preorderIds = array_map(fn($p) => $p->id, $matchingPreorders);
$preorderIdSet = array_flip($preorderIds);
$workorders = WorkorderModel::getAll([
'status' => ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved', 'documented', 'archived'],
'preorderId' => $preorderIds
]);
foreach ($workorders as $workorder) {
if (!isset($preorderIdSet[$workorder->preorderId])) continue;
$oldStatus = $workorder->status;
$workorder->status = 'completed';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => 'Dokumentation automatisch akzeptiert (Auto-Complete Filter).',
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'),
'create' => time(),
'createBy' => 1,
]);
}
}
file_put_contents($lockFile, time());
}
//endregion
}

View File

@@ -121,6 +121,23 @@ class WorkorderCompanyController extends WorkorderBaseController {
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']);
}
protected function clearAppointmentAction() {
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A';
$workorder->appointmentDate = null;
$workorder->status = 'assigned';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Termin gelöscht (war: {$oldDateFormatted}).",
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gelöscht.']);
}
protected function requestInterventionAction() {
if (empty($this->postData['workorderId']) || empty($this->postData['journalText'])) self::sendError("Erforderliche Felder fehlen.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
@@ -167,14 +184,23 @@ class WorkorderCompanyController extends WorkorderBaseController {
self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']);
return;
}
self::returnJson([
$response = [
'success' => true,
'documentationTypes' => json_decode($tenantConfig->documentationTypes, true),
'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired,
'interventionTypes' => json_decode($tenantConfig->interventionTypes, true),
'requireCableLength' => $tenantConfig->requireCableLength,
'requireCableType' => $tenantConfig->requireCableType
]);
'requireCableType' => $tenantConfig->requireCableType,
'showTechnicalData' => (bool)$tenantConfig->showTechnicalData,
'tiefbauSeesNormalDocs' => (bool)$tenantConfig->tiefbauSeesNormalDocs,
];
if ($tenantConfig->showTechnicalData) {
$response['technicalData'] = $this->getTechnicalData((int)$this->request->workorderId);
}
self::returnJson($response);
}
protected function uploadDocumentationAction() {

View File

@@ -0,0 +1,304 @@
<?php
class WorkorderDashboardController extends TTCrud
{
protected string $headerTitle = 'Arbeitsaufträge Dashboard';
protected bool $createText = false;
protected array $columns = [];
protected array $permissionCheck = ['RMLAdmin'];
protected array $additionalHead = [
"<link rel='stylesheet' href='/js/pages/WorkorderDashboard/WorkorderDashboard.css'>",
"<script src='https://cdn.jsdelivr.net/npm/chart.js'></script>",
"<script src='https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js'></script>",
"<script src='https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js'></script>"
];
protected array $statusLabels = [
'new' => 'Neu', 'assigned' => 'Zugewiesen', 'scheduled' => 'Geplant', 'in_progress' => 'In Bearbeitung',
'correction_requested' => 'Korrektur angefordert', 'intervention_required' => 'Eingriff erforderlich',
'civil_engineering_required' => 'Tiefbau benötigt', 'civil_engineering_completed' => 'Tiefbau abgeschlossen',
'problem_solved' => 'Problem gelöst', 'documented' => 'Dokumentiert', 'completed' => 'Abgeschlossen',
'charged' => 'Verrechnet', 'cancelled' => 'Abgebrochen', 'archived' => 'Archiviert',
];
protected array $statusColors = [
'new' => '#3b82f6', 'assigned' => '#06b6d4', 'scheduled' => '#8b5cf6', 'in_progress' => '#f59e0b',
'correction_requested' => '#ef4444', 'intervention_required' => '#dc2626', 'civil_engineering_required' => '#ea580c',
'civil_engineering_completed' => '#65a30d', 'problem_solved' => '#22c55e', 'documented' => '#14b8a6',
'completed' => '#10b981', 'charged' => '#8b5cf6', 'cancelled' => '#6b7280', 'archived' => '#9ca3af',
];
protected function indexAction()
{
$this->layout()->set('additionalHead', $this->additionalHead);
Helper::renderVue($this, 'WorkorderDashboard', $this->headerTitle, []);
}
protected function getFilterOptionsAction()
{
if ($this->user->isAdmin()) {
$tenants = WorkorderTenantConfigModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
} else {
$tenants = WorkorderTenantConfigModel::getAll(['addressId' => $this->user->address_id], null, 0, ['key' => 'name', 'order' => 'ASC']);
}
$companies = WorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
self::returnJson([
'tenants' => array_map(fn($t) => ['value' => $t->id, 'text' => $t->name], $tenants),
'companies' => array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies),
'statuses' => array_map(fn($key, $label) => ['value' => $key, 'text' => $label], array_keys($this->statusLabels), $this->statusLabels),
'campaigns' => []
]);
}
protected function getCampaignsForTenantAction()
{
$tenantId = $this->postData['tenantId'] ?? null;
if (!$tenantId || !($config = WorkorderTenantConfigModel::get($tenantId))) {
self::returnJson([]);
return;
}
if (!$this->user->isAdmin() && $config->addressId != $this->user->address_id) {
self::returnJson([]);
return;
}
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
if (empty($networks)) {
self::returnJson([]);
return;
}
$campaigns = PreordercampaignModel::search(['network_id' => array_map(fn($n) => $n->id, $networks)]);
$options = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $campaigns);
usort($options, fn($a, $b) => strcmp($a['text'], $b['text']));
self::returnJson($options);
}
protected function getDashboardDataAction()
{
$tenantId = $this->postData['tenantId'] ?? null;
$dateFrom = $this->postData['dateFrom'] ?? null;
$dateTo = $this->postData['dateTo'] ?? null;
$companyIds = $this->postData['companyIds'] ?? [];
$statuses = $this->postData['statuses'] ?? [];
$campaignIds = $this->postData['campaignIds'] ?? [];
if (!$tenantId) self::sendError('Mandant muss ausgewählt werden.');
$config = WorkorderTenantConfigModel::get($tenantId);
if (!$config) self::sendError('Mandant nicht gefunden.');
if (!$this->user->isAdmin() && $config->addressId != $this->user->address_id) self::sendError('Keine Berechtigung für diesen Mandanten.');
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
$tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => array_map(fn($n) => $n->id, $networks)]));
if (empty($tenantCampaignIds)) {
self::returnJson($this->getEmptyDashboardData());
return;
}
if (!empty($campaignIds)) $tenantCampaignIds = array_intersect($tenantCampaignIds, $campaignIds);
$db = FronkDB::singleton();
$whereConditions = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")"];
if ($dateFrom) $whereConditions[] = "w.`create` >= " . intval($dateFrom);
if ($dateTo) $whereConditions[] = "w.`create` <= " . intval($dateTo);
if (!empty($companyIds)) $whereConditions[] = "w.companyId IN (" . implode(',', array_map('intval', $companyIds)) . ")";
if (!empty($statuses)) $whereConditions[] = "w.status IN (" . implode(',', array_map(fn($s) => "'" . $db->escape($s) . "'", $statuses)) . ")";
$whereClause = implode(' AND ', $whereConditions);
self::returnJson([
'kpis' => $this->getKPIs($db, $whereClause, $tenantCampaignIds, $dateFrom, $dateTo),
'statusDistribution' => $this->getStatusDistribution($db, $whereClause),
'companyPerformance' => $this->getCompanyPerformance($db, $whereClause),
'timeTrends' => $this->getTimeTrends($db, $tenantCampaignIds, $dateFrom, $dateTo, $companyIds),
'companyStatusCampaign' => $this->getCompanyStatusCampaign($db, $whereClause),
'interventionRates' => $this->getInterventionRates($db, $whereClause),
'statusTransitions' => $this->getStatusTransitions($db, $tenantCampaignIds, $dateFrom, $dateTo),
]);
}
private function getKPIs($db, $whereClause, $tenantCampaignIds, $dateFrom, $dateTo): array
{
$total = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause")->fetch_assoc()['c'] ?? 0;
$offen = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status NOT IN ('completed', 'charged', 'archived')")->fetch_assoc()['c'] ?? 0;
$terminisiert = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status NOT IN ('completed', 'charged', 'archived') AND w.appointmentDate IS NOT NULL")->fetch_assoc()['c'] ?? 0;
$issues = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status IN ('intervention_required', 'correction_requested')")->fetch_assoc()['c'] ?? 0;
return [
'total' => (int)$total,
'offen' => (int)$offen,
'terminisiert' => (int)$terminisiert,
'issues' => (int)$issues,
'interventionRate' => $total > 0 ? round(($issues / $total) * 100, 1) : 0,
'avgCompletionDays' => $this->calculateAvgCompletionTime($db, $tenantCampaignIds),
];
}
private function calculateAvgCompletionTime($db, $tenantCampaignIds): ?float
{
$sql = "SELECT w.id,
MIN(CASE WHEN wj.statusChange LIKE '%Zugewiesen%' OR wj.statusChange LIKE '%-> Zugewiesen' THEN wj.`create` END) as assigned_time,
MIN(CASE WHEN wj.statusChange LIKE '%-> Abgeschlossen%' THEN wj.`create` END) as completed_time
FROM thetool.Workorder w
JOIN thetool.Preorder p ON w.preorderId = p.id
JOIN thetool.WorkorderJournal wj ON w.id = wj.workorderId
WHERE p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ") AND w.status IN ('completed', 'charged')
GROUP BY w.id HAVING assigned_time IS NOT NULL AND completed_time IS NOT NULL";
$result = $db->query($sql);
$totalDays = $count = 0;
while ($row = $result->fetch_assoc()) {
if ($row['completed_time'] > $row['assigned_time']) {
$totalDays += ($row['completed_time'] - $row['assigned_time']) / 86400;
$count++;
}
}
return $count > 0 ? round($totalDays / $count, 1) : null;
}
private function getStatusDistribution($db, $whereClause): array
{
$result = $db->query("SELECT w.status, COUNT(*) as count FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause GROUP BY w.status ORDER BY count DESC");
$distribution = [];
while ($row = $result->fetch_assoc()) {
$distribution[] = [
'status' => $row['status'],
'label' => $this->statusLabels[$row['status']] ?? $row['status'],
'count' => (int)$row['count'],
'color' => $this->statusColors[$row['status']] ?? '#6b7280',
];
}
return $distribution;
}
private function getCompanyPerformance($db, $whereClause): array
{
$sql = "SELECT wc.name as company, wc.id as companyId,
SUM(CASE WHEN w.status IN ('completed', 'charged') THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN w.status IN ('new', 'assigned', 'scheduled', 'in_progress', 'documented') THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN w.status IN ('intervention_required', 'correction_requested') THEN 1 ELSE 0 END) as issues,
COUNT(*) as total
FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
WHERE $whereClause AND w.companyId IS NOT NULL GROUP BY wc.id, wc.name ORDER BY total DESC";
$result = $db->query($sql);
$performance = [];
while ($row = $result->fetch_assoc()) {
$performance[] = [
'company' => $row['company'] ?? 'Nicht zugewiesen',
'companyId' => (int)$row['companyId'],
'completed' => (int)$row['completed'],
'pending' => (int)$row['pending'],
'issues' => (int)$row['issues'],
'total' => (int)$row['total'],
];
}
return $performance;
}
private function getTimeTrends($db, $tenantCampaignIds, $dateFrom, $dateTo, $companyIds): array
{
$where = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")"];
if ($dateFrom) $where[] = "w.`create` >= " . intval($dateFrom);
if ($dateTo) $where[] = "w.`create` <= " . intval($dateTo);
if (!empty($companyIds)) $where[] = "w.companyId IN (" . implode(',', array_map('intval', $companyIds)) . ")";
$sql = "SELECT DATE(FROM_UNIXTIME(w.`create`)) as date, COUNT(*) as created,
SUM(CASE WHEN w.status IN ('completed', 'charged') THEN 1 ELSE 0 END) as completed
FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
WHERE " . implode(' AND ', $where) . " GROUP BY DATE(FROM_UNIXTIME(w.`create`)) ORDER BY date ASC";
$result = $db->query($sql);
$trends = [];
while ($row = $result->fetch_assoc()) {
$trends[] = ['date' => $row['date'], 'created' => (int)$row['created'], 'completed' => (int)$row['completed']];
}
return $trends;
}
private function getCompanyStatusCampaign($db, $whereClause): array
{
$sql = "SELECT wc.name as company, w.status, pc.name as campaign, COUNT(*) as count
FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
JOIN thetool.Preordercampaign pc ON p.preordercampaign_id = pc.id
LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
WHERE $whereClause AND w.companyId IS NOT NULL
GROUP BY wc.name, w.status, pc.name ORDER BY wc.name, count DESC";
$result = $db->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$key = ($row['company'] ?? 'Nicht zugewiesen') . '|' . $row['status'];
if (!isset($data[$key])) {
$data[$key] = [
'company' => $row['company'] ?? 'Nicht zugewiesen',
'status' => $row['status'],
'statusLabel' => $this->statusLabels[$row['status']] ?? $row['status'],
'count' => 0,
'campaigns' => [],
];
}
$data[$key]['count'] += (int)$row['count'];
$data[$key]['campaigns'][] = ['name' => $row['campaign'], 'count' => (int)$row['count']];
}
$result = array_values($data);
usort($result, fn($a, $b) => $b['count'] - $a['count']);
return $result;
}
private function getInterventionRates($db, $whereClause): array
{
$sql = "SELECT wc.name as company, COUNT(*) as total,
SUM(CASE WHEN w.status = 'intervention_required' THEN 1 ELSE 0 END) as interventions,
SUM(CASE WHEN w.status = 'correction_requested' THEN 1 ELSE 0 END) as corrections
FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
WHERE $whereClause AND w.companyId IS NOT NULL GROUP BY wc.name
HAVING COUNT(*) >= 5 ORDER BY (SUM(CASE WHEN w.status IN ('intervention_required', 'correction_requested') THEN 1 ELSE 0 END) / COUNT(*)) DESC";
$result = $db->query($sql);
$rates = [];
while ($row = $result->fetch_assoc()) {
$total = (int)$row['total'];
$issueCount = (int)$row['interventions'] + (int)$row['corrections'];
$rates[] = [
'company' => $row['company'] ?? 'Nicht zugewiesen',
'total' => $total,
'interventions' => (int)$row['interventions'],
'corrections' => (int)$row['corrections'],
'rate' => $total > 0 ? round(($issueCount / $total) * 100, 1) : 0,
];
}
return $rates;
}
private function getStatusTransitions($db, $tenantCampaignIds, $dateFrom, $dateTo): array
{
$where = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")", "wj.statusChange IS NOT NULL"];
if ($dateFrom) $where[] = "wj.`create` >= " . intval($dateFrom);
if ($dateTo) $where[] = "wj.`create` <= " . intval($dateTo);
$sql = "SELECT wj.statusChange, COUNT(*) as count FROM thetool.WorkorderJournal wj
JOIN thetool.Workorder w ON wj.workorderId = w.id JOIN thetool.Preorder p ON w.preorderId = p.id
WHERE " . implode(' AND ', $where) . " GROUP BY wj.statusChange ORDER BY count DESC LIMIT 15";
$result = $db->query($sql);
$transitions = [];
while ($row = $result->fetch_assoc()) {
$transitions[] = ['transition' => $row['statusChange'], 'count' => (int)$row['count']];
}
return $transitions;
}
private function getEmptyDashboardData(): array
{
return [
'kpis' => ['total' => 0, 'offen' => 0, 'terminisiert' => 0, 'issues' => 0, 'interventionRate' => 0, 'avgCompletionDays' => null],
'statusDistribution' => [], 'companyPerformance' => [], 'timeTrends' => [],
'companyStatusCampaign' => [], 'interventionRates' => [], 'statusTransitions' => [],
];
}
}

View File

@@ -375,4 +375,38 @@ class WorkorderMphAdminController extends WorkorderMphBaseController
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']);
}
protected function unassignWorkorderAction()
{
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
if ($workorder->status === 'new') self::sendError("Arbeitsauftrag ist nicht zugewiesen.");
if (in_array($workorder->status, ['completed', 'cancelled'])) self::sendError("Arbeitsauftrag kann nicht mehr geändert werden.");
$oldStatus = $workorder->status;
$oldCompany = $workorder->companyId ? WorkorderCompanyModel::get($workorder->companyId) : null;
$oldCompanyName = $oldCompany ? $oldCompany->name : 'Unbekannt';
$workorder->status = 'new';
$workorder->companyId = null;
$workorder->assignmentDate = null;
$workorder->deadlineDate = null;
$workorder->appointmentDate = null;
WorkorderMphModel::update((array)$workorder);
$reason = !empty($this->postData['reason']) ? " Grund: " . $this->postData['reason'] : '';
WorkorderMphJournalModel::create([
'workorderMphId' => $workorder->id,
'text' => "Zuweisung aufgehoben (vorher: $oldCompanyName).$reason",
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'),
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Zuweisung wurde aufgehoben.']);
}
}

View File

@@ -20,6 +20,7 @@ class WorkorderTenantConfigController extends TTCrud {
$data['interventionTypes'] = json_encode($data['interventionTypes'] ?? []);
$data['workorderCreationFilters'] ??= '{}';
$data['workorderActiveFilters'] ??= '{}';
$data['autoCompleteFilter'] ??= null;
if (empty($data['id'])) {
$data['create'] = time();

View File

@@ -8,10 +8,13 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel {
public string $documentationTypes; // JSON
public string $workorderCreationFilters; // JSON
public ?string $workorderActiveFilters; // JSON
public ?string $autoCompleteFilter; // JSON
public ?string $interventionTypes; // JSON
public int $civilEngineeringDocsRequired;
public int $requireCableLength;
public int $requireCableType;
public int $showTechnicalData = 0;
public int $tiefbauSeesNormalDocs = 0;
public int $enableWorkorder;
public int $enableWorkorderMph;
public int $create;

View File

@@ -10,6 +10,7 @@
"stomp-php/stomp-php": "^5",
"phpmailer/phpmailer": "^6.9",
"pear2/net_routeros": "dev-develop@dev",
"matthiasmullie/minify": "^1.3"
"matthiasmullie/minify": "^1.3",
"smalot/pdfparser": "^2.0"
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddLockExportedToManualinvoice extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("ManualInvoice");
$table->addColumn("lock", "integer", [
"null" => false,
"default" => 0,
"limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
"after" => "status"
]);
$table->addColumn("exported", "integer", [
"null" => false,
"default" => 0,
"limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
"after" => "lock"
]);
$table->save();
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("ManualInvoice");
$table->removeColumn("lock")->save();
$table->removeColumn("exported")->save();
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateWarehouseLagerbewegung extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$lagerbewegung = $this->table('WarehouseLagerbewegung');
$lagerbewegung
->addColumn('movementNumber', 'string', ['limit' => 50, 'null' => true])
->addColumn('movementType', 'enum', ['values' => ['IN', 'OUT', 'ADJUSTMENT']])
->addColumn('articleId', 'integer', ['signed' => false])
->addColumn('warehouseLocationId', 'integer', ['signed' => true])
->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => true])
->addColumn('quantity', 'decimal', ['precision' => 10, 'scale' => 2])
->addColumn('quantityBefore', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true])
->addColumn('quantityAfter', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true])
->addColumn('reasonCategory', 'string', ['limit' => 50])
->addColumn('note', 'text', ['null' => true])
->addColumn('userId', 'integer', ['signed' => false])
->addColumn('createBy', 'integer', ['signed' => false])
->addColumn('create', 'integer')
->addIndex(['movementNumber'], ['unique' => true])
->addIndex(['articleId'])
->addIndex(['warehouseLocationId'])
->addIndex(['movementType'])
->addIndex(['userId'])
->addIndex(['create'])
->create();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$this->table('WarehouseLagerbewegung')->drop()->save();
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class RenameLagerbewegungToMovement extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$this->table('WarehouseLagerbewegung')->rename('WarehouseMovement')->save();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$this->table('WarehouseMovement')->rename('WarehouseLagerbewegung')->save();
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class PreorderCtagAddExtName extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("PreorderCtag");
$table->renameColumn("ext_id", "ext_name");
$table->addColumn("ext_id", "string", ["null" => true, "default" => null, "length" => 255, "after" => "service_type"]);
$table->update();
}
if($this->getEnvironment() == "addressdb") {
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("PreorderCtag");
$table->removeColumn("ext_id");
$table->renameColumn("ext_name", "ext_id");
$table->update();
}
if($this->getEnvironment() == "addressdb") {
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class VoicenumberAddDisableReasonCanceled extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("Voicenumber");
$table->changeColumn("disabled", "integer", ["null" => false, "default" => 0]);
$table->changeColumn("disabled_reason", "enum", ["values" => "ported_out,ported_back,reserved,legacy,damaged,contract_cancelled", "null" => true, "default" => null]);
$table->addColumn("disabled_by", "integer", ["null" => true, "default" => null, "after" => "disabled_reason"]);
$table->update();
}
if($this->getEnvironment() == "addressdb") {
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
}
if($this->getEnvironment() == "addressdb") {
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddressAddManualInvoiceSepaLimit extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table('Address');
$table->addColumn("manual_invoice_sepa_limit", "decimal", ["null" => true, "default" => 500, "precision" => 9, "scale" => 2, "after" => "fibu_payment_skonto_rate"]);
$table->update();
}
if($this->getEnvironment() == "addressdb") {
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$this->table('Address')->removeColumn("manual_invoice_sepa_limit")->update();
}
if($this->getEnvironment() == "addressdb") {
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class OrderProductAddPreorderData extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("OrderProduct");
$table->addColumn("oaid", "string", ["null" => true, "default" => null, "limit" => 255, "after" => "termination_id"]);
$table->addColumn("preorder_id", "integer", ["null" => true, "default" => null, "after" => "oaid"]);
$table->addColumn("snopp_order_id", "integer", ["null" => true, "default" => null, "after" => "preorder_id"]);
$table->update();
}
if($this->getEnvironment() == "addressdb") {
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
}
if($this->getEnvironment() == "addressdb") {
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddShippingNoteNumber extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table('WarehouseShippingNote');
$table
->addColumn('shippingNoteNumber', 'string', ['limit' => 20, 'null' => true, 'after' => 'id'])
->addIndex(['shippingNoteNumber'], ['unique' => true])
->update();
// Get all shipping notes ordered by create timestamp to assign numbers in chronological order
$rows = $this->fetchAll(
"SELECT id, YEAR(FROM_UNIXTIME(`create`)) as year
FROM WarehouseShippingNote
ORDER BY `create` ASC, id ASC"
);
// Group by year and assign sequential numbers
$yearCounters = [];
foreach ($rows as $row) {
$year = $row['year'];
if (!isset($yearCounters[$year])) {
$yearCounters[$year] = 0;
}
$yearCounters[$year]++;
$shippingNoteNumber = 'LS' . $year . '-X' . str_pad((string)$yearCounters[$year], 4, '0', STR_PAD_LEFT);
$this->execute(
"UPDATE WarehouseShippingNote SET shippingNoteNumber = '{$shippingNoteNumber}' WHERE id = {$row['id']}"
);
}
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table('WarehouseShippingNote');
$table->removeColumn('shippingNoteNumber')->update();
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddOrderMovementLinking extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
// Add columns to WarehouseOrder for linking to movements and delivery note files
$orderTable = $this->table('WarehouseOrder');
$orderTable
->addColumn('linkedMovementIds', 'text', ['null' => true, 'after' => 'note'])
->addColumn('deliveryNoteFileIds', 'text', ['null' => true, 'after' => 'linkedMovementIds'])
->update();
// Add column to WarehouseMovement for linking back to orders
$movementTable = $this->table('WarehouseMovement');
$movementTable
->addColumn('linkedOrderId', 'integer', ['null' => true, 'signed' => false, 'after' => 'note'])
->addIndex(['linkedOrderId'], ['name' => 'idx_linkedOrderId'])
->update();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$orderTable = $this->table('WarehouseOrder');
$orderTable
->removeColumn('linkedMovementIds')
->removeColumn('deliveryNoteFileIds')
->update();
$movementTable = $this->table('WarehouseMovement');
$movementTable
->removeIndex(['linkedOrderId'])
->removeColumn('linkedOrderId')
->update();
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddShowTechnicalDataToTenantConfig extends AbstractMigration {
public function up() {
if ($this->getEnvironment() !== "thetool") return;
$table = $this->table('WorkorderTenantConfig');
if (!$table->hasColumn('showTechnicalData')) {
$table->addColumn('showTechnicalData', 'boolean', [
'default' => false,
'after' => 'requireCableType'
])->update();
}
}
public function down() {
if ($this->getEnvironment() !== "thetool") return;
$table = $this->table('WorkorderTenantConfig');
if ($table->hasColumn('showTechnicalData')) {
$table->removeColumn('showTechnicalData')->update();
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AssetManagementAddCategory extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table("AssetManagement");
$table->addColumn('category', 'string', [
'limit' => 255,
'null' => true,
'default' => null,
'after' => 'description',
'comment' => 'Free text category for the asset with autocomplete support',
]);
$table->addIndex(['category'], ['name' => 'idx_category']);
$table->update();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table("AssetManagement");
$table->removeIndex(['category']);
$table->removeColumn('category');
$table->update();
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddMetadataToWorkorder extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table("Workorder");
$table
->addColumn("metadata", "json", [
'null' => true,
'default' => null,
'after' => 'cableType'
])
->update();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$this->table("Workorder")
->removeColumn("metadata")
->save();
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WarehousearticleRenameRevenueaccountToVatgroupid extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$this->execute("ALTER TABLE WarehouseArticle CHANGE revenueAccount vatgroup_id INT(11) NOT NULL DEFAULT 2");
$this->execute("UPDATE WarehouseArticle SET vatgroup_id = CASE
WHEN vatgroup_id = 0 THEN 2
WHEN vatgroup_id = 1 THEN 3
ELSE vatgroup_id
END");
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$this->execute("UPDATE WarehouseArticle SET vatgroup_id = CASE
WHEN vatgroup_id = 2 THEN 0
WHEN vatgroup_id = 3 THEN 1
ELSE vatgroup_id
END");
$this->execute("ALTER TABLE WarehouseArticle CHANGE vatgroup_id revenueAccount INT(11) NOT NULL DEFAULT 0");
}
}
}

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