Merge branch 'master' into 'spidev'

# Conflicts:
#   Layout/default/Device/Detail.php
This commit is contained in:
Daniel Spitzer
2026-01-29 20:17:14 +00: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/
/Layout/default/DeviceDetail/ /Layout/default/DeviceDetail/
mobile-presentation/
nul

View File

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

View File

@@ -232,12 +232,22 @@
<input type="text" class="form-control" name="bank_account_bic" id="bank_account_bic" value="<?=$address->bank_account_bic?>" /> <input type="text" class="form-control" name="bank_account_bic" id="bank_account_bic" value="<?=$address->bank_account_bic?>" />
</div> </div>
</div> </div>
<?php if($me->can("Fibu")): ?>
<div class="form-group row"> <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 class="col-lg-10 alert alert-danger hidden" id="bank-error"></div>
</div> </div>
<?php if($me->can("Fibu")): ?> <?php if($me->can("Fibu")): ?>
<div class="form-group row"> <div class="form-group row">
<label class="col-lg-2 col-form-label" for="sepa_date">Sepa Mandatsdatum</label> <label class="col-lg-2 col-form-label" for="sepa_date">Sepa Mandatsdatum</label>

View File

@@ -144,8 +144,12 @@
</tr><tr> </tr><tr>
<th>BIC</th> <th>BIC</th>
<td><?=$address->bank_account_bic?></td> <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> </tr>
<?php if($me->can("Fibu")): ?> <?php if($me->can("Fibu")): ?>
<tr> <tr>
<th>Sepa Mandatsdatum</th> <th>Sepa Mandatsdatum</th>
<td><?=($address->sepa_date) ? date("d.m.Y", $address->sepa_date) : ""?></td> <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'" : "" ?>> <option value="FritzBox 6490 Cable" <?= ($product->cpeprovisioning->routertype == "FritzBox 6490 Cable") ? "selected='selected'" : "" ?>>
FritzBox 6490 Cable (Inet, Phone, IPTV) FritzBox 6490 Cable (Inet, Phone, IPTV)
</option> </option>
<option value="FritzBox 6670 Cable" <?= ($product->cpeprovisioning->routertype == "FritzBox 6670 Cable") ? "selected='selected'" : "" ?>>
FritzBox 6670 Cable (Inet, Phone, IPTV)
</option>
<?php endif; ?> <?php endif; ?>
</select> </select>
</div> </div>

View File

@@ -25,22 +25,52 @@ $pagination_entity_name = "Rechnungen";
<div class="row"> <div class="row">
<div class="col-lg-12"> <div class="col-lg-12">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col"> <div class="col-3">
<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> <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> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-8"> <div class="col-8">
<div class="card"> <div class="card">
<div class="card-body mb-3"> <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")?>"> <form method="get" action="<?=self::getUrl("Invoice")?>">
<div class="row"> <div class="row">
@@ -399,6 +429,22 @@ $pagination_entity_name = "Rechnungen";
todayBtn: 'linked', todayBtn: 'linked',
autoclose: true 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({ $('.datepicker').datepicker({
orientation: "bottom", orientation: "bottom",
language: 'de', language: 'de',

View File

@@ -68,9 +68,7 @@
<td style="vertical-align: top; text-align: right;"> <td style="vertical-align: top; text-align: right;">
<table style="display: inline-table; vertical-align: top;"> <table style="display: inline-table; vertical-align: top;">
<tr> <tr>
<td style="vertical-align: top; padding-right: 10px;"> {{ qrCodeHtml }}
<img alt="QR-Code" src="{{ qrCodeSrc }}" style="display: block; height: 100%; max-height: 3.5cm; width: auto;">
</td>
<td> <td>
<table class="invoice-details"> <table class="invoice-details">
<tr> <tr>

View File

@@ -17,7 +17,7 @@ foreach($invoice->positions as $p) {
} }
} }
$gesamtrabatt = $invoice->gesamtrabatt ?? 0; $total_discount = $invoice->total_discount ?? 0;
$subtotal = 0; $subtotal = 0;
foreach($invoice->positions as $p) { foreach($invoice->positions as $p) {
$subtotal += $p->price_total ?? 0; $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> <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 ?? ''): ?> <?php if($invoice->introductory_text ?? ''): ?>
<p style="margin-top: 10pt; margin-bottom: 20pt; text-align: center; font-weight: bold;"><?=nl2br(htmlspecialchars($invoice->einleitender_text))?></p> <p style="margin-top: 10pt; margin-bottom: 20pt; text-align: center; font-weight: bold;"><?=nl2br(htmlspecialchars($invoice->introductory_text))?></p>
<?php endif; ?> <?php endif; ?>
<table style="border-collapse: collapse; width: 100%;" id="invoiceTable"> <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" ?>"> <tr class="position <?=($i%2 == 0) ? "even" : "uneven" ?>">
<td style="padding-left: 4pt; vertical-align: top;"> <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): ?> <?php if(isset($p->product_info) && $p->product_info): ?>
<div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->product_info)?></div> <div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->product_info)?></div>
<?php endif; ?> <?php endif; ?>
@@ -186,17 +186,17 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
endforeach; endforeach;
endforeach; endforeach;
?> ?>
<?php if($gesamtrabatt > 0): ?> <?php if($total_discount > 0): ?>
<tr style="background-color: #ebebeb; border-top: 2px solid black;"> <tr style="background-color: #ebebeb; border-top: 2px solid black;">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Zwischensumme:</td> <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> <td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($subtotal, 2, ",","."). " €"?></td>
</tr> </tr>
<tr style="background-color: #ebebeb; border-bottom: 1px solid #ccc;"> <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="<?=$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 * ($gesamtrabatt / 100), 2, ",","."). " €"?></td> <td colspan="2" style="text-align: right; padding-right: 4pt; color: #d32f2f;">-<?=number_format($subtotal * ($total_discount / 100), 2, ",","."). " €"?></td>
</tr> </tr>
<?php endif; ?> <?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="<?=$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> <td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($net_total, 2, ",","."). " €"?></td>
</tr> </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"> <div class="form-group row">
<label class="col-lg-2 col-form-label" for="order_date">Bestelldatum</label> <label class="col-lg-2 col-form-label" for="order_date">Bestelldatum</label>
<div class="col-lg-4"> <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> </div>
<div class="form-group row"> <div class="form-group row">
@@ -553,7 +553,6 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-body" id="products-form"> <div class="card-body" id="products-form">
<h4>Produkte</h4> <h4>Produkte</h4>
@@ -585,6 +584,9 @@
<div class="row product-container" id="position-<?=$i?>"> <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?>][delete]" id="products-<?=$i?>-delete" value="0" />
<input type="hidden" name="products[<?=$i?>][orderproduct_id]" value="<?=$product->id?>" /> <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="col-md-1 product-<?=$i?>">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
@@ -596,9 +598,11 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-12 delete-button-container"> <?php if(!$product->preorder_id): ?>
<i class="btn btn-outline-danger fas fa-trash-can pointer" style="font-size: 1.5em" onclick="toggleDeletePos(<?=$i?>)"></i> <div class="col-md-12 delete-button-container">
</div> <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>
</div> </div>
@@ -641,7 +645,17 @@
</div> </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( <?php if(
(is_array($product->product->attributes) && count($product->product->attributes)) (is_array($product->product->attributes) && count($product->product->attributes))
&& (array_key_exists(TT_ATTRIB_TERMINATION_REQUIRED_NAME, $product->product->attributes) && (array_key_exists(TT_ATTRIB_TERMINATION_REQUIRED_NAME, $product->product->attributes)
@@ -828,7 +842,7 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-12 delete-button-container"> <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> </div>
</div> </div>
@@ -869,7 +883,14 @@
<input type="text" class="form-control" name="products[<?=$i?>][price_setup]" id="price_setup-<?=$i?>" value="" placeholder="Preis Setup" /> <input type="text" class="form-control" name="products[<?=$i?>][price_setup]" id="price_setup-<?=$i?>" value="" placeholder="Preis Setup" />
</div> </div>
</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"> <div class="row mt-1 mb-2 hidden" id="termination_id-<?=$i?>-line">
<!-- line to choose termination --> <!-- line to choose termination -->
<div class="col-12"> <div class="col-12">
@@ -1383,8 +1404,15 @@
}); });
} else { } else {
$('#termination_id-' + id + '-line').hide(); $('#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) { if(typeof p.attributes === 'object' && "needs_number" in p.attributes && p.attributes.needs_number == 1) {
console.log("needs_number"); console.log("needs_number");
@@ -1893,7 +1921,7 @@
} else { } else {
$('#products-' + id + '-delete').val(0); $('#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 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 + ' .delete-button-container i').removeClass("btn-outline-white fa-trash-can-slash").addClass("text-danger fa-trash-can");
$('#position-' + id).removeClass('text-white deleted'); $('#position-' + id).removeClass('text-white deleted');
@@ -1932,7 +1960,7 @@
</div> \ </div> \
<div class="row"> \ <div class="row"> \
<div class="col-md-12 delete-button-container"> \ <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> \ </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" /> \ <input type="text" class="form-control" name="products[' + i +'][price_setup]" id="price_setup-' + i +'" value="" placeholder="Preis Setup" /> \
</div> \ </div> \
</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"> \ <div class="row mt-1 mb-2 hidden" id="termination_id-' + i +'-line"> \
<!-- line to choose termination --> \ <!-- line to choose termination --> \
<div class="col-12"> \ <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", "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", "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> <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> </td>
</tr> </tr>
<tr class="building-list-tr <?=($order_count % 2 == 0) ? "table-bg-even" : ""?>" id="order-dates-<?=$order->id?>"> <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->pos?></td>
<td class="text-right"><?=$product->formatAmount()?></td> <td class="text-right"><?=$product->formatAmount()?></td>
<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 <?php
if( if(
(is_array($product->product->attributes) && count($product->product->attributes)) (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> Filter-Vorlagen <i class="fas fa-caret-down"></i>
</button> </button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton"> <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" => ["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): ?> <?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> <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 ?>"/> <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 * Globals for map display
*/ */
var borderpolies = []; var borderpolies = [];
<?php if($me->is("Admin")): ?> <?php if($me->is("Admin") && !isset($campaign)): ?>
<?php foreach(ADBNetzgebietModel::search(["borderpoly" => true]) as $bp_netz): ?> <?php foreach(ADBNetzgebietModel::search(["borderpoly" => true]) as $bp_netz): ?>
borderpolies.push([<?=$bp_netz->borderpoly?>]); borderpolies.push([<?=$bp_netz->borderpoly?>]);
<?php endforeach; ?> <?php endforeach; ?>

View File

@@ -36,7 +36,9 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="card-header bg-info text-white pl-2 pr-2 pt-1 pb-1">Details</div> <div class="card-header bg-info text-white pl-2 pr-2 pt-1 pb-1">Details</div>
<div class="row"> <div class="row">
<div class="col-8"> <div class="col-8">
<div class="loader-big hidden" ><img src="<?=self::getResourcePath()?>assets/images/loader-big.gif" /></div> <div class="loader-big hidden" ><img src="<?=self::getResourcePath()?>assets/images/loader-big.gif" /></div>
@@ -170,6 +172,18 @@
</tr> </tr>
</table> </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>
<div class="col-4"> <div class="col-4">
@@ -697,6 +711,7 @@
<th>ctag</th> <th>ctag</th>
<th>Typ</th> <th>Typ</th>
<th>External ID</th> <th>External ID</th>
<th>External Name</th>
<th>External State</th> <th>External State</th>
</tr> </tr>
<?php if(is_array($preorder->ctags) && count($preorder->ctags)): ?> <?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->ctag?></td>
<td class="text-monospace"><?=$ctag->service_type?></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_id)?></td>
<td class="text-monospace"><?=htmlentities($ctag->ext_name)?></td>
<td class="text-monospace"><?=htmlentities($ctag->ext_status)?></td> <td class="text-monospace"><?=htmlentities($ctag->ext_status)?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>

View File

@@ -117,6 +117,7 @@
<option></option> <option></option>
<option value="ported_out" <?=($number->disabled_reason == "ported_out") ? "selected='selected'" : ""?>>Zu neuem Provider portiert</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="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="reserved" <?=($number->disabled_reason == "reserved") ? "selected='selected'" : ""?>>Reserviert</option>
<option value="legacy" <?=($number->disabled_reason == "legacy") ? "selected='selected'" : ""?>>Legacy</option> <option value="legacy" <?=($number->disabled_reason == "legacy") ? "selected='selected'" : ""?>>Legacy</option>
<option value="damaged" <?=($number->disabled_reason == "damaged") ? "selected='selected'" : ""?>>Kaputt</option> <option value="damaged" <?=($number->disabled_reason == "damaged") ? "selected='selected'" : ""?>>Kaputt</option>

View File

@@ -17,14 +17,16 @@
</tr> </tr>
<?php $i = 0; foreach(range((array_key_exists($block->id, $num_from) ? $num_from[$block->id] : $block->first), $block->last) as $number): ?> <?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]) ?> <?php $num = VoicenumberModel::getFirst(['voicenumberblock_id' => $block->id, 'number' => $number]) ?>
<tr> <tr class="<?=($num->disabled) ? "bg-secondary text-white" : ""?>">
<td><?=$number?></td> <td><?=$number?></td>
<td> <td>
<?php if($num->active): ?> <?php if($num->active): ?>
<span class="text-success"><i class="fas fa-check"></i></span> <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> <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: ?> <?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; ?> <?php endif; ?>
</td> </td>
<td><a href="<?=self::getUrl("Contract", "view", ["id" => $num->contract_id])?>"><?=$num->contract_id?></a></td> <td><a href="<?=self::getUrl("Contract", "view", ["id" => $num->contract_id])?>"><?=$num->contract_id?></a></td>
@@ -40,7 +42,7 @@
Lokal Lokal
<?php endif; ?> <?php endif; ?>
</td> </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><?=($num->id && $num->enable_on_date) ? date("d.m.Y", $num->enable_on_date) : ""?></td>
<td> <td>
<a href="<?=self::getUrl("Voicenumber", "edit", ["block_id" => $block->id, "number" => $number])?>"><i class="fas fa-edit"></i></a> <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 <?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> <!DOCTYPE html>
<html lang="de"> <html lang="de">
@@ -32,6 +40,34 @@
} }
</script> </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> <style>
html, body { html, body {
overscroll-behavior: none; overscroll-behavior: none;

View File

@@ -1,4 +1,13 @@
<?php <?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> <!DOCTYPE html>
<html lang="de"> <html lang="de">
@@ -34,6 +43,34 @@
} }
</script> </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> <style>
html, body { html, body {
/* Prevents the rubber-band scroll effect on iOS and pull-to-refresh on Android */ /* 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.')); 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 --- // --- METHODS ---
const applyTheme = () => { 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) => { const selectFcp = (fcpValue) => {
selectedFcp.value = fcpValue; selectedFcp.value = fcpValue;
isFcpSelectOpen.value = false; isFcpSelectOpen.value = false;
@@ -520,10 +581,11 @@
checklist, fullscreenViewer, missingTasksPopover, translatedDocs, filteredJournals, installModal, isStandalone, checklist, fullscreenViewer, missingTasksPopover, translatedDocs, filteredJournals, installModal, isStandalone,
selectedFcp, isFcpSelectOpen, fcpOptions, selectedFcpText, fcpSearchTerm, filteredFcpOptions, fcpInputRef, selectedFcp, isFcpSelectOpen, fcpOptions, selectedFcpText, fcpSearchTerm, filteredFcpOptions, fcpInputRef,
isSettingsOpen, theme, showThemePicker, isSettingsOpen, theme, showThemePicker,
savingData, // <-- ADDED savingData,
isCivilEngineering, showNormalDocsForCivilEng,
fetchWorkorders, openDetails, closeDetails, getStatusInfo, formatDate, googleMapsLink, startEditInfo, saveAdditionalInfo, fetchWorkorders, openDetails, closeDetails, getStatusInfo, formatDate, googleMapsLink, startEditInfo, saveAdditionalInfo,
handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick, selectFcp, setTheme, handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick, selectFcp, setTheme,
saveWorkorderData // <-- ADDED saveWorkorderData, completeCivilEngineering
}; };
}, },
template: ` template: `
@@ -673,7 +735,7 @@
{{ savingData ? 'Speichert...' : 'Daten speichern' }} {{ savingData ? 'Speichert...' : 'Daten speichern' }}
</button> </button>
</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-3">Checkliste</h3> <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-if="isDetailsLoading" class="space-y-3 animate-pulse">
<div v-for="i in 4" :key="i" class="flex items-center"> <div v-for="i in 4" :key="i" class="flex items-center">
@@ -693,7 +755,7 @@
</div> </div>
</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> <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"> <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> <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> </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> <h3 class="font-bold text-slate-700 dark:text-secondary mb-4">Journal</h3>
<div v-if="isDetailsLoading" class="animate-pulse"> <div v-if="isDetailsLoading" class="animate-pulse">
<div class="flex items-start"> <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))]"> <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> <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"> <template v-if="isCivilEngineering">
<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> <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>
<transition name="fade"> </template>
<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"> <template v-else>
<h4 class="font-bold mb-1">Fehlende Punkte:</h4> <div class="relative">
<ul class="list-disc list-inside space-y-1"> <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>
<li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li> <transition name="fade">
</ul> <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">
<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> <h4 class="font-bold mb-1">Fehlende Punkte:</h4>
</div> <ul class="list-disc list-inside space-y-1">
</transition> <li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li>
</div> </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> </footer>
</div> </div>
</transition> </transition>

View File

@@ -18,21 +18,21 @@ $qrCodeBase64 = (new QRCode($options))->render($qrData);
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>
* { margin: 0; padding: 0; } * { 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; } body { font-family: Arial, sans-serif; color: #000; }
table { border-collapse: collapse; } table { border-collapse: collapse; }
</style> </style>
</head> </head>
<body> <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> <tr>
<td style="width: 22mm; height: 25mm; vertical-align: middle; text-align: center;"> <td style="width: 24mm; height: 25mm; position: relative;">
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm;"> <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>
<td style="height: 25mm; vertical-align: middle; padding-left: 1mm; padding-right: 1mm;"> <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;"> <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: 10px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($articleNumber); ?></div> <div style="font-size: 11px; 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> <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> </td>
</tr> </tr>
</table> </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); $formattedOfferDate = date("d.m.Y", $offerDate);
$validityDays = isset($validity) ? (int)$validity : 14; $validityDays = isset($validity) ? (int)$validity : 31;
$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $offerDate)); // 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> <!DOCTYPE html>

View File

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

View File

@@ -99,6 +99,7 @@
</style> </style>
<?php endif; ?> <?php endif; ?>
<?php $openreplayUserType = 'internal'; include(__DIR__ . "/includes/openreplay.php"); ?>
</head> </head>
<body> <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","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("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; ?> <?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("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("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("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; ?> <?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> </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("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") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseProject")?>"><i class="fas fa-fw fa-project-diagram text-info"></i> Projekte</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseStocktake")?>"><i class="far fa-fw fa-clipboard-check text-info"></i> Inventur</a></li><?php endif; ?> <?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("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; ?> <?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; ?> <?php endif; ?>
</style> </style>
<?php $openreplayUserType = 'internal'; include(__DIR__ . "/includes/openreplay.php"); ?>
</head> </head>
<body> <body>

View File

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

View File

@@ -41,6 +41,10 @@ class ADBNetzgebiet extends mfBaseModelV2 {
public ?string $source_id = null; public ?string $source_id = null;
public ?string $borderpoly = null; public ?string $borderpoly = null;
public ?string $freigabe = '["interest", "provision", "order", "reorder"]'; 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 ?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 $create;
public int $edit; public int $edit;

View File

@@ -24,6 +24,9 @@ class ADBNetzgebietController extends mfBaseController {
"GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"), "GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"),
"SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"), "SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"),
"HISTORY_URL" => $this::getUrl("ADBNetzgebiet/getHistory"), "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_URL" => $this::getUrl("Network/Index"),
"NETWORK_CREATE_URL" => $this::getUrl("Network/add"), "NETWORK_CREATE_URL" => $this::getUrl("Network/add"),
"CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"), "CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"),
@@ -130,6 +133,193 @@ class ADBNetzgebietController extends mfBaseController {
self::returnJson(['success' => true, 'data' => $history]); 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 // TODO: Implement RIMO API check
protected function checkRimoSourceIdAction(): void { protected function checkRimoSourceIdAction(): void {
self::returnJson(['success' => false, 'message' => "RIMO API check not available."]); 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_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'] = ($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['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() { protected function getClusters() {
$cluster_search = []; $cluster_search = [];
if(count($this->filter_salescluster_ids)) { if(count($this->filter_salescluster_ids)) {
$cluster_search['netzgebiet_id'] = $this->filter_salescluster_ids; $cluster_search['id'] = $this->filter_salescluster_ids;
} }
$clusters = []; $clusters = [];
foreach(ADBNetzgebietModel::search($cluster_search) as $cluster) { 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"]; $bb_down = $this->post["bb_down"];
$execution_date = false; $execution_date = false;
if($this->post["execution_date"]) { /*if($this->post["execution_date"]) {
try { try {
$execution_date = new \DateTime($this->post["execution_date"]); $execution_date = new \DateTime($this->post["execution_date"]);
} catch(\Exception $e) { } catch(\Exception $e) {
return \mfResponse::BadRequest(["message" => "Invalid Timestamp format"]); 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) { 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 // if all services are ordered and active, finish order and return active
$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]); $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($ctag->ext_id && $ctag->ext_status == "finished") {
if($preorder->status->code < 500) { if($preorder->status->code < 500) {
$preorder->setNewStatusCode(500); $preorder->setNewStatusCode(500);
@@ -128,11 +132,11 @@ class SnoppCitycom extends Modules\ApiControllerModule
// Home must have Status 300, else return deferred // 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"]); return \mfResponse::Ok(["message" => "ONT not yet installed. Deferred", "activation_status" => "deferred"]);
} }*/
/*
$cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref); $cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref);
$data["up"] = $bb_up; $data["up"] = $bb_up;
$data["down"] = $bb_down; $data["down"] = $bb_down;
@@ -159,10 +163,30 @@ class SnoppCitycom extends Modules\ApiControllerModule
// order Service at Citycom and set Preorder to 500 Finished // order Service at Citycom and set Preorder to 500 Finished
if(!$cc_api->orderServices($preorder, $cc_home_id, $data)) { if(!$cc_api->orderServices($preorder, $cc_home_id, $data)) {
return \mfResponse::InternalServerError(["message" => "Error activating service"]); 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 $cc_api_client = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS);
$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]); $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($ctag->ext_id && $ctag->ext_status == "finished") {
if($preorder->status->code < 500) { if($preorder->status->code < 500) {
$preorder->setNewStatusCode(500); $preorder->setNewStatusCode(500);

View File

@@ -43,22 +43,8 @@ class Cif extends Modules\ApiControllerModule {
return \mfResponse::NotFound(["message" => "Preorder not found"]); return \mfResponse::NotFound(["message" => "Preorder not found"]);
} }
// set status to 200 // set status flag 200
if($preorder->status->code < 200) { $preorder->setStatusFlag(200, 1);
$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();
}
return \mfResponse::Ok(["message" => "Status successfully updated."]); return \mfResponse::Ok(["message" => "Status successfully updated."]);
@@ -134,22 +120,8 @@ class Cif extends Modules\ApiControllerModule {
return \mfResponse::NotFound(["message" => "Invalid ciftoken"]); return \mfResponse::NotFound(["message" => "Invalid ciftoken"]);
} }
// set status to 200 // set status flag 200
if($preorder->status->code < 200) { $preorder->setStatusFlag(200, 1);
$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();
}
return \mfResponse::Ok(["message" => "Status successfully updated."]); 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 // Simplified columns for better layout, details are in the 'assetDetails' slot
protected array $columns = [ 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' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']], ['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']],
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']], ['key' => '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->additionalJSVariables['ASSET_ADMIN'] = '0';
$this->columns = array_filter($this->columns, fn($col) => $col['key'] !== 'actions'); $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); $json = json_decode(file_get_contents('php://input'), true);
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10]; $pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
$filters = $json['filters'] ?? []; $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 // Fetch paginated assets
$assets = AssetManagementModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order); $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.']); 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() { protected function printLabelAction() {
if (!$this->user->can('AssetAdmin')) { if (!$this->user->can('AssetAdmin')) {
self::sendError("Permission denied", 403); self::sendError("Permission denied", 403);

View File

@@ -4,6 +4,7 @@ class AssetManagementModel extends TTCrudBaseModel {
public int $id; public int $id;
public string $name; public string $name;
public ?string $description; public ?string $description;
public ?string $category;
public ?int $mainImageId; // Renamed from imageId public ?int $mainImageId; // Renamed from imageId
public ?string $imageIds; // Changed to JSON (will be a string in PHP) public ?string $imageIds; // Changed to JSON (will be a string in PHP)
public string $assetNumber; public string $assetNumber;
@@ -35,8 +36,7 @@ class AssetManagementModel extends TTCrudBaseModel {
foreach ($searchTerms as $term) { foreach ($searchTerms as $term) {
if (empty(trim($term))) continue; if (empty(trim($term))) continue;
$escapedTerm = $db->real_escape_string($term); $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%' OR `category` LIKE '%$escapedTerm%')";
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')";
} }
if (!empty($searchConditions)) { if (!empty($searchConditions)) {
@@ -99,8 +99,8 @@ class AssetManagementModel extends TTCrudBaseModel {
foreach ($searchTerms as $term) { foreach ($searchTerms as $term) {
if (empty(trim($term))) continue; if (empty(trim($term))) continue;
$escapedTerm = $db->real_escape_string($term); $escapedTerm = $db->real_escape_string($term);
// For each term, search in name, assetNumber, and description. // For each term, search in name, assetNumber, description, and category.
$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)) { if (!empty($searchConditions)) {
@@ -128,4 +128,26 @@ class AssetManagementModel extends TTCrudBaseModel {
return $result->fetch_assoc()['count']; 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"), "ORDER_URL" => $this->getUrl("Order"),
"NETWORKS" => NetworkModel::getAll(), "NETWORKS" => NetworkModel::getAll(),
"ROUTER_OPTIONS" => [ "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 // General Options
['value' => 'eigener Router', 'text' => 'Eigener Router'], ['value' => 'eigener Router', 'text' => 'Eigener Router'],
['value' => 'anderes CPE', 'text' => 'Anderes CPE'], ['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 // Static Routers
['value' => 'Mikrotik HAP AC', 'text' => 'Mikrotik HAP AC (Inet, IPTV)'], ['value' => 'Mikrotik HAP AC', 'text' => 'Mikrotik HAP AC (Inet, IPTV)'],
['value' => 'Mikrotik HEX S', 'text' => 'Mikrotik HEX S (Inet, IPTV)'], ['value' => 'Mikrotik HEX S', 'text' => 'Mikrotik HEX S (Inet, IPTV)'],
['value' => 'Mikrotik RB3011', 'text' => 'Mikrotik RB3011 (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 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" => [ "ROUTER_SHIPPING_DATA" => [
"TP-Link Archer C80" => ["weight" => 1, "length" => 35, "width" => 24, "height" => 8], "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; 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 = [ $attachment = [
"file" => $filepath, "file" => $filepath,
"content" => $content, "content" => $content,

View File

@@ -681,6 +681,8 @@ class InvoiceController extends mfBaseController {
} }
// save Invoiceposition // save Invoiceposition
// first round price
$position->price_gross = round($position->price_gross, 4);
if (!$position->save()) { if (!$position->save()) {
$invoice->rollbackTransaction(); $invoice->rollbackTransaction();
die("Error saving Invoiceposition"); die("Error saving Invoiceposition");
@@ -703,7 +705,7 @@ class InvoiceController extends mfBaseController {
} }
$invoice->total = $total_net; $invoice->total = $total_net;
$invoice->total_gross = $total_gross; $invoice->total_gross = round($total_gross, 4);
//$invoice->total_vat = $total_vat; //$invoice->total_vat = $total_vat;
if (!$invoice->save()) { 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() { protected function exportBmdAction() {
if(!$this->me->can("Billing")) { if(!$this->me->can("Billing")) {
$this->redirect("Dashboard"); $this->redirect("Dashboard");

View File

@@ -105,10 +105,12 @@ class ManualInvoiceController extends TTCrud
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '', "{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU", "{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()), "{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
"{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->leistungszeitraum) . "</td></tr>" : "", "{{ leistungszeitraumHtml }}" => ($invoice->performance_period ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->performance_period) . "</td></tr>" : "",
"{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->externe_referenz) . "</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>" : "", "{{ 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")); $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); $post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null; $id = $post['id'] ?? null;
$recipientEmail = $post['email'] ?? 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) { if (!$id || !$recipientEmail) {
self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']); self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']);
@@ -222,6 +222,19 @@ class ManualInvoiceController extends TTCrud
return; 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 // Generate PDF
$pdf_filename = $this->createPDFAction(true); $pdf_filename = $this->createPDFAction(true);
if (!$pdf_filename || !file_exists($pdf_filename)) { if (!$pdf_filename || !file_exists($pdf_filename)) {
@@ -232,19 +245,33 @@ class ManualInvoiceController extends TTCrud
$pdfContent = file_get_contents($pdf_filename); $pdfContent = file_get_contents($pdf_filename);
// --- HTML Email Generation --- // --- HTML Email Generation ---
$logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png'; $logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
$logoToolExists = file_exists($logoToolPath);
$logoXinonExists = file_exists($logoXinonPath); $logoXinonExists = file_exists($logoXinonPath);
// Construct HTML Body // Construct HTML Body with Outlook compatibility
$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 = '<!DOCTYPE html>';
$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);">'; $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 // Outlook-safe container table
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">'; $html .= '<!--[if mso]><table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" align="center"><tr><td><![endif]-->';
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">'; $html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;">';
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
// 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 .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>'; $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 .= '<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 .= '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); $mail = new PHPMailer(true);
try { try {
@@ -269,12 +298,11 @@ class ManualInvoiceController extends TTCrud
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587; $mail->Port = 587;
// Logos // Logo embedding
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice'); $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); $customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname);
$mail->addAddress($recipientEmail, $customerName); $mail->addAddress($recipientEmail, $customerName);
@@ -283,7 +311,10 @@ class ManualInvoiceController extends TTCrud
$mail->Body = $html; $mail->Body = $html;
$mail->AltBody = strip_tags($bodyText); $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(); $mail->send();
@@ -320,10 +351,17 @@ class ManualInvoiceController extends TTCrud
$me = new User(); $me = new User();
$me->loadMe(); $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([ ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id, 'manualinvoiceId' => $id,
'text' => 'Rechnung heruntergeladen', 'text' => 'Rechnung heruntergeladen',
'statusChange' => 'gesendet',
'createBy' => $me->id, 'createBy' => $me->id,
'create' => time() 'create' => time()
]); ]);
@@ -349,20 +387,42 @@ class ManualInvoiceController extends TTCrud
$data['invoice_date'] = strtotime($data['invoice_date']); $data['invoice_date'] = strtotime($data['invoice_date']);
} }
$data = array_merge([ // Always generate invoice number (override any null from frontend)
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(), $data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber();
'invoice_date' => $data['invoice_date'] ?? time(), $data['invoice_date'] = $data['invoice_date'] ?? time();
'status' => 'erstellt', $data['status'] = 'erstellt';
'fibu_payment_skonto' => 0, $data['fibu_payment_skonto'] = $data['fibu_payment_skonto'] ?? 0;
'fibu_payment_skonto_rate' => 0, $data['fibu_payment_skonto_rate'] = $data['fibu_payment_skonto_rate'] ?? 0;
'gesamtrabatt' => 0,
'total' => 0, $data['total_discount'] = $data['total_discount'] ?? $data['gesamtrabatt'] ?? 0;
'total_gross' => 0, $data['performance_period'] = $data['performance_period'] ?? $data['leistungszeitraum'] ?? null;
'create_by' => $me->id, $data['introductory_text'] = $data['introductory_text'] ?? $data['einleitender_text'] ?? null;
'edit_by' => $me->id, $data['external_reference'] = $data['external_reference'] ?? $data['externe_referenz'] ?? null;
'create' => time(), unset($data['gesamtrabatt'], $data['leistungszeitraum'], $data['einleitender_text'], $data['externe_referenz'], $data['billing_delivery']);
'edit' => time()
], $data); $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; return true;
} }
@@ -389,9 +449,15 @@ class ManualInvoiceController extends TTCrud
unset($data['positions']); unset($data['positions']);
} }
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') { if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id']))) {
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden'; if ($invoice->lock == 1) {
return false; $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 // Convert invoice_date from string to timestamp if needed
@@ -432,23 +498,39 @@ class ManualInvoiceController extends TTCrud
$me->loadMe(); $me->loadMe();
foreach ($this->tempPositions as $position) { foreach ($this->tempPositions as $position) {
// Skip empty positions $articleName = $position['warehousearticle_name'] ?? $position['product_name'] ?? '';
if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue; if (empty($articleName) || ($position['amount'] ?? 0) == 0) continue;
// Map _group to position_group $amount = floatval($position['amount']);
$groupName = $position['_group'] ?? null; $price = floatval($position['price']);
unset($position['_group']); $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, 'manualinvoice_id' => $invoiceId,
'position_group' => $groupName, 'position_group' => $position['_group'] ?? null,
'unit' => 'Stk.', 'matchcode' => $position['matchcode'] ?? null,
'discount' => 0, '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, 'create_by' => $me->id,
'edit_by' => $me->id, 'edit_by' => $me->id,
'create' => time(), 'create' => time(),
'edit' => time() 'edit' => time()
], $position)); ]);
} }
$this->tempPositions = []; $this->tempPositions = [];
} }
@@ -458,17 +540,13 @@ class ManualInvoiceController extends TTCrud
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]); $positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
$subtotal = array_sum(array_column($positions, 'price_total')); $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; $grossTotal = 0;
foreach ($positions as $pos) { foreach ($positions as $pos) {
$positionNet = $pos->price_total; $positionNet = $pos->price_total;
$positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100); $positionAfterDiscount = $positionNet * (1 - $totalDiscount / 100);
$grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100); $grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100);
} }
@@ -530,11 +608,11 @@ class ManualInvoiceController extends TTCrud
'id' => $pos->id, 'id' => $pos->id,
'manualinvoice_id' => $pos->manualinvoice_id, 'manualinvoice_id' => $pos->manualinvoice_id,
'_group' => $pos->position_group ?? '', '_group' => $pos->position_group ?? '',
'billing_id' => $pos->billing_id,
'contract_id' => $pos->contract_id,
'matchcode' => $pos->matchcode, 'matchcode' => $pos->matchcode,
'product_id' => $pos->product_id, 'warehousearticle_id' => $pos->warehousearticle_id,
'product_name' => $pos->product_name, 'warehousearticle_name' => $pos->warehousearticle_name,
'product_id' => $pos->warehousearticle_id,
'product_name' => $pos->warehousearticle_name,
'product_info' => $pos->product_info, 'product_info' => $pos->product_info,
'amount' => $pos->amount, 'amount' => $pos->amount,
'unit' => $pos->unit ?? 'Stk.', 'unit' => $pos->unit ?? 'Stk.',
@@ -580,19 +658,20 @@ class ManualInvoiceController extends TTCrud
foreach ($existingCredits as $credit) { foreach ($existingCredits as $credit) {
foreach ($credit->getProperty('positions') as $creditPos) { 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); $creditedAmounts[$key] = ($creditedAmounts[$key] ?? 0) + abs($creditPos->amount);
} }
} }
$availablePositions = []; $availablePositions = [];
foreach ($positions as $pos) { foreach ($positions as $pos) {
$key = $pos->product_id . '_' . $pos->matchcode; $key = $pos->warehousearticle_id . '_' . $pos->matchcode;
$availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0); $availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0);
if ($availableAmount > 0) { if ($availableAmount > 0) {
$availablePositions[] = [ $availablePositions[] = [
'id' => $pos->id, 'id' => $pos->id,
'product_name' => $pos->product_name, 'warehousearticle_name' => $pos->warehousearticle_name,
'product_name' => $pos->warehousearticle_name,
'product_info' => $pos->product_info, 'product_info' => $pos->product_info,
'original_amount' => $pos->amount, 'original_amount' => $pos->amount,
'credited_amount' => $creditedAmounts[$key] ?? 0, 'credited_amount' => $creditedAmounts[$key] ?? 0,
@@ -600,7 +679,8 @@ class ManualInvoiceController extends TTCrud
'unit' => $pos->unit ?? 'Stk.', 'unit' => $pos->unit ?? 'Stk.',
'price' => $pos->price, 'price' => $pos->price,
'vatrate' => $pos->vatrate, 'vatrate' => $pos->vatrate,
'product_id' => $pos->product_id, 'warehousearticle_id' => $pos->warehousearticle_id,
'product_id' => $pos->warehousearticle_id,
'matchcode' => $pos->matchcode, 'matchcode' => $pos->matchcode,
'fibu_cost_account' => $pos->fibu_cost_account, 'fibu_cost_account' => $pos->fibu_cost_account,
'fibu_taxcode' => $pos->fibu_taxcode 'fibu_taxcode' => $pos->fibu_taxcode
@@ -626,6 +706,12 @@ class ManualInvoiceController extends TTCrud
if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) { if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) {
self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']); 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(); $me = new User();
@@ -634,10 +720,10 @@ class ManualInvoiceController extends TTCrud
$invoiceData = [ $invoiceData = [
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(), 'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => time(), 'invoice_date' => time(),
'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null, 'performance_period' => $originalInvoice->performance_period ?? null,
'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number, 'introductory_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
'externe_referenz' => $originalInvoice->externe_referenz ?? null, 'external_reference' => $originalInvoice->external_reference ?? null,
'gesamtrabatt' => 0, 'total_discount' => 0,
'owner_id' => $originalInvoice->owner_id, 'owner_id' => $originalInvoice->owner_id,
'billingaddress_id' => $originalInvoice->billingaddress_id, 'billingaddress_id' => $originalInvoice->billingaddress_id,
'customer_number' => $originalInvoice->customer_number, 'customer_number' => $originalInvoice->customer_number,
@@ -663,7 +749,6 @@ class ManualInvoiceController extends TTCrud
'email' => $originalInvoice->email, 'email' => $originalInvoice->email,
'uid' => $originalInvoice->uid, 'uid' => $originalInvoice->uid,
'billing_type' => $originalInvoice->billing_type, 'billing_type' => $originalInvoice->billing_type,
'billing_delivery' => $originalInvoice->billing_delivery,
'bank_account_bank' => $originalInvoice->bank_account_bank, 'bank_account_bank' => $originalInvoice->bank_account_bank,
'bank_account_owner' => $originalInvoice->bank_account_owner, 'bank_account_owner' => $originalInvoice->bank_account_owner,
'bank_account_iban' => $originalInvoice->bank_account_iban, 'bank_account_iban' => $originalInvoice->bank_account_iban,
@@ -673,6 +758,8 @@ class ManualInvoiceController extends TTCrud
'vatgroup_id' => $originalInvoice->vatgroup_id, 'vatgroup_id' => $originalInvoice->vatgroup_id,
'credit_for_invoice_id' => $originalInvoiceId, 'credit_for_invoice_id' => $originalInvoiceId,
'status' => 'erstellt', 'status' => 'erstellt',
'lock' => 0,
'exported' => 0,
'create' => time(), 'create' => time(),
'edit' => time(), 'edit' => time(),
'create_by' => $me->id, 'create_by' => $me->id,
@@ -681,6 +768,7 @@ class ManualInvoiceController extends TTCrud
if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) { if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) {
self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']); self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']);
return;
} }
foreach ($positions as $pos) { foreach ($positions as $pos) {
@@ -688,8 +776,8 @@ class ManualInvoiceController extends TTCrud
ManualInvoicepositionModel::create([ ManualInvoicepositionModel::create([
'manualinvoice_id' => $creditInvoiceId, 'manualinvoice_id' => $creditInvoiceId,
'position_group' => null, 'position_group' => null,
'product_id' => $pos['product_id'], 'warehousearticle_id' => $pos['warehousearticle_id'] ?? $pos['product_id'] ?? 0,
'product_name' => $pos['product_name'], 'warehousearticle_name' => $pos['warehousearticle_name'] ?? $pos['product_name'] ?? '',
'product_info' => $pos['product_info'] ?? '', 'product_info' => $pos['product_info'] ?? '',
'amount' => -abs($pos['amount']), 'amount' => -abs($pos['amount']),
'unit' => $pos['unit'] ?? 'Stk.', 'unit' => $pos['unit'] ?? 'Stk.',
@@ -701,8 +789,6 @@ class ManualInvoiceController extends TTCrud
'matchcode' => $pos['matchcode'] ?? null, 'matchcode' => $pos['matchcode'] ?? null,
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null, 'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null, 'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
'contract_id' => 0,
'billing_id' => null,
'create_by' => $me->id, 'create_by' => $me->id,
'edit_by' => $me->id, 'edit_by' => $me->id,
'create' => time(), 'create' => time(),
@@ -718,7 +804,11 @@ class ManualInvoiceController extends TTCrud
protected function beforeDelete(): bool { protected function beforeDelete(): bool {
if ($id = $this->request->id) { if ($id = $this->request->id) {
$invoice = ManualInvoiceModel::get($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'; $this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden';
return false; return false;
} }
@@ -732,4 +822,119 @@ class ManualInvoiceController extends TTCrud
} }
return true; 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 int $id;
public ?string $invoice_number; public ?string $invoice_number;
public int $invoice_date; public int $invoice_date;
public ?string $leistungszeitraum; public ?string $performance_period;
public ?string $einleitender_text; public ?string $introductory_text;
public ?string $externe_referenz; public ?string $external_reference;
public float $gesamtrabatt; public float $total_discount;
public int $owner_id; public int $owner_id;
public int $billingaddress_id; public int $billingaddress_id;
public int $customer_number; public int $customer_number;
@@ -33,7 +33,6 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public ?string $email; public ?string $email;
public ?string $uid; public ?string $uid;
public string $billing_type; public string $billing_type;
public string $billing_delivery;
public ?string $bank_account_bank; public ?string $bank_account_bank;
public ?string $bank_account_owner; public ?string $bank_account_owner;
public ?string $bank_account_iban; public ?string $bank_account_iban;
@@ -44,6 +43,8 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public ?int $bmd_export_date; public ?int $bmd_export_date;
public ?int $date_delivered; public ?int $date_delivered;
public string $status; public string $status;
public int $lock = 0;
public int $exported = 0;
public ?int $credit_for_invoice_id; public ?int $credit_for_invoice_id;
public int $create_by; public int $create_by;
public int $edit_by; public int $edit_by;

View File

@@ -4,11 +4,9 @@ class ManualInvoicepositionModel extends TTCrudBaseModel {
public int $id; public int $id;
public ?int $manualinvoice_id; public ?int $manualinvoice_id;
public ?string $position_group; public ?string $position_group;
public ?int $billing_id;
public int $contract_id;
public ?string $matchcode; public ?string $matchcode;
public int $product_id; public int $warehousearticle_id;
public string $product_name; public string $warehousearticle_name;
public ?string $product_info; public ?string $product_info;
public float $amount; public float $amount;
public string $unit; 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']); $resp_data = Rimoapi::assignOaid($this->oaid, $ftu_data['id']);
// update OAID export data // 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_id = $ftu_data['id'];
$exp_data_update->rimo->ftu_name = $ftu_data['name']; $exp_data_update->rimo->ftu_name = $ftu_data['name'];
$exp_data_update->rimo->ftu_assigned_date = date("U"); $exp_data_update->rimo->ftu_assigned_date = date("U");
@@ -306,11 +311,11 @@ class OpenAccessId extends mfBaseModel {
public function getExportData($key = false) { public function getExportData($key = false) {
if(!$this->export_data) { if(!$this->export_data) {
return []; return new StdClass();
} else { } else {
$exdata = json_decode($this->export_data); $exdata = json_decode($this->export_data);
if(!is_object($exdata)) { if(!is_object($exdata)) {
return []; return new StdClass();
} }
if(!$key) { if(!$key) {

View File

@@ -67,6 +67,27 @@ class Order extends mfBaseModel {
//var_dump($this->terminations);exit; //var_dump($this->terminations);exit;
return $terminations; 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() { public function getShippingdate() {
if(!$this->id) { if(!$this->id) {

View File

@@ -359,7 +359,7 @@ class OrderController extends mfBaseController {
return $new_filter; return $new_filter;
} }
protected function addAction() { public function addAction() {
//var_dump($this->request->filter);exit; //var_dump($this->request->filter);exit;
@@ -393,9 +393,9 @@ class OrderController extends mfBaseController {
$products[$pn->product_id] = $pn->product; $products[$pn->product_id] = $pn->product;
} }
} }
} }
$order = $this->layout()->get("order"); $order = $this->layout()->get("order");
if($order) { if($order) {
foreach($order->products as $op) { foreach($order->products as $op) {
@@ -404,7 +404,7 @@ class OrderController extends mfBaseController {
} }
} }
} }
$this->layout()->set("products", $products); $this->layout()->set("products", $products);
$countries = CountryModel::getAll(); $countries = CountryModel::getAll();
@@ -969,6 +969,12 @@ class OrderController extends mfBaseController {
} }
$product_data = []; $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["order_id"] = $new_id;
$product_data["product_id"] = $p["product_id"]; $product_data["product_id"] = $p["product_id"];
$product_data['amount'] = (!empty($p['amount'])) ? $p['amount'] : 1; $product_data['amount'] = (!empty($p['amount'])) ? $p['amount'] : 1;
@@ -1338,7 +1344,7 @@ class OrderController extends mfBaseController {
$this->layout()->setFlash("Keine Berechtigung", "error"); $this->layout()->setFlash("Keine Berechtigung", "error");
$this->redirect("Order"); $this->redirect("Order");
} }
$r = $this->request; $r = $this->request;
$order_id = $r->id; $order_id = $r->id;
@@ -1366,7 +1372,146 @@ class OrderController extends mfBaseController {
$this->returnJson(["status" => "OK", "order" => ['id' => $order_id]]); $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() { protected function deleteAction() {
if(!$this->me->is(["Admin","salespartner"])) { if(!$this->me->is(["Admin","salespartner"])) {
$this->layout()->setFlash("Keine Berechtigung", "error"); $this->layout()->setFlash("Keine Berechtigung", "error");

View File

@@ -48,11 +48,7 @@ class OrderProduct extends mfBaseModel {
public function getProperty($name) { public function getProperty($name) {
if($this->$name == null) { if($this->$name == null) {
if(!$this->id) {
return null;
}
if($name == "cpeprovisioning") { if($name == "cpeprovisioning") {
$this->cpeprovisioning = CpeprovisioningModel::getFirst(["orderproduct_id" => $this->id]); $this->cpeprovisioning = CpeprovisioningModel::getFirst(["orderproduct_id" => $this->id]);
return $this->cpeprovisioning; return $this->cpeprovisioning;
@@ -128,7 +124,7 @@ class OrderProduct extends mfBaseModel {
} }
return $this->editor; return $this->editor;
} }
$classname = ucfirst($name); $classname = ucfirst($name);
$idfield = $name."_id"; $idfield = $name."_id";
$this->$name = mfValuecache::singleton()->get("mfObjectmodel-$name-".$this->$idfield); $this->$name = mfValuecache::singleton()->get("mfObjectmodel-$name-".$this->$idfield);

View File

@@ -5,6 +5,8 @@ class OrderProductModel
public $order_id; public $order_id;
public $product_id; public $product_id;
public $termination_id; public $termination_id;
public $oaid;
public $preorder_id;
public $voicenumber; public $voicenumber;
public $voiceplan_id; public $voiceplan_id;
public $domain; 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)) { if (array_key_exists("voicenumber", $filter)) {
$voicenumber = FronkDB::singleton()->escape($filter['voicenumber']); $voicenumber = FronkDB::singleton()->escape($filter['voicenumber']);
if ($voicenumber) { if ($voicenumber) {

View File

@@ -26,6 +26,7 @@ class Preorder extends mfBaseModel {
private $statusjournals; private $statusjournals;
private $cancel_request_status; private $cancel_request_status;
private $cancel_request_creator; private $cancel_request_creator;
private $orderproduct;
protected function beforeUpdate($data) { protected function beforeUpdate($data) {
if(!array_key_exists("edit_by", $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); $first_ctag = $search_ctag - ($search_ctag % $ctags_per_home);
$last_ctag = $first_ctag + $ctags_per_home - 1; $last_ctag = $first_ctag + $ctags_per_home - 1;
$mgmt_ctag_exists = false;
$mgmt_ctag = null; $mgmt_ctag = null;
$ctag_range = []; $ctag_range = [];
for($i = $first_ctag; $i <= $last_ctag; $i++) { for($i = $first_ctag; $i <= $last_ctag; $i++) {
if(!PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name])) { $ctag = PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name]);
if($i == $last_ctag) { if(!$ctag) {
if($i == $last_ctag && !$mgmt_ctag_exists) {
// mgmt ctag should be the last in range // mgmt ctag should be the last in range
$mgmt_ctag = $i; $mgmt_ctag = $i;
continue; continue;
} }
$ctag_range[] = $i; $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]; return [$ctag_range, $mgmt_ctag];
} }
@@ -1682,6 +1691,13 @@ class Preorder extends mfBaseModel {
return $this->editor; 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") { if($name == "creator") {
$user = mfValuecache::singleton()->get("Worker-id-" . $this->create_by); $user = mfValuecache::singleton()->get("Worker-id-" . $this->create_by);
if($user) { if($user) {

View File

@@ -1048,6 +1048,171 @@ class PreorderController extends mfBaseController {
$this->layout()->set("no_filename", false); $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() { protected function apiAction() {
$do = $this->request->do; $do = $this->request->do;
$data = []; $data = [];
@@ -1420,8 +1585,14 @@ class PreorderController extends mfBaseController {
$new_remark = date("d.m.Y").": ".$new_remark; $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 // upload remark to Rimo
if(!Rimoapi::addRemark($workorder->rimo_id, $new_remark)) { if(!Rimoapi::addRemark($apikey, $workorder->rimo_id, $new_remark)) {
return false; return false;
} }

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,45 @@ use PHPMailer\PHPMailer\Exception;
class RadiusController extends mfBaseController { class RadiusController extends mfBaseController {
private User $me; 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 { 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 = new User();
$me->loadMe(); $me->loadMe();
$this->layout()->set("me", $me); $this->layout()->set("me", $me);
@@ -51,20 +87,32 @@ class RadiusController extends mfBaseController {
$acs = $this->getGenieACS(); $acs = $this->getGenieACS();
// Set speedtest parameters on the device $resolvedId = $this->resolveDeviceId($deviceId, $acs);
$acs->setParameterValues($deviceId, [ 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.Start' => 1,
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1, 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1,
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true
]); ]);
// Get device and extract IP sleep(3);
$device = $acs->getDevice($deviceId);
$ip = GenieACS::getExternalIP($device); $device = $acs->getDevice($resolvedId);
$managementIp = GenieACS::getManagementIP($device);
$externalIp = GenieACS::getExternalIP($device);
$ip = $externalIp ?: $managementIp;
if (!$ip) self::sendError("Could not determine device IP"); if (!$ip) self::sendError("Could not determine device IP");
// Trigger speedtest via external API
$url = "http://acs.xinon.at:5000/run-speedtest"; $url = "http://acs.xinon.at:5000/run-speedtest";
$apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ"; $apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ";
$data = json_encode(['ip' => $ip]); $data = json_encode(['ip' => $ip]);
@@ -84,9 +132,8 @@ class RadiusController extends mfBaseController {
if ($response === false) self::sendError("Failed to connect to speedtest server"); 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) { } catch (Exception $e) {
$this->log->debug("Speedtest Error", ['error' => $e->getMessage()]);
self::sendError("Error running speedtest: " . $e->getMessage()); self::sendError("Error running speedtest: " . $e->getMessage());
} }
} }
@@ -101,11 +148,12 @@ class RadiusController extends mfBaseController {
$acs = $this->getGenieACS(); $acs = $this->getGenieACS();
// Request parameter refresh $resolvedId = $this->resolveDeviceId($deviceId, $acs);
$acs->getParameterValues($deviceId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']); if (!$resolvedId) self::sendError("Device not found in GenieACS");
// Get device info with full data $acs->getParameterValues($resolvedId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']);
$device = $acs->getDevice($deviceId);
$device = $acs->getDevice($resolvedId);
if (!$device) self::sendError("Device not found"); if (!$device) self::sendError("Device not found");
@@ -178,6 +226,19 @@ class RadiusController extends mfBaseController {
return new GenieACS($host, $username, $password); 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() { protected function genieacsGetDeviceByIpAction() {
try { try {
$ip = $_GET['ip'] ?? null; $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() { protected function genieacsRebootDeviceAction() {
try { try {
$input = json_decode(file_get_contents('php://input'), true); $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"); if (!$deviceId) self::sendError("Device ID is required");
$acs = $this->getGenieACS(); $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"); if (!$creds) self::sendError("Could not obtain credentials for FritzBox");
$url = "http://acs.xinon.at:5000/read-fritz-eventlog"; $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"); if (!$deviceId) self::sendError("Device ID is required");
$acs = $this->getGenieACS(); $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"); if (!$creds) self::sendError("Could not obtain credentials for FritzBox");
$url = "http://acs.xinon.at:5000/read-fritz"; $url = "http://acs.xinon.at:5000/read-fritz";
@@ -523,47 +657,302 @@ class RadiusController extends mfBaseController {
private function getVendor($mac) { private function getVendor($mac) {
$mac = strtoupper(str_replace([':', '-', '.'], '', $mac)); $mac = strtoupper(str_replace([':', '-', '.'], '', $mac));
if (strlen($mac) < 6) return null; if (strlen($mac) < 6) return null;
$path = TEMP_DIR . '/mac-vendors.csv'; $path = TEMP_DIR . '/mac-vendors.csv';
if (!file_exists($path)) return null; if (!file_exists($path)) return null;
// Format as XX:XX:XX // Format as XX:XX:XX
$prefix = substr($mac, 0, 2) . ':' . substr($mac, 2, 2) . ':' . substr($mac, 4, 2); $prefix = substr($mac, 0, 2) . ':' . substr($mac, 2, 2) . ':' . substr($mac, 4, 2);
// Use grep for speed if available, else fallback to basic search? // Use grep for speed if available, else fallback to basic search?
// Assuming Linux env as per docker context. // Assuming Linux env as per docker context.
$cmd = "grep -m 1 \"^" . $prefix . "\" " . escapeshellarg($path); $cmd = "grep -m 1 \"^" . $prefix . "\" " . escapeshellarg($path);
$output = shell_exec($cmd); $output = shell_exec($cmd);
if ($output) { if ($output) {
$parts = str_getcsv($output); $parts = str_getcsv($output);
if (isset($parts[1])) return $parts[1]; if (isset($parts[1])) return $parts[1];
} }
return null; return null;
} }
// ========== AVM Scanner Methods ==========
private static $avmScannerStateFile = null;
private static $avmPrefixes = [
'00:04:0E', '00:15:0C', '00:1A:4F', '00:1C:4A', '00:1F:3F', '00:24:FE',
'04:B4:FE', '08:96:D7', '08:B6:57', '0C:72:74',
'1C:ED:6F',
'24:65:11', '2C:3A:FD', '2C:91:AB',
'34:31:C4', '34:81:C4', '34:E1:A9', '38:10:D5', '3C:37:12', '3C:A6:2F',
'44:4E:6D', '48:5D:35',
'50:E6:36', '5C:49:79',
'60:B5:8D',
'74:42:7F', '7C:FF:4D',
'80:23:95',
'98:9B:CB', '98:A9:65', '9C:C7:A6',
'B0:F2:08', 'B4:FC:7D', 'BC:05:43',
'C0:25:06', 'C8:0E:14', 'CC:CE:1E',
'D0:12:CB', 'D4:24:DD', 'DC:15:C8', 'DC:39:6F',
'E0:08:55', 'E0:28:6D', 'E8:DF:70',
'F0:B0:14'
];
private function getAvmScannerStatePath(): string {
return BASEDIR . '/files/avm_scanner.json';
}
private function loadAvmScannerState(): array {
$path = $this->getAvmScannerStatePath();
if (file_exists($path)) {
$content = file_get_contents($path);
$state = json_decode($content, true);
if (is_array($state)) return $state;
}
return [
'scanning' => false,
'stopRequested' => false,
'progress' => ['current' => 0, 'total' => 0],
'currentDevice' => null,
'startedAt' => null,
'startedBy' => null,
'lastUpdated' => date('c'),
'devices' => []
];
}
private function saveAvmScannerState(array $state): void {
$dir = dirname($this->getAvmScannerStatePath());
if (!is_dir($dir)) @mkdir($dir, 0777, true);
$state['lastUpdated'] = date('c');
file_put_contents($this->getAvmScannerStatePath(), json_encode($state, JSON_PRETTY_PRINT));
}
private function isAvmMac(string $mac): bool {
$mac = strtoupper(trim($mac));
$prefix = substr($mac, 0, 8);
return in_array($prefix, self::$avmPrefixes);
}
private function fetchRadiusUsersFromApi(): array {
$url = "http://radius.xinon.at/api.php";
$opts = [
"http" => [
"method" => "GET",
"header" => "Authorization: Basic " . base64_encode("admin:saveman"),
"timeout" => 120
]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response === false) return [];
$data = json_decode($response, true);
return is_array($data) ? $data : [];
}
private function fetchRadacctForUser(string $username): ?array {
$url = "http://radius.xinon.at/api.php?action2=fetchRadacct&username=" . urlencode($username);
$opts = [
"http" => [
"method" => "GET",
"header" => "Authorization: Basic " . base64_encode("admin:saveman"),
"timeout" => 10
]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response === false) return null;
$data = json_decode($response, true);
return is_array($data) ? $data : null;
}
protected function avmScannerGetStateAction() {
$state = $this->loadAvmScannerState();
header('Content-Type: application/json');
echo json_encode($state);
die();
}
protected function avmScannerGetUsersAction() {
$users = $this->fetchRadiusUsersFromApi();
$debug = $_GET['debug'] ?? false;
$macUsers = 0;
$avmMacUsers = 0;
$notInAcs = 0;
$macPrefixCounts = [];
$avmUsers = [];
foreach ($users as $user) {
$username = trim($user['username'] ?? '');
if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue;
$macUsers++;
$prefix = strtoupper(substr($username, 0, 8));
$macPrefixCounts[$prefix] = ($macPrefixCounts[$prefix] ?? 0) + 1;
if (!$this->isAvmMac($username)) continue;
$avmMacUsers++;
$info = $user['info'] ?? '';
if (stripos($info, 'ACS') !== false) continue;
$notInAcs++;
$user['username'] = $username;
$avmUsers[] = $user;
}
if ($debug) {
arsort($macPrefixCounts);
header('Content-Type: application/json');
echo json_encode([
'totalFromApi' => count($users),
'macAddressUsers' => $macUsers,
'avmMacUsers' => $avmMacUsers,
'notInAcs' => $notInAcs,
'topMacPrefixes' => array_slice($macPrefixCounts, 0, 30, true),
'avmPrefixes' => self::$avmPrefixes
]);
die();
}
header('Content-Type: application/json');
echo json_encode(['users' => $avmUsers, 'count' => count($avmUsers)]);
die();
}
protected function avmScannerStartAction() {
$state = $this->loadAvmScannerState();
if ($state['scanning']) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Scan already running']);
die();
}
// Count how many users will be scanned (for immediate feedback)
$users = $this->fetchRadiusUsersFromApi();
$count = 0;
foreach ($users as $user) {
$username = trim($user['username'] ?? '');
if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue;
if (!$this->isAvmMac($username)) continue;
$info = $user['info'] ?? '';
if (stripos($info, 'ACS') !== false) continue;
$count++;
}
// Spawn background script using nohup to ensure it runs independently
$scriptPath = BASEDIR . '/scripts/avm_scanner.php';
$logPath = BASEDIR . '/files/avm_scanner.log';
$cmd = "nohup php " . escapeshellarg($scriptPath) . " >> " . escapeshellarg($logPath) . " 2>&1 &";
shell_exec($cmd);
header('Content-Type: application/json');
echo json_encode(['success' => true, 'message' => 'Scan started', 'total' => $count]);
die();
}
protected function avmScannerStopAction() {
$state = $this->loadAvmScannerState();
$state['stopRequested'] = true;
$this->saveAvmScannerState($state);
header('Content-Type: application/json');
echo json_encode(['success' => true, 'message' => 'Stop requested']);
die();
}
protected function avmScannerToggleErledigtAction() {
$input = json_decode(file_get_contents('php://input'), true);
$mac = $input['mac'] ?? null;
if (!$mac) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['error' => 'MAC address required']);
die();
}
$state = $this->loadAvmScannerState();
foreach ($state['devices'] as &$device) {
if ($device['mac'] === $mac) {
$device['erledigt'] = !($device['erledigt'] ?? false);
break;
}
}
$this->saveAvmScannerState($state);
header('Content-Type: application/json');
echo json_encode(['success' => true]);
die();
}
private function detectFritzPort(string $ip): ?int {
$url = "https://acs.xinon.at/detect-port";
$data = json_encode(['fritz_ip' => $ip, 'timeout' => 3]);
$opts = [
"http" => [
"method" => "POST",
"header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n",
"content" => $data,
"timeout" => 15
],
"ssl" => ["verify_peer" => false, "verify_peer_name" => false]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response) {
$json = json_decode($response, true);
if ($json && $json['success'] && isset($json['port'])) {
return (int)$json['port'];
}
}
return null;
}
private function detectFritzDevice(string $ip, int $port): ?array {
$url = "https://acs.xinon.at/detect-device";
$data = json_encode(['fritz_ip' => $ip, 'fritz_port' => (string)$port]);
$opts = [
"http" => [
"method" => "POST",
"header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n",
"content" => $data,
"timeout" => 15
],
"ssl" => ["verify_peer" => false, "verify_peer_name" => false]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
if ($response) {
$json = json_decode($response, true);
if ($json && $json['success'] && isset($json['device'])) {
return $json['device'];
}
}
return null;
}
} }

View File

@@ -1,5 +1,7 @@
<?php <?php
use Smalot\PdfParser\Parser;
class RimoWorkorder extends mfBaseModel { class RimoWorkorder extends mfBaseModel {
private $adb_wohneinheit; private $adb_wohneinheit;
private $termination; private $termination;
@@ -56,6 +58,113 @@ class RimoWorkorder extends mfBaseModel {
return $ah; 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) { public function getProperty($name) {
if($this->$name == null) { if($this->$name == null) {

View File

@@ -13,6 +13,7 @@ class RimoWorkorderController extends mfBaseController {
protected function downloadAhaAction() { protected function downloadAhaAction() {
$workorder_id = $this->request->id; $workorder_id = $this->request->id;
$inline = !empty($this->request->inline);
if(!$workorder_id || $workorder_id < 1) { if(!$workorder_id || $workorder_id < 1) {
header("HTTP/1.1 400 Bad Request"); header("HTTP/1.1 400 Bad Request");
@@ -34,10 +35,33 @@ class RimoWorkorderController extends mfBaseController {
exit; exit;
} }
header("Content-type: text/pdf"); $filename = $workorder->rimo_name.'_AHA.pdf';
header('Content-disposition: attachment; filename="'.$workorder->rimo_name.'_AHA.pdf"'); $disposition = $inline ? 'inline' : 'attachment';
header("Content-type: application/pdf");
header('Content-disposition: '.$disposition.'; filename="'.$filename.'"');
echo $return; echo $return;
exit; 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; 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) protected function init($request = null)
{ {
$this->needlogin = true; $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); 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) protected function indexAction($request)
{ {
if (!$this->isAdmin()) { if (!$this->isAdmin()) {
@@ -32,6 +40,7 @@ class UserController extends mfBaseController
Helper::renderVue($this, "User", "Benutzer", [ Helper::renderVue($this, "User", "Benutzer", [
"IS_ADMIN" => $this->me->isAdmin(), "IS_ADMIN" => $this->me->isAdmin(),
"CAN_MANAGE_USERS" => $this->canManageUsers(),
"USERS" => array_map(fn($user) => [ "USERS" => array_map(fn($user) => [
"username" => $user->username, "username" => $user->username,
"name" => $user->name, "name" => $user->name,
@@ -53,6 +62,7 @@ class UserController extends mfBaseController
protected function formAction() { protected function formAction() {
if (!$this->isAdmin()) $this->redirect("Dashboard"); if (!$this->isAdmin()) $this->redirect("Dashboard");
if (!$this->canManageUsers()) $this->redirect("User");
$id = $this->request->id; $id = $this->request->id;
$user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User(); $user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User();
@@ -178,6 +188,7 @@ class UserController extends mfBaseController
protected function generateApikeyAction($request) { protected function generateApikeyAction($request) {
if (!$this->isAdmin()) $this->redirect("Dashboard"); if (!$this->isAdmin()) $this->redirect("Dashboard");
if (!$this->canManageUsers()) $this->redirect("User");
$id = $request['id']; $id = $request['id'];
if (!is_numeric($id) || $id < 1) { if (!is_numeric($id) || $id < 1) {
@@ -207,6 +218,11 @@ class UserController extends mfBaseController
unset($r->address_id); 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'); if (!$id && !$r->username) self::redirect('User');
$user = new User($id); $user = new User($id);
@@ -569,7 +585,7 @@ class UserController extends mfBaseController
} }
protected function impersonateAction() { 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"); header("HTTP/1.1 403 Forbidden");
exit; exit;
} }
@@ -590,6 +606,10 @@ class UserController extends mfBaseController
protected function sendLoginEmailAction() protected function sendLoginEmailAction()
{ {
if (!$this->canManageUsers()) {
self::sendError("Keine Berechtigung.");
}
$id = $this->request->id; $id = $this->request->id;
if (!$id || !is_numeric($id)) { if (!$id || !is_numeric($id)) {
self::sendError("Benutzer-ID fehlt oder ist ungültig."); 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); $number_data['port_out_date'] = self::dateToTimestamp($r->port_out_date);
} }
if($r->disabled === "1") { if($r->disabled == "1") {
$number_data['disabled'] = 1; if(!$number->disabled) {
$number_data['disabled'] = date('U');
$number_data['disabled_by'] = $this->me->id;
}
switch($r->disabled_reason) { switch($r->disabled_reason) {
case "ported_out": case "ported_out":
$number_data['disabled_reason'] = "ported_out"; $number_data['disabled_reason'] = "ported_out";
@@ -123,6 +126,9 @@ class VoicenumberController extends mfBaseController {
case "ported_back": case "ported_back":
$number_data['disabled_reason'] = "ported_back"; $number_data['disabled_reason'] = "ported_back";
break; break;
case "contract_cancelled":
$number_data['disabled_reason'] = "contract_cancelled";
break;
case "legacy": case "legacy":
$number_data['disabled_reason'] = "legacy"; $number_data['disabled_reason'] = "legacy";
break; break;

View File

@@ -17,6 +17,7 @@ class VoicenumberModel {
public $ported_out; public $ported_out;
public $disabled; public $disabled;
public $disabled_reason; public $disabled_reason;
public $disabled_by;
public $enable_on_date; public $enable_on_date;
public $comment; 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' => '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' => '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' => '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' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', '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], ['key' => 'warningAmount', 'text' => 'Warnmenge', 'required' => true,'modal' => ['type' => 'number'], 'table' => false],
@@ -111,7 +111,7 @@ class WarehouseArticleController extends TTCrud {
if ($categoryId) { if ($categoryId) {
$category = WarehouseCategory::get($categoryId); $category = WarehouseCategory::get($categoryId);
if ($category && $category->articleNumberPrefix) { if ($category && $category->articleNumberPrefix) {
$expectedPrefix = $category->articleNumberPrefix; $expectedPrefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
$articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix)); $articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix));
if ($articlePrefix !== $expectedPrefix) { if ($articlePrefix !== $expectedPrefix) {
self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen."); 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) self::sendError("Kategorie nicht gefunden");
if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix"); 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(); $db = FronkDB::singleton();
// Get all existing article numbers with this prefix, sorted // Get all existing article numbers with this prefix, sorted
@@ -253,7 +253,7 @@ class WarehouseArticleController extends TTCrud {
]; ];
$pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars); $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); $filename = $pdf->render($wkhtmltopdfArgs);
@@ -262,4 +262,30 @@ class WarehouseArticleController extends TTCrud {
readfile($filename); readfile($filename);
die(); 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 ?int $isEndOfLife;
public string $unit; public string $unit;
public ?int $isSerialDocumentation; public ?int $isSerialDocumentation;
public int $revenueAccount; public int $vatgroup_id;
public int $create; public int $create;
public int $createBy; public int $createBy;
} }

View File

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

View File

@@ -16,7 +16,39 @@ class WarehouseCategoryController extends TTCrud {
]; ];
// @formatter:on // @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 { protected function beforeCreate(): bool {
$this->postData['articleNumberPrefix'] = $this->getNextFreePrefix(); $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['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
$this->postData['status'] = 'new'; $this->postData['status'] = 'new';
$this->postData['version'] = 1; $this->postData['version'] = 1;
$this->postData['validity'] = 14; $this->postData['validity'] = 31;
$this->postData['alternativePositions'] = json_encode([]); $this->postData['alternativePositions'] = json_encode([]);
return true; return true;
} }
@@ -366,10 +366,13 @@ class WarehouseOfferController extends TTCrud
$version = $this->request->version ?? null; $version = $this->request->version ?? null;
$offerData = null; $offerData = null;
$versionDate = null; // Date when this version was created (for validity calculation)
if ($version) { if ($version) {
$historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version); $historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version);
if ($historyEntry && !empty($historyEntry->data)) { if ($historyEntry && !empty($historyEntry->data)) {
$offerData = json_decode($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); $offer = WarehouseOfferModel::get($id);
if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden'); if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden');
$offerData = $offer; $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, "alternativeTotal" => $alternativeTotal,
"offerNumber" => $offerData->offerNumber, "offerNumber" => $offerData->offerNumber,
"offerDate" => $offerData->create, "offerDate" => $offerData->create,
"versionDate" => $versionDate ?? $offerData->create, // Date for validity calculation
"offerEditorName" => $editor ? $editor->name : 'Unbekannt', "offerEditorName" => $editor ? $editor->name : 'Unbekannt',
"includeTax" => true, "includeTax" => true,
"vatRate" => 0.20, "vatRate" => 0.20,
"offerText" => $offerData->notes ?? '', "offerText" => $offerData->notes ?? '',
"validity" => $offerData->validity ?? 14, "validity" => $offerData->validity ?? 31,
"closingText" => $offerData->closingText ?? '', "closingText" => $offerData->closingText ?? '',
"bank_iban" => TT_INVOICE_BANK_IBAN, "bank_iban" => TT_INVOICE_BANK_IBAN,
"bank_bic" => TT_INVOICE_BANK_BIC, "bank_bic" => TT_INVOICE_BANK_BIC,

View File

@@ -406,9 +406,72 @@ $appendToBody
]; ];
try { 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') { if ($postData['status'] !== 'noChanges') {
$orderAsArray['status'] = $postData['status']; $orderAsArray['status'] = $postData['status'];
WarehouseOrderModel::update($orderAsArray); 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 // Only create a log entry if there's actually something to log
@@ -416,7 +479,11 @@ $appendToBody
WarehouseLogModel::create($log); WarehouseLogModel::create($log);
} }
self::returnJson(['success' => true, 'message' => 'Log entry created']); self::returnJson([
'success' => true,
'message' => 'Log entry created',
'createdMovementIds' => $createdMovementIds
]);
} catch (Exception $e) { } catch (Exception $e) {
self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]); 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 string $delAddrPLZ;
public int $editor; public int $editor;
public ?string $note; public ?string $note;
public ?string $linkedMovementIds = null;
public ?string $deliveryNoteFileIds = null;
public string $positions; public string $positions;
public ?int $sendShippingNote; public ?int $sendShippingNote;
public int $create; public int $create;

View File

@@ -6,7 +6,7 @@ class WarehouseShippingNoteController extends TTCrud {
//@formatter:off //@formatter:off
protected array $columns = [ 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' => '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' => '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' => [ ['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']); $this->postData['positions'] = json_encode($this->postData['positions']);
if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']); if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']);
$this->postData['shippingNoteNumber'] = WarehouseShippingNoteModel::generateShippingNoteNumber();
return true; return true;
} }
@@ -130,7 +131,10 @@ class WarehouseShippingNoteController extends TTCrud {
// Get billing address info // Get billing address info
$billingAddress = null; $billingAddress = null;
if ($shippingNote->billingAddressId) { 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) // Determine price type ONCE (not in loop for performance)
@@ -486,10 +490,6 @@ class WarehouseShippingNoteController extends TTCrud {
"bank_bank" => TT_INVOICE_BANK_BANK, "bank_bank" => TT_INVOICE_BANK_BANK,
"bank_owner" => TT_INVOICE_BANK_OWNER]; "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 = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html");
$headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml); $headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml);
$headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $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_4 }}", "", $headerHtml);
$headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml);
$headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $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); $headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml);
$headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html"; $headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html";

View File

@@ -2,6 +2,7 @@
class WarehouseShippingNoteModel extends TTCrudBaseModel { class WarehouseShippingNoteModel extends TTCrudBaseModel {
public int $id; public int $id;
public ?string $shippingNoteNumber = null;
public ?int $billingAddressId; public ?int $billingAddressId;
public ?string $type; public ?string $type;
public ?string $metadata; public ?string $metadata;
@@ -21,4 +22,23 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel {
public ?int $eShopOrderId; public ?int $eShopOrderId;
public ?int $create; public ?int $create;
public ?int $createBy; 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 $additionalInfo;
public ?string $cableLength; public ?string $cableLength;
public ?string $cableType; public ?string $cableType;
public ?string $metadata;
public int $create; public int $create;
public int $createBy; public int $createBy;
@@ -199,4 +200,62 @@ class WorkorderModel extends TTCrudBaseModel
$result = $db->query($sql); $result = $db->query($sql);
return $result ? $result->fetch_assoc()['count'] : 0; 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() public function indexAction()
{ {
$this->createWorkordersFromPreorders(); $this->createWorkordersFromPreorders();
$this->autoCompleteDocumentedWorkorders();
$this->archiveWorkorders(); $this->archiveWorkorders();
parent::indexAction(); parent::indexAction();
} }

View File

@@ -60,6 +60,10 @@ class WorkorderBaseController extends TTCrud
$translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap); $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 = []; $responseDocs = [];
$typeCounts = []; $typeCounts = [];
@@ -141,6 +145,13 @@ class WorkorderBaseController extends TTCrud
return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]) ?? null; 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 //region BACKGROUND TASKS
/** /**
* Creates new workorders from preorders based on tenant configurations. * Creates new workorders from preorders based on tenant configurations.
@@ -272,5 +283,50 @@ class WorkorderBaseController extends TTCrud
} }
file_put_contents($lockFile, time()); 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 //endregion
} }

View File

@@ -121,6 +121,23 @@ class WorkorderCompanyController extends WorkorderBaseController {
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']); 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() { protected function requestInterventionAction() {
if (empty($this->postData['workorderId']) || empty($this->postData['journalText'])) self::sendError("Erforderliche Felder fehlen."); if (empty($this->postData['workorderId']) || empty($this->postData['journalText'])) self::sendError("Erforderliche Felder fehlen.");
$workorder = WorkorderModel::get($this->postData['workorderId']); $workorder = WorkorderModel::get($this->postData['workorderId']);
@@ -167,14 +184,23 @@ class WorkorderCompanyController extends WorkorderBaseController {
self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']); self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']);
return; return;
} }
self::returnJson([
$response = [
'success' => true, 'success' => true,
'documentationTypes' => json_decode($tenantConfig->documentationTypes, true), 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true),
'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired, 'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired,
'interventionTypes' => json_decode($tenantConfig->interventionTypes, true), 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true),
'requireCableLength' => $tenantConfig->requireCableLength, '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() { 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.']); 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['interventionTypes'] = json_encode($data['interventionTypes'] ?? []);
$data['workorderCreationFilters'] ??= '{}'; $data['workorderCreationFilters'] ??= '{}';
$data['workorderActiveFilters'] ??= '{}'; $data['workorderActiveFilters'] ??= '{}';
$data['autoCompleteFilter'] ??= null;
if (empty($data['id'])) { if (empty($data['id'])) {
$data['create'] = time(); $data['create'] = time();

View File

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

View File

@@ -10,6 +10,7 @@
"stomp-php/stomp-php": "^5", "stomp-php/stomp-php": "^5",
"phpmailer/phpmailer": "^6.9", "phpmailer/phpmailer": "^6.9",
"pear2/net_routeros": "dev-develop@dev", "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