Merge remote-tracking branch 'origin/spidev' into spidev
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,3 +51,5 @@ Thumbs.db
|
||||
|
||||
/Layout/default/DeviceDetail/
|
||||
/Layout/default/DeviceDetail/
|
||||
mobile-presentation/
|
||||
nul
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
</style>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $openreplayUserType = 'internal'; include(__DIR__ . "/../default/includes/openreplay.php"); ?>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -233,8 +233,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if($me->can("Fibu")): ?>
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-2 col-form-label" for="bank_account_bic"></label>
|
||||
<label class="col-lg-2 col-form-label" for="manual_invoice_sepa_limit">Manuelle Rechnungen abbuchen bis (€)</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>
|
||||
|
||||
|
||||
@@ -144,8 +144,12 @@
|
||||
</tr><tr>
|
||||
<th>BIC</th>
|
||||
<td><?=$address->bank_account_bic?></td>
|
||||
</tr><tr>
|
||||
<th>Manuelle Rechnungen abbuchen bis (erfordert Bankeinzug)</th>
|
||||
<td><?=number_format($address->manual_invoice_sepa_limit, 2, ",", ".")?> €</td>
|
||||
</tr>
|
||||
<?php if($me->can("Fibu")): ?>
|
||||
|
||||
<tr>
|
||||
<th>Sepa Mandatsdatum</th>
|
||||
<td><?=($address->sepa_date) ? date("d.m.Y", $address->sepa_date) : ""?></td>
|
||||
|
||||
@@ -318,6 +318,9 @@ $pagination_entity_name = "Zu provisionierende CPEs";
|
||||
<option value="FritzBox 6490 Cable" <?= ($product->cpeprovisioning->routertype == "FritzBox 6490 Cable") ? "selected='selected'" : "" ?>>
|
||||
FritzBox 6490 Cable (Inet, Phone, IPTV)
|
||||
</option>
|
||||
<option value="FritzBox 6670 Cable" <?= ($product->cpeprovisioning->routertype == "FritzBox 6670 Cable") ? "selected='selected'" : "" ?>>
|
||||
FritzBox 6670 Cable (Inet, Phone, IPTV)
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -25,22 +25,52 @@ $pagination_entity_name = "Rechnungen";
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://thetool.xinon.at/xfarm/" class="btn btn-primary" target="_blank"><i class="far fa-arrows-to-circle fa-fw"></i> Fakt-Rechnungen Import</a>
|
||||
<div class="col-3">
|
||||
<h4 class="header-title mb-3">Manuelle Rechnungen</h4>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://thetool.xinon.at/xfarm/" class="btn btn-primary mt-4" target="_blank"><i class="far fa-arrows-to-circle fa-fw"></i> Fakt-Rechnungen Import</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<form method="post" action="<?=self::getUrl("Invoice", "manualExportBmd")?>">
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<label class="form-label" for="manual_invoice_date_from">Rechnungsdatum von</label>
|
||||
<input type="text" class="form-control" name="manual_invoice_date_from" id="manual_invoice_date_from" value="" />
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<label class="form-label" for="manual_invoice_date_to">Rechnungsdatum bis</label>
|
||||
<input type="text" class="form-control" name="manual_invoice_date_to" id="manual_invoice_date_to" value="" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-outline-primary ml-1"><i class="far fa-fw fa-file-export"></i> Rechnungsexport für BMD</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body mb-3">
|
||||
<h4 class="header-title mb-3">Filter</h4>
|
||||
<h4 class="header-title mb-3">Contract Rechnungen</h4>
|
||||
|
||||
<form method="get" action="<?=self::getUrl("Invoice")?>">
|
||||
<div class="row">
|
||||
@@ -399,6 +429,22 @@ $pagination_entity_name = "Rechnungen";
|
||||
todayBtn: 'linked',
|
||||
autoclose: true
|
||||
});
|
||||
$('#manual_invoice_date_from').datepicker({
|
||||
orientation: "bottom",
|
||||
language: 'de',
|
||||
format: "dd.mm.yyyy",
|
||||
showWeekDays: true,
|
||||
todayBtn: 'linked',
|
||||
autoclose: true
|
||||
});
|
||||
$('#manual_invoice_date_to').datepicker({
|
||||
orientation: "bottom",
|
||||
language: 'de',
|
||||
format: "dd.mm.yyyy",
|
||||
showWeekDays: true,
|
||||
todayBtn: 'linked',
|
||||
autoclose: true
|
||||
});
|
||||
$('.datepicker').datepicker({
|
||||
orientation: "bottom",
|
||||
language: 'de',
|
||||
|
||||
@@ -68,9 +68,7 @@
|
||||
<td style="vertical-align: top; text-align: right;">
|
||||
<table style="display: inline-table; vertical-align: top;">
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding-right: 10px;">
|
||||
<img alt="QR-Code" src="{{ qrCodeSrc }}" style="display: block; height: 100%; max-height: 3.5cm; width: auto;">
|
||||
</td>
|
||||
{{ qrCodeHtml }}
|
||||
<td>
|
||||
<table class="invoice-details">
|
||||
<tr>
|
||||
|
||||
@@ -17,7 +17,7 @@ foreach($invoice->positions as $p) {
|
||||
}
|
||||
}
|
||||
|
||||
$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
|
||||
$total_discount = $invoice->total_discount ?? 0;
|
||||
$subtotal = 0;
|
||||
foreach($invoice->positions as $p) {
|
||||
$subtotal += $p->price_total ?? 0;
|
||||
@@ -127,8 +127,8 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||
|
||||
<h2 style="text-align: center;color: #005384">Ihre Xinon <?=($is_credit) ? "Gutschrift" : "Rechnung"?> vom <?=date("d.m.Y",$invoice->invoice_date)?></h2>
|
||||
|
||||
<?php if($invoice->einleitender_text ?? ''): ?>
|
||||
<p style="margin-top: 10pt; margin-bottom: 20pt; text-align: center; font-weight: bold;"><?=nl2br(htmlspecialchars($invoice->einleitender_text))?></p>
|
||||
<?php if($invoice->introductory_text ?? ''): ?>
|
||||
<p style="margin-top: 10pt; margin-bottom: 20pt; text-align: center; font-weight: bold;"><?=nl2br(htmlspecialchars($invoice->introductory_text))?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<table style="border-collapse: collapse; width: 100%;" id="invoiceTable">
|
||||
@@ -166,7 +166,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||
|
||||
<tr class="position <?=($i%2 == 0) ? "even" : "uneven" ?>">
|
||||
<td style="padding-left: 4pt; vertical-align: top;">
|
||||
<?=htmlspecialchars($p->product_name ?? '')?>
|
||||
<?=htmlspecialchars($p->warehousearticle_name ?? '')?>
|
||||
<?php if(isset($p->product_info) && $p->product_info): ?>
|
||||
<div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->product_info)?></div>
|
||||
<?php endif; ?>
|
||||
@@ -186,17 +186,17 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||
endforeach;
|
||||
endforeach;
|
||||
?>
|
||||
<?php if($gesamtrabatt > 0): ?>
|
||||
<?php if($total_discount > 0): ?>
|
||||
<tr style="background-color: #ebebeb; border-top: 2px solid black;">
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Zwischensumme:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($subtotal, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
<tr style="background-color: #ebebeb; border-bottom: 1px solid #ccc;">
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamtrabatt <?=number_format($gesamtrabatt, 2, ",", ".")?>%:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt; color: #d32f2f;">-<?=number_format($subtotal * ($gesamtrabatt / 100), 2, ",","."). " €"?></td>
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamtrabatt <?=number_format($total_discount, 2, ",", ".")?>%:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt; color: #d32f2f;">-<?=number_format($subtotal * ($total_discount / 100), 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;<?=($gesamtrabatt > 0) ? '' : 'border-top: 2px solid black;'?>">
|
||||
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;<?=($total_discount > 0) ? '' : 'border-top: 2px solid black;'?>">
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamt Netto:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($net_total, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
|
||||
9
Layout/default/MobileApp/App.php
Normal file
9
Layout/default/MobileApp/App.php
Normal 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';
|
||||
71
Layout/default/MobileApp/Base.php
Normal file
71
Layout/default/MobileApp/Base.php
Normal 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>
|
||||
10
Layout/default/MobileApp/WarehouseStocktake.php
Normal file
10
Layout/default/MobileApp/WarehouseStocktake.php
Normal 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';
|
||||
@@ -437,7 +437,7 @@
|
||||
<div class="form-group row">
|
||||
<label class="col-lg-2 col-form-label" for="order_date">Bestelldatum</label>
|
||||
<div class="col-lg-4">
|
||||
<input type="text" class="form-control" name="order_date" id="order_date" value="<?=($order->order_date) ? date("d.m.Y", $order->order_date) : ""?>" />
|
||||
<input type="text" class="form-control" name="order_date" id="order_date" value="<?=(is_numeric($order->order_date)) ? date("d.m.Y", $order->order_date) : $order->order_date ?>" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
@@ -553,7 +553,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body" id="products-form">
|
||||
<h4>Produkte</h4>
|
||||
@@ -585,6 +584,9 @@
|
||||
<div class="row product-container" id="position-<?=$i?>">
|
||||
<input type="hidden" name="products[<?=$i?>][delete]" id="products-<?=$i?>-delete" value="0" />
|
||||
<input type="hidden" name="products[<?=$i?>][orderproduct_id]" value="<?=$product->id?>" />
|
||||
<?php if($product->preorder_id): ?>
|
||||
<input type="hidden" name="products[<?=$i?>][preorder_id]" value="<?=$product->preorder_id?>" />
|
||||
<?php endif; ?>
|
||||
<div class="col-md-1 product-<?=$i?>">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
@@ -596,9 +598,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12 delete-button-container">
|
||||
<i class="btn btn-outline-danger fas fa-trash-can pointer" style="font-size: 1.5em" onclick="toggleDeletePos(<?=$i?>)"></i>
|
||||
</div>
|
||||
<?php if(!$product->preorder_id): ?>
|
||||
<div class="col-md-12 delete-button-container">
|
||||
<a href="#" class="btn btn-xl btn-outline-danger" onclick="toggleDeletePos(<?=$i?>); return false;"><i class="fas fa-fw fa-trash-can fa-xl pointer"></i></a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,6 +646,16 @@
|
||||
|
||||
</div>
|
||||
|
||||
<?php if($product->product->getAttributeValue("oaid_enabled")): ?>
|
||||
<div class="row mt-1 mb-2" id="oaid-<?=$i?>-line">
|
||||
<?php else: ?>
|
||||
<div class="row mt-1 mb-2 hidden" id="oaid-<?=$i?>-line">
|
||||
<?php endif; ?>
|
||||
<div class="col-4">
|
||||
<label class="form-label" for="oaid-<?=$i?>">OAID</label>
|
||||
<input type="text" name="products[<?=$i?>][oaid]" id="oaid-<?=$i?>" class="form-control" value="<?=$product->oaid?>" placeholder="optional">
|
||||
</div>
|
||||
</div>
|
||||
<?php if(
|
||||
(is_array($product->product->attributes) && count($product->product->attributes))
|
||||
&& (array_key_exists(TT_ATTRIB_TERMINATION_REQUIRED_NAME, $product->product->attributes)
|
||||
@@ -828,7 +842,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12 delete-button-container">
|
||||
<i class="btn btn-outline-info fas fa-trash-can pointer" title="Inhalte löschen" style="font-size: 1.5em" onclick="clearNewPos(<?=$i?>)"></i>
|
||||
<a href="#" class="btn btn-xl btn-outline-info" title="Inhalte löschen" onclick="clearNewPos(<?=$i?>); return false;"><i class="fas fa-fw fa-trash-can fa-xl pointer"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -870,6 +884,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-1 mb-2 hidden" id="oaid-<?=$i?>-line">
|
||||
<div class="col-4">
|
||||
<label class="form-label" for="oaid-<?=$i?>">OAID</label>
|
||||
<input type="text" name="products[<?=$i?>][oaid]" id="oaid-<?=$i?>" class="form-control" value="<?=$product->oaid?>" placeholder="optional">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-1 mb-2 hidden" id="termination_id-<?=$i?>-line">
|
||||
<!-- line to choose termination -->
|
||||
<div class="col-12">
|
||||
@@ -1383,9 +1404,16 @@
|
||||
});
|
||||
} else {
|
||||
$('#termination_id-' + id + '-line').hide();
|
||||
//$('#termination_id-' + id + '-line').hide();
|
||||
}
|
||||
|
||||
if(typeof p.attributes === 'object' && "oaid_enabled" in p.attributes && p.attributes.oaid_enabled == 1) {
|
||||
console.log("oaid_enabled");
|
||||
$('#oaid-' + id + '-line').show();
|
||||
console.log($('#oaid-' + id).val());
|
||||
} else {
|
||||
$('#oaid-' + id + '-line').hide();
|
||||
}
|
||||
|
||||
if(typeof p.attributes === 'object' && "needs_number" in p.attributes && p.attributes.needs_number == 1) {
|
||||
console.log("needs_number");
|
||||
$('#voicenumber-' + id + '-line').show();
|
||||
@@ -1893,7 +1921,7 @@
|
||||
} else {
|
||||
$('#products-' + id + '-delete').val(0);
|
||||
//$('#position-' + id + ' .delete-button-container i').removeClass("fa-trash-can-slash").addClass("fa-trash-can");
|
||||
$('#position-' + id + ' .delete-button-container').html('<i class="btn btn-outline-danger fas fa-trash-can pointer" style="font-size: 1.5em" onclick="toggleDeletePos(' + id + ')"></i>');
|
||||
$('#position-' + id + ' .delete-button-container').html('<a href="#" class="btn btn-xl btn-outline-info" title="Inhalte löschen" onclick="toggleDeletePos(' + id + '); return false;"><i class="fas fa-fw fa-trash-can fa-xl pointer"></i></a>');
|
||||
//$('#position-' + id + ' .delete-button-container i').removeClass("btn-outline-white fa-trash-can-slash").addClass("text-danger fa-trash-can");
|
||||
$('#position-' + id).removeClass('text-white deleted');
|
||||
|
||||
@@ -1932,7 +1960,7 @@
|
||||
</div> \
|
||||
<div class="row"> \
|
||||
<div class="col-md-12 delete-button-container"> \
|
||||
<i class="btn btn-outline-info fas fa-trash-can pointer" title="Inhalte löschen" style="font-size: 1.5em" onclick="clearNewPos(' + i +')"></i> \
|
||||
<a href="#" class="btn btn-xl btn-outline-info" title="Inhalte löschen" onclick="clearNewPos(' + i +'); return false;"><i class="fas fa-fw fa-trash-can fa-xl pointer"></i></a> \
|
||||
</div> \
|
||||
</div> \
|
||||
</div> \
|
||||
@@ -1973,7 +2001,14 @@
|
||||
<input type="text" class="form-control" name="products[' + i +'][price_setup]" id="price_setup-' + i +'" value="" placeholder="Preis Setup" /> \
|
||||
</div> \
|
||||
</div> \
|
||||
\
|
||||
\
|
||||
<div class="row mt-1 mb-2 hidden" id="oaid-<?=$i?>-line"> \
|
||||
<div class="col-4"> \
|
||||
<label class="form-label" for="oaid-<?=$i?>">OAID</label> \
|
||||
<input type="text" name="products[<?=$i?>][oaid]" id="oaid-<?=$i?>" class="form-control" value="" placeholder="optional"> \
|
||||
</div> \
|
||||
</div> \
|
||||
\
|
||||
<div class="row mt-1 mb-2 hidden" id="termination_id-' + i +'-line"> \
|
||||
<!-- line to choose termination --> \
|
||||
<div class="col-12"> \
|
||||
|
||||
@@ -299,6 +299,10 @@
|
||||
<a href="<?=self::getUrl("Order", "sendServicePin", ["id" => $order->id])?>" onclick="if(!confirm('Soll der Service-PIN an den Vertragsinhaber gesendet werden?')) return false;"><i class="fas fa-paper-plane" title="Service PIN als PDF per Email an Vertragsinhaber"></i></a>
|
||||
<a href="<?=self::getUrl("Order", "edit", ["id" => $order->id, "filter" => $filter, "noTermProducts" => 1])?>"><i class="far fa-edit" title="Bearbeiten"></i></a>
|
||||
<a href="<?=self::getUrl("Order", "delete", ["id" => $order->id])?>" onclick="if(!confirm('Bestellung wirklich löschen?')) return false;" class="text-danger" title="Löschen"><i class="fas fa-trash"></i></a>
|
||||
|
||||
<?php if(!$order->getSnoppProduct() && ($order->getPreorderProduct() || $order->getOaidProduct())): ?>
|
||||
<a href="<?=self::getUrl("Order", "createSnoppOrder", ["id" => $order->id, "filter" => $filter, "noTermProducts" => 1])?>" class="ml-2"><img src="<?=self::getResourcePath()?>img/snop-logo.png" style="width:24px;height:auto;" /></a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="building-list-tr <?=($order_count % 2 == 0) ? "table-bg-even" : ""?>" id="order-dates-<?=$order->id?>">
|
||||
@@ -543,7 +547,10 @@
|
||||
<td class="text-right"><?=$product->pos?></td>
|
||||
<td class="text-right"><?=$product->formatAmount()?></td>
|
||||
<td>
|
||||
<?=$product->product->name?>
|
||||
<?php if($product->snopp_order_id): ?>
|
||||
<img src="<?=self::getResourcePath()?>/img/snop-logo.png" style="width:24px;height:auto;" title="Bestellung in Snopp">
|
||||
<?php endif; ?>
|
||||
<?=$product->product->name?> <?=$product->oaid ? "<span class='text-pink font-italic'>{$product->oaid}</span>" : ""?>
|
||||
<?php
|
||||
if(
|
||||
(is_array($product->product->attributes) && count($product->product->attributes))
|
||||
|
||||
@@ -888,9 +888,10 @@ $pagination_entity_name = "Vorbestellungen";
|
||||
Filter-Vorlagen <i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25], "rimo_workorder" => 1, "borderpoint" => "all"]])?>">Gelöschte Bestellungen mit Workorder</a></li>
|
||||
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25,930,931,932,933,934], "rimo_workorder" => 1, "rimo_workorder_status" => ["Clarify","Accepted","Plan released","Assigned","Executed","Documented","Review"]]])?>">Gelöschte Bestellungen mit Workorder</a></li>
|
||||
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["preorder_status_flags" => [4], "connection_type" => ["apartment", "apartment-building"], "borderpoint" => "all"]])?>">Wohnung - Verkabelung erledigt</a></li>
|
||||
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25]]])?>">Storniert</a></li>
|
||||
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [1,2,3,4,5,6,7,8,9,10,11,12,13,15,16,17,18,28,34]]])?>">Offene Bestellungen Status 10-299</a></li>
|
||||
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["status" => [21,22,23,24,25,29,30,31,32,33]]])?>">Storniert</a></li>
|
||||
<?php if ($me->isAdmin() || $me->address->id == 209): ?>
|
||||
<li><a class="dropdown-item" href="<?=self::getUrl("Preorder", "Index", ["filter" => ["onlyShowCustomMailSent" => 1]])?>">Bestellungen mit gesendeter Custom-300 Benachrichtigung</a></li>
|
||||
<input type="hidden" name="filter[onlyShowCustomMailSent]" value="<?= (isset($filter['onlyShowCustomMailSent']) && $filter['onlyShowCustomMailSent'] == 1) ? 1 : 0 ?>"/>
|
||||
@@ -1359,7 +1360,7 @@ $pagination_entity_name = "Vorbestellungen";
|
||||
* Globals for map display
|
||||
*/
|
||||
var borderpolies = [];
|
||||
<?php if($me->is("Admin")): ?>
|
||||
<?php if($me->is("Admin") && !isset($campaign)): ?>
|
||||
<?php foreach(ADBNetzgebietModel::search(["borderpoly" => true]) as $bp_netz): ?>
|
||||
borderpolies.push([<?=$bp_netz->borderpoly?>]);
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
<div class="card-body">
|
||||
<div class="card-header bg-info text-white pl-2 pr-2 pt-1 pb-1">Details</div>
|
||||
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<div class="loader-big hidden" ><img src="<?=self::getResourcePath()?>assets/images/loader-big.gif" /></div>
|
||||
@@ -170,6 +172,18 @@
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="col-8">
|
||||
<?php if($preorder->orderproduct): ?>
|
||||
<a href="<?=self::getUrl("Order", "index", ["id" => $preorder->orderproduct->order_id])?>" target="_blank"><i class="far fa-fw fa-angle-double-right"></i> Internetbestellung anzeigen</a>
|
||||
<?php else: ?>
|
||||
<a href="<?=self::getUrl("Preorder","createOrderFromPreorder", ["preorder_id" => $preorder->id])?>" target="_blank" class="btn btn-outline-primary"><i class="far fa-fw fa-angle-double-right"></i> Internetbestellung anlegen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-4">
|
||||
|
||||
@@ -697,6 +711,7 @@
|
||||
<th>ctag</th>
|
||||
<th>Typ</th>
|
||||
<th>External ID</th>
|
||||
<th>External Name</th>
|
||||
<th>External State</th>
|
||||
</tr>
|
||||
<?php if(is_array($preorder->ctags) && count($preorder->ctags)): ?>
|
||||
@@ -706,6 +721,7 @@
|
||||
<td class="text-monospace"><?=$ctag->ctag?></td>
|
||||
<td class="text-monospace"><?=$ctag->service_type?></td>
|
||||
<td class="text-monospace"><?=htmlentities($ctag->ext_id)?></td>
|
||||
<td class="text-monospace"><?=htmlentities($ctag->ext_name)?></td>
|
||||
<td class="text-monospace"><?=htmlentities($ctag->ext_status)?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
<option></option>
|
||||
<option value="ported_out" <?=($number->disabled_reason == "ported_out") ? "selected='selected'" : ""?>>Zu neuem Provider portiert</option>
|
||||
<option value="ported_back" <?=($number->disabled_reason == "ported_back") ? "selected='selected'" : ""?>>Zum Anker zurückportiert</option>
|
||||
<option value="contract_cancelled" <?=($number->disabled_reason == "contract_cancelled") ? "selected='selected'" : ""?>>Vertrag gekündigt</option>
|
||||
<option value="reserved" <?=($number->disabled_reason == "reserved") ? "selected='selected'" : ""?>>Reserviert</option>
|
||||
<option value="legacy" <?=($number->disabled_reason == "legacy") ? "selected='selected'" : ""?>>Legacy</option>
|
||||
<option value="damaged" <?=($number->disabled_reason == "damaged") ? "selected='selected'" : ""?>>Kaputt</option>
|
||||
|
||||
@@ -17,14 +17,16 @@
|
||||
</tr>
|
||||
<?php $i = 0; foreach(range((array_key_exists($block->id, $num_from) ? $num_from[$block->id] : $block->first), $block->last) as $number): ?>
|
||||
<?php $num = VoicenumberModel::getFirst(['voicenumberblock_id' => $block->id, 'number' => $number]) ?>
|
||||
<tr>
|
||||
<tr class="<?=($num->disabled) ? "bg-secondary text-white" : ""?>">
|
||||
<td><?=$number?></td>
|
||||
<td>
|
||||
<?php if($num->active): ?>
|
||||
<span class="text-success"><i class="fas fa-check"></i></span>
|
||||
<small class="text-monospace">(seit <?=($num->id) ? date("d.m.Y H:i:s", $num->activated_date) : ""?>)</small>
|
||||
<span class="text-success"><i class="fas fa-check"></i></span>
|
||||
<small class="text-monospace">(seit <?=($num->id) ? date("d.m.Y H:i:s", $num->activated_date) : ""?>)</small>
|
||||
<?php elseif($num->disabled): ?>
|
||||
<span class="text-success text-danger" title="Nummer wurde wegportiert"><i class="far fa-anchor bg-white" style="padding: 2px;"></i> <i class="far fa-file-export bg-white" style="padding: 2px;"></i></span>
|
||||
<?php else: ?>
|
||||
<span class="text-danger"><i class="fas fa-times"></i></span>
|
||||
<span class="text-danger"><i class="fas fa-times"></i></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><a href="<?=self::getUrl("Contract", "view", ["id" => $num->contract_id])?>"><?=$num->contract_id?></a></td>
|
||||
@@ -40,7 +42,7 @@
|
||||
Lokal
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?=$num->disabled_reason?></td>
|
||||
<td><?=$num->disabled_reason?><?=($num->disabled > 1) ? " (<i>".date("d.m.Y H:i", $num->disabled)."</i>)" : ""?></td>
|
||||
<td><?=($num->id && $num->enable_on_date) ? date("d.m.Y", $num->enable_on_date) : ""?></td>
|
||||
<td>
|
||||
<a href="<?=self::getUrl("Voicenumber", "edit", ["block_id" => $block->id, "number" => $number])?>"><i class="fas fa-edit"></i></a>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
<?php
|
||||
// Prepare OpenReplay user data
|
||||
$openreplayUserId = '';
|
||||
if (class_exists('mfUser') && class_exists('mfLoginController') && mfLoginController::isLoggedIn()) {
|
||||
$user = mfUser::singleton();
|
||||
if ($user && $user->id) {
|
||||
$openreplayUserId = (string) $user->id;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
@@ -32,6 +40,34 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- OpenReplay Session Recording -->
|
||||
<script>
|
||||
var initOpts = {
|
||||
projectKey: "96MdXVcId8Ph3eOirMWj",
|
||||
ingestPoint: "https://openreplay.xinon.at/ingest",
|
||||
defaultInputMode: 2,
|
||||
obscureTextNumbers: false,
|
||||
obscureTextEmails: true,
|
||||
};
|
||||
var startOpts = { userID: <?= json_encode($openreplayUserId) ?> };
|
||||
(function(A,s,a,y,e,r){
|
||||
r=window.OpenReplay=[e,r,y,[s-1, e]];
|
||||
s=document.createElement('script');s.src=A;s.async=!a;
|
||||
document.getElementsByTagName('head')[0].appendChild(s);
|
||||
r.start=function(v){r.push([0])};
|
||||
r.stop=function(v){r.push([1])};
|
||||
r.setUserID=function(id){r.push([2,id])};
|
||||
r.setUserAnonymousID=function(id){r.push([3,id])};
|
||||
r.setMetadata=function(k,v){r.push([4,k,v])};
|
||||
r.event=function(k,p,i){r.push([5,k,p,i])};
|
||||
r.issue=function(k,p){r.push([6,k,p])};
|
||||
r.isActive=function(){return false};
|
||||
r.getSessionToken=function(){};
|
||||
})("//static.openreplay.com/17.0.0/openreplay.js",1,0,initOpts,startOpts);
|
||||
window.OpenReplay.setMetadata('userType', 'internal');
|
||||
window.OpenReplay.setMetadata('app', 'warehouse-stocktake-pwa');
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<?php
|
||||
// Prepare OpenReplay user data for external company users
|
||||
$openreplayUserId = '';
|
||||
$openreplayCompanyId = $JSGlobals['COMPANY_ID'] ?? '';
|
||||
if (class_exists('mfUser') && class_exists('mfLoginController') && mfLoginController::isLoggedIn()) {
|
||||
$user = mfUser::singleton();
|
||||
if ($user && $user->id) {
|
||||
$openreplayUserId = 'company_' . $user->id;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
@@ -34,6 +43,34 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- OpenReplay Session Recording -->
|
||||
<script>
|
||||
var initOpts = {
|
||||
projectKey: "96MdXVcId8Ph3eOirMWj",
|
||||
ingestPoint: "https://openreplay.xinon.at/ingest",
|
||||
defaultInputMode: 2,
|
||||
obscureTextNumbers: false,
|
||||
obscureTextEmails: true,
|
||||
};
|
||||
var startOpts = { userID: <?= json_encode($openreplayUserId) ?> };
|
||||
(function(A,s,a,y,e,r){
|
||||
r=window.OpenReplay=[e,r,y,[s-1, e]];
|
||||
s=document.createElement('script');s.src=A;s.async=!a;
|
||||
document.getElementsByTagName('head')[0].appendChild(s);
|
||||
r.start=function(v){r.push([0])};
|
||||
r.stop=function(v){r.push([1])};
|
||||
r.setUserID=function(id){r.push([2,id])};
|
||||
r.setUserAnonymousID=function(id){r.push([3,id])};
|
||||
r.setMetadata=function(k,v){r.push([4,k,v])};
|
||||
r.event=function(k,p,i){r.push([5,k,p,i])};
|
||||
r.issue=function(k,p){r.push([6,k,p])};
|
||||
r.isActive=function(){return false};
|
||||
r.getSessionToken=function(){};
|
||||
})("//static.openreplay.com/17.0.0/openreplay.js",1,0,initOpts,startOpts);
|
||||
window.OpenReplay.setMetadata('userType', 'external');
|
||||
window.OpenReplay.setMetadata('companyId', <?= json_encode($openreplayCompanyId) ?>);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
/* Prevents the rubber-band scroll effect on iOS and pull-to-refresh on Android */
|
||||
@@ -247,6 +284,14 @@
|
||||
return documentation.journals.filter(j => !j.text.toLowerCase().includes('wurde zugewiesen.'));
|
||||
});
|
||||
|
||||
const isCivilEngineering = computed(() => {
|
||||
return selectedWorkorder.value?.status === 'civil_engineering_required';
|
||||
});
|
||||
|
||||
const showNormalDocsForCivilEng = computed(() => {
|
||||
return isCivilEngineering.value && tenantConfig.value?.tiefbauSeesNormalDocs;
|
||||
});
|
||||
|
||||
|
||||
// --- METHODS ---
|
||||
const applyTheme = () => {
|
||||
@@ -485,6 +530,22 @@
|
||||
}
|
||||
};
|
||||
|
||||
const completeCivilEngineering = async () => {
|
||||
if (!confirm("Möchten Sie den Tiefbau wirklich abschließen?")) return;
|
||||
try {
|
||||
const response = await api.post('/completeCivilEngineering', { workorderId: selectedWorkorder.value.id });
|
||||
if (response.data.success) {
|
||||
await fetchWorkorders();
|
||||
closeDetails();
|
||||
} else {
|
||||
alert(response.data.message);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("Failed to complete civil engineering", e);
|
||||
alert(e.response?.data?.message || 'Fehler beim Abschließen des Tiefbaus.');
|
||||
}
|
||||
};
|
||||
|
||||
const selectFcp = (fcpValue) => {
|
||||
selectedFcp.value = fcpValue;
|
||||
isFcpSelectOpen.value = false;
|
||||
@@ -520,10 +581,11 @@
|
||||
checklist, fullscreenViewer, missingTasksPopover, translatedDocs, filteredJournals, installModal, isStandalone,
|
||||
selectedFcp, isFcpSelectOpen, fcpOptions, selectedFcpText, fcpSearchTerm, filteredFcpOptions, fcpInputRef,
|
||||
isSettingsOpen, theme, showThemePicker,
|
||||
savingData, // <-- ADDED
|
||||
savingData,
|
||||
isCivilEngineering, showNormalDocsForCivilEng,
|
||||
fetchWorkorders, openDetails, closeDetails, getStatusInfo, formatDate, googleMapsLink, startEditInfo, saveAdditionalInfo,
|
||||
handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick, selectFcp, setTheme,
|
||||
saveWorkorderData // <-- ADDED
|
||||
saveWorkorderData, completeCivilEngineering
|
||||
};
|
||||
},
|
||||
template: `
|
||||
@@ -673,7 +735,7 @@
|
||||
{{ savingData ? 'Speichert...' : 'Daten speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<div v-if="!isCivilEngineering || showNormalDocsForCivilEng" class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<h3 class="font-bold text-slate-700 dark:text-secondary mb-3">Checkliste</h3>
|
||||
<div v-if="isDetailsLoading" class="space-y-3 animate-pulse">
|
||||
<div v-for="i in 4" :key="i" class="flex items-center">
|
||||
@@ -693,7 +755,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<div v-if="!isCivilEngineering || showNormalDocsForCivilEng" class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<h3 class="font-bold text-slate-700 dark:text-secondary mb-2">Dokumentation</h3>
|
||||
<label for="file-upload" class="w-full inline-flex items-center justify-center px-4 py-2 border border-dashed border-slate-300 dark:border-slate-700 text-sm font-medium rounded-md text-slate-700 dark:text-slate-200 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
|
||||
@@ -718,7 +780,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<div v-if="!isCivilEngineering || showNormalDocsForCivilEng" class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<h3 class="font-bold text-slate-700 dark:text-secondary mb-4">Journal</h3>
|
||||
<div v-if="isDetailsLoading" class="animate-pulse">
|
||||
<div class="flex items-start">
|
||||
@@ -748,18 +810,23 @@
|
||||
|
||||
<footer class="bg-white dark:bg-slate-900 p-2 border-t border-slate-200 dark:border-slate-800 flex-shrink-0 grid grid-cols-2 gap-2 pt-2 px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))]">
|
||||
<button @click="problemModal.show = true" class="w-full px-4 py-3 bg-red-600 text-white font-bold rounded-md text-center">Problem melden</button>
|
||||
<div class="relative">
|
||||
<button @click="handleCompleteClick" class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-md text-center disabled:bg-slate-300">Abschließen</button>
|
||||
<transition name="fade">
|
||||
<div v-if="missingTasksPopover.show" class="absolute bottom-full right-0 mb-2 w-72 bg-red-700 text-white text-sm rounded-lg shadow-lg p-3">
|
||||
<h4 class="font-bold mb-1">Fehlende Punkte:</h4>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="absolute bottom-[-5px] right-[calc(6rem-8px)] w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-red-700"></div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<template v-if="isCivilEngineering">
|
||||
<button @click="completeCivilEngineering" class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-md text-center">Tiefbau abschließen</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="relative">
|
||||
<button @click="handleCompleteClick" class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-md text-center disabled:bg-slate-300">Abschließen</button>
|
||||
<transition name="fade">
|
||||
<div v-if="missingTasksPopover.show" class="absolute bottom-full right-0 mb-2 w-72 bg-red-700 text-white text-sm rounded-lg shadow-lg p-3">
|
||||
<h4 class="font-bold mb-1">Fehlende Punkte:</h4>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="absolute bottom-[-5px] right-[calc(6rem-8px)] w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-red-700"></div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
</footer>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -18,21 +18,21 @@ $qrCodeBase64 = (new QRCode($options))->render($qrData);
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; }
|
||||
html, body { height: 25mm; width: 50mm; }
|
||||
html, body { height: 25mm; width: 63mm; overflow: hidden; }
|
||||
body { font-family: Arial, sans-serif; color: #000; }
|
||||
table { border-collapse: collapse; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="width: 50mm; height: 25mm;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="width: 63mm; height: 25mm;">
|
||||
<tr>
|
||||
<td style="width: 22mm; height: 25mm; vertical-align: middle; text-align: center;">
|
||||
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm;">
|
||||
<td style="width: 24mm; height: 25mm; position: relative;">
|
||||
<img src="<?php echo $qrCodeBase64; ?>" style="width: 21mm; height: 21mm; position: absolute; top: 50%; left: 50%; margin-top: -10.5mm; margin-left: -10.5mm;">
|
||||
</td>
|
||||
<td style="height: 25mm; vertical-align: middle; padding-left: 1mm; padding-right: 1mm;">
|
||||
<img src="<?php echo BASEDIR; ?>/public/assets/images/xinon-full-transparent.png" style="width: 24mm; height: auto; display: block; margin-bottom: 1mm;">
|
||||
<div style="font-size: 10px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($articleNumber); ?></div>
|
||||
<div style="font-size: 7px; color: #000; margin-top: 1px; word-wrap: break-word; overflow: hidden; text-align: center;"><?php echo htmlspecialchars($articleTitle); ?></div>
|
||||
<img src="<?php echo BASEDIR; ?>/public/assets/images/xinon-full-transparent.png" style="width: 24mm; height: auto; display: block; margin: 0 auto 1mm auto;">
|
||||
<div style="font-size: 11px; font-weight: bold; color: #000; text-align: center;"><?php echo htmlspecialchars($articleNumber); ?></div>
|
||||
<div style="font-size: 9px; color: #000; margin-top: 1px; word-wrap: break-word; overflow: hidden; text-align: center;"><?php echo htmlspecialchars($articleTitle); ?></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
54
Layout/default/WarehouseArticle/LABEL_BULK.php
Normal file
54
Layout/default/WarehouseArticle/LABEL_BULK.php
Normal 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>
|
||||
@@ -61,8 +61,10 @@ if ($includeTax) {
|
||||
}
|
||||
|
||||
$formattedOfferDate = date("d.m.Y", $offerDate);
|
||||
$validityDays = isset($validity) ? (int)$validity : 14;
|
||||
$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $offerDate));
|
||||
$validityDays = isset($validity) ? (int)$validity : 31;
|
||||
// Use versionDate (when this version was created) for validity calculation, fallback to offerDate
|
||||
$validityBaseDate = isset($versionDate) ? $versionDate : $offerDate;
|
||||
$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $validityBaseDate));
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
.invoice-details td {
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invoice-details td:first-child {
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
</style>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $openreplayUserType = 'internal'; include(__DIR__ . "/includes/openreplay.php"); ?>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
79
Layout/default/includes/openreplay.php
Normal file
79
Layout/default/includes/openreplay.php
Normal 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; ?>
|
||||
@@ -141,9 +141,9 @@
|
||||
<?php if($me->is(["Admin","netowner","pipeplanner","pipeplanner"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanDispatcher")?>"><i class="fas fa-building-circle-arrow-right text-info"></i> Verteiler und Schächte</a></li><?php endif; ?>
|
||||
<?php if($me->is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanPipe")?>"><i class="fas fa-pipe text-info pl-1"></i> Rohrverzeichnis</a></li><?php endif; ?>
|
||||
<?php if($me->is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanCable")?>"><i class="fa-solid fa-timeline text-info "></i> Kabelverzeichnis</a></li><?php endif; ?>
|
||||
<!-- add a new line Arbeitsaufträge for RMLCompany, add a new line Arbeitsaufträge-Management for RMLAdmin -->
|
||||
<?php if($me->can("RMLCompany")): ?><li><a href="<?=self::getUrl("WorkorderCompany")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge</a></li><?php endif; ?>
|
||||
<?php if($me->can("RMLAdmin")): ?><li><a href="<?=self::getUrl("WorkorderAdmin")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge-Management</a></li><?php endif; ?>
|
||||
<?php if($me->can("RMLAdmin")): ?><li><a href="<?=self::getUrl("WorkorderDashboard")?>"><i class="far fa-fw fa-chart-line text-info"></i> Arbeitsaufträge-Dashboard</a></li><?php endif; ?>
|
||||
<?php if($me->can("WorkorderMph")): ?><li><a href="<?=self::getUrl("WorkorderMphCompany")?>"><i class="far fa-fw fa-buildings text-info"></i> MPH Arbeitsaufträge</a></li><?php endif; ?>
|
||||
<?php if($me->can("WorkorderMphAdmin")): ?><li><a href="<?=self::getUrl("WorkorderMphAdmin")?>"><i class="far fa-fw fa-buildings text-info"></i> MPH Arbeitsaufträge Verwaltung</a></li><?php endif; ?>
|
||||
</ul>
|
||||
@@ -185,6 +185,7 @@
|
||||
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseShippingNote")?>"><i class="far fa-fw fa-shipping-fast text-info"></i> Lieferscheine</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseProject")?>"><i class="fas fa-fw fa-project-diagram text-info"></i> Projekte</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseStocktake")?>"><i class="far fa-fw fa-clipboard-check text-info"></i> Inventur</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseMovement")?>"><i class="far fa-fw fa-arrow-right-arrow-left text-info"></i> Lagerbewegung</a></li><?php endif; ?>
|
||||
|
||||
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseDistributor")?>"><i class="far fa-fw fa-cogs text-info"></i> Administration</a></li><?php endif; ?>
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
|
||||
<?php endif; ?>
|
||||
</style>
|
||||
|
||||
<?php $openreplayUserType = 'internal'; include(__DIR__ . "/includes/openreplay.php"); ?>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ class ADBHausnummerModel {
|
||||
|
||||
$sql .= " WHERE $where";
|
||||
|
||||
if (!empty($filter['home_oaid_rimo_id'])) {
|
||||
if (!empty($filter['home_oaid_rimo_id']) && !$join_tables) {
|
||||
$sql .= " GROUP BY Hausnummer.id";
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ class ADBNetzgebiet extends mfBaseModelV2 {
|
||||
public ?string $source_id = null;
|
||||
public ?string $borderpoly = null;
|
||||
public ?string $freigabe = '["interest", "provision", "order", "reorder"]';
|
||||
public int $unit_count = 0;
|
||||
public int $unit_count_sd = 0;
|
||||
public int $unit_count_md = 0;
|
||||
public int $unit_create_oaid = 0;
|
||||
public ?string $options = '{"create_address_parts": 0, "update_freigabe": 1, "update_address": 1, "hausnummer_dont_overwrite_netzgebiet": 0, "create_preorder": 0, "preorder_only_oaid": 0, "wo_ignore_status": 0, "delete_units": 0, "mph_min_homes_tool_automatic_count": 3, "unit_create_oaid": 0}';
|
||||
public int $create;
|
||||
public int $edit;
|
||||
|
||||
@@ -24,6 +24,9 @@ class ADBNetzgebietController extends mfBaseController {
|
||||
"GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"),
|
||||
"SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"),
|
||||
"HISTORY_URL" => $this::getUrl("ADBNetzgebiet/getHistory"),
|
||||
"START_RIMO_IMPORT_URL" => $this::getUrl("ADBNetzgebiet/startRimoImport"),
|
||||
"GET_RIMO_IMPORT_STATUS_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportStatus"),
|
||||
"GET_RIMO_IMPORT_LOG_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportLog"),
|
||||
"NETWORK_URL" => $this::getUrl("Network/Index"),
|
||||
"NETWORK_CREATE_URL" => $this::getUrl("Network/add"),
|
||||
"CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"),
|
||||
@@ -130,6 +133,193 @@ class ADBNetzgebietController extends mfBaseController {
|
||||
self::returnJson(['success' => true, 'data' => $history]);
|
||||
}
|
||||
|
||||
protected function startRimoImportAction(): void {
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (empty($id)) {
|
||||
self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]);
|
||||
return;
|
||||
}
|
||||
|
||||
$netzgebiet = ADBNetzgebiet::get($id);
|
||||
if (!$netzgebiet || !$netzgebiet->id) {
|
||||
self::returnJson(['success' => false, 'message' => "Netzgebiet not found."]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strpos($netzgebiet->source, 'rimo-') !== 0) {
|
||||
self::returnJson(['success' => false, 'message' => "This action is only for RIMO-source Netzgebiete."]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($netzgebiet->source_id)) {
|
||||
self::returnJson(['success' => false, 'message' => "Netzgebiet has no Source ID."]);
|
||||
return;
|
||||
}
|
||||
|
||||
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
|
||||
$importTempDir = TEMP_DIR . "/ADBNetzgebietRimoImport/";
|
||||
$logDir = $importTempDir . $safeSourceId;
|
||||
$logFile = $logDir . "/import.log";
|
||||
$lockFile = $logDir . "/import.lock";
|
||||
|
||||
if (is_dir($importTempDir)) {
|
||||
foreach (glob($importTempDir . "*") as $dir) {
|
||||
if (is_dir($dir) && (time() - filemtime($dir)) > 86400) {
|
||||
// simple cleanup
|
||||
if (file_exists($dir . "/import.log")) @unlink($dir . "/import.log");
|
||||
if (file_exists($dir . "/import.lock")) @unlink($dir . "/import.lock");
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
if ((time() - filemtime($lockFile)) > 3600) { // stale lock for 1h
|
||||
@unlink($lockFile);
|
||||
} else {
|
||||
self::returnJson(['success' => false, 'message' => "Import is already running.", 'status' => 'running']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
|
||||
$remaining = 900 - (time() - filemtime($logFile));
|
||||
self::returnJson(['success' => false, 'message' => "Please wait before starting another import.", 'status' => 'cooldown', 'remaining' => $remaining]);
|
||||
return;
|
||||
}
|
||||
|
||||
touch($lockFile);
|
||||
|
||||
$projectRoot = dirname(dirname(__DIR__));
|
||||
$scriptRelativePath = 'scripts/adb-rimo-import/rimo-import.php';
|
||||
$scriptFullPath = $projectRoot . '/' . $scriptRelativePath;
|
||||
|
||||
if (!file_exists($scriptFullPath)) {
|
||||
self::returnJson(['success' => false, 'message' => "Import script not found."]);
|
||||
return;
|
||||
}
|
||||
|
||||
$php_executable = "php";
|
||||
$command = "$php_executable $scriptRelativePath " . escapeshellarg($netzgebiet->source_id);
|
||||
|
||||
$bgCommand = 'cd ' . escapeshellarg($projectRoot) . ' && ' . $command . ' > ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
|
||||
$pid = shell_exec($bgCommand);
|
||||
|
||||
if(empty($pid) || !is_numeric(trim($pid))) {
|
||||
self::returnJson(['success' => false, 'message' => "Failed to start background process."]);
|
||||
return;
|
||||
}
|
||||
|
||||
file_put_contents($lockFile, trim($pid));
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'RIMO import started.']);
|
||||
}
|
||||
|
||||
protected function getRimoImportStatusAction(): void {
|
||||
$ids = $this->postData['ids'] ?? [];
|
||||
if (empty($ids)) {
|
||||
self::returnJson(['success' => true, 'data' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = [];
|
||||
foreach ($ids as $id) {
|
||||
$netzgebiet = ADBNetzgebiet::get($id);
|
||||
if (!$netzgebiet || !$netzgebiet->id || strpos($netzgebiet->source, 'rimo-') !== 0 || empty($netzgebiet->source_id)) {
|
||||
$statuses[$id] = ['status' => 'not_applicable'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
|
||||
$logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId;
|
||||
$logFile = $logDir . "/import.log";
|
||||
$lockFile = $logDir . "/import.lock";
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
$pid = trim(file_get_contents($lockFile));
|
||||
// Check if process is still running. posix_getpgid returns false if process does not exist.
|
||||
if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) {
|
||||
$statuses[$id] = ['status' => 'running'];
|
||||
} else {
|
||||
// Stale lock file, process is gone.
|
||||
@unlink($lockFile);
|
||||
// Check for cooldown based on log file from the finished process
|
||||
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
|
||||
$statuses[$id] = [
|
||||
'status' => 'cooldown',
|
||||
'remaining' => 900 - (time() - filemtime($logFile))
|
||||
];
|
||||
} else {
|
||||
$statuses[$id] = ['status' => 'idle'];
|
||||
}
|
||||
}
|
||||
} elseif (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
|
||||
$statuses[$id] = [
|
||||
'status' => 'cooldown',
|
||||
'remaining' => 900 - (time() - filemtime($logFile))
|
||||
];
|
||||
} else {
|
||||
$statuses[$id] = ['status' => 'idle'];
|
||||
}
|
||||
}
|
||||
self::returnJson(['success' => true, 'data' => $statuses]);
|
||||
}
|
||||
|
||||
protected function getRimoImportLogAction(): void {
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (empty($id)) {
|
||||
self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]);
|
||||
return;
|
||||
}
|
||||
|
||||
$netzgebiet = ADBNetzgebiet::get($id);
|
||||
if (!$netzgebiet || !$netzgebiet->id || empty($netzgebiet->source_id)) {
|
||||
self::returnJson(['success' => false, 'message' => "Netzgebiet not found or not applicable."]);
|
||||
return;
|
||||
}
|
||||
|
||||
$safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
|
||||
$logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId;
|
||||
$logFile = $logDir . "/import.log";
|
||||
$lockFile = $logDir . "/import.lock";
|
||||
|
||||
$logContent = "";
|
||||
if (file_exists($logFile)) {
|
||||
$logContent = file_get_contents($logFile);
|
||||
}
|
||||
|
||||
$status = 'idle';
|
||||
if (file_exists($lockFile)) {
|
||||
$pid = trim(file_get_contents($lockFile));
|
||||
if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) {
|
||||
$status = 'running';
|
||||
} else {
|
||||
@unlink($lockFile); // Stale lock, process is gone
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== 'running') {
|
||||
if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
|
||||
$status = 'cooldown';
|
||||
} else {
|
||||
$status = file_exists($logFile) ? 'finished' : 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'log' => $logContent,
|
||||
'status' => $status,
|
||||
'timestamp' => file_exists($logFile) ? filemtime($logFile) : null
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
// TODO: Implement RIMO API check
|
||||
protected function checkRimoSourceIdAction(): void {
|
||||
self::returnJson(['success' => false, 'message' => "RIMO API check not available."]);
|
||||
|
||||
@@ -333,6 +333,8 @@ class AddressController extends mfBaseController {
|
||||
$data['fibu_supplier_due'] = ($r->fibu_supplier_due) ? trim($r->fibu_supplier_due) : null;
|
||||
$data['fibu_supplier_skonto'] = ($r->fibu_supplier_skonto) ? trim($r->fibu_supplier_skonto) : null;
|
||||
$data['fibu_supplier_skonto_rate'] = ($r->fibu_supplier_skonto_rate) ? trim($r->fibu_supplier_skonto_rate) : null;
|
||||
|
||||
$data["manual_invoice_sepa_limit"] = ($r->manual_invoice_sepa_limit) ? str_replace(",", ".", trim($r->manual_invoice_sepa_limit)) : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class AddressdbApicontroller extends mfBaseApicontroller {
|
||||
protected function getClusters() {
|
||||
$cluster_search = [];
|
||||
if(count($this->filter_salescluster_ids)) {
|
||||
$cluster_search['netzgebiet_id'] = $this->filter_salescluster_ids;
|
||||
$cluster_search['id'] = $this->filter_salescluster_ids;
|
||||
}
|
||||
$clusters = [];
|
||||
foreach(ADBNetzgebietModel::search($cluster_search) as $cluster) {
|
||||
|
||||
176
application/Api/v1/InvestigatorApicontroller.php
Normal file
176
application/Api/v1/InvestigatorApicontroller.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -85,13 +85,13 @@ class SnoppCitycom extends Modules\ApiControllerModule
|
||||
$bb_down = $this->post["bb_down"];
|
||||
$execution_date = false;
|
||||
|
||||
if($this->post["execution_date"]) {
|
||||
/*if($this->post["execution_date"]) {
|
||||
try {
|
||||
$execution_date = new \DateTime($this->post["execution_date"]);
|
||||
} catch(\Exception $e) {
|
||||
return \mfResponse::BadRequest(["message" => "Invalid Timestamp format"]);
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
if(!is_numeric($bb_down) || !$bb_down || !is_numeric($bb_up) || !$bb_up || !$bb_down > 10000 || $bb_up > 10000) {
|
||||
@@ -118,6 +118,10 @@ class SnoppCitycom extends Modules\ApiControllerModule
|
||||
|
||||
// if all services are ordered and active, finish order and return active
|
||||
$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
|
||||
if(!$ctag) {
|
||||
return \mfResponse::NotFound(["message" => "Home not found"]);
|
||||
}
|
||||
|
||||
if($ctag->ext_id && $ctag->ext_status == "finished") {
|
||||
if($preorder->status->code < 500) {
|
||||
$preorder->setNewStatusCode(500);
|
||||
@@ -128,11 +132,11 @@ class SnoppCitycom extends Modules\ApiControllerModule
|
||||
|
||||
|
||||
// Home must have Status 300, else return deferred
|
||||
if($wohneinheit->status->code < 300) {
|
||||
/*if($wohneinheit->status->code < 300) {
|
||||
return \mfResponse::Ok(["message" => "ONT not yet installed. Deferred", "activation_status" => "deferred"]);
|
||||
}
|
||||
|
||||
}*/
|
||||
|
||||
/*
|
||||
$cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref);
|
||||
$data["up"] = $bb_up;
|
||||
$data["down"] = $bb_down;
|
||||
@@ -159,10 +163,30 @@ class SnoppCitycom extends Modules\ApiControllerModule
|
||||
// order Service at Citycom and set Preorder to 500 Finished
|
||||
if(!$cc_api->orderServices($preorder, $cc_home_id, $data)) {
|
||||
return \mfResponse::InternalServerError(["message" => "Error activating service"]);
|
||||
}*/
|
||||
|
||||
// update product at citycom
|
||||
//$cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref);
|
||||
$data["up"] = $bb_up;
|
||||
$data["down"] = $bb_down;
|
||||
$data["product_name"] = false;
|
||||
|
||||
if($preorder->campaign->fulfillment == "citycom_oan") {
|
||||
$data["product_name"] = "Estmk Greenstream OAN $bb_down/$bb_up";
|
||||
}
|
||||
|
||||
// live check if service is active, if not return deferred
|
||||
$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
|
||||
$cc_api_client = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS);
|
||||
$cc_api = new \Citycom_OanApiHelper($cc_api_client);
|
||||
|
||||
// try to update product with bandwidth provided by snopp.
|
||||
// updateService() only updates if values are changed.
|
||||
if(!$cc_api->updateService($ctag->ext_id, $data)) {
|
||||
$this->log->error(__METHOD__.": Error updating service {$ctag->ext_id} for preorder {$preorder->id}");
|
||||
//return \mfResponse::InternalServerError(["message" => "Error activating service"]);
|
||||
}
|
||||
|
||||
// check if service is active, if not return deferred
|
||||
//$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
|
||||
if($ctag->ext_id && $ctag->ext_status == "finished") {
|
||||
if($preorder->status->code < 500) {
|
||||
$preorder->setNewStatusCode(500);
|
||||
|
||||
@@ -43,22 +43,8 @@ class Cif extends Modules\ApiControllerModule {
|
||||
return \mfResponse::NotFound(["message" => "Preorder not found"]);
|
||||
}
|
||||
|
||||
// set status to 200
|
||||
if($preorder->status->code < 200) {
|
||||
$new_status = \PreorderstatusModel::getFirst(["code" => 200]);
|
||||
if(!$new_status) {
|
||||
return \mfResponse::InternalServerError();
|
||||
}
|
||||
$preorder->status_id = $new_status->id;
|
||||
$preorder->save();
|
||||
}
|
||||
|
||||
$sflag = \PreorderStatusflagModel::getFirst(["code" => 200]);
|
||||
$sflag->preorder_id = $preorder->id;
|
||||
if(!$sflag->value->value) {
|
||||
$sflag->value->value = 1;
|
||||
$sflag->value->save();
|
||||
}
|
||||
// set status flag 200
|
||||
$preorder->setStatusFlag(200, 1);
|
||||
|
||||
return \mfResponse::Ok(["message" => "Status successfully updated."]);
|
||||
|
||||
@@ -134,22 +120,8 @@ class Cif extends Modules\ApiControllerModule {
|
||||
return \mfResponse::NotFound(["message" => "Invalid ciftoken"]);
|
||||
}
|
||||
|
||||
// set status to 200
|
||||
if($preorder->status->code < 200) {
|
||||
$new_status = \PreorderstatusModel::getFirst(["code" => 200]);
|
||||
if(!$new_status) {
|
||||
return \mfResponse::InternalServerError();
|
||||
}
|
||||
$preorder->status_id = $new_status->id;
|
||||
$preorder->save();
|
||||
}
|
||||
|
||||
$sflag = \PreorderStatusflagModel::getFirst(["code" => 200]);
|
||||
$sflag->preorder_id = $preorder->id;
|
||||
if(!$sflag->value->value) {
|
||||
$sflag->value->value = 1;
|
||||
$sflag->value->save();
|
||||
}
|
||||
// set status flag 200
|
||||
$preorder->setStatusFlag(200, 1);
|
||||
|
||||
return \mfResponse::Ok(["message" => "Status successfully updated."]);
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ class AssetManagementController extends TTCrud
|
||||
|
||||
// Simplified columns for better layout, details are in the 'assetDetails' slot
|
||||
protected array $columns = [
|
||||
['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search']],
|
||||
['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]],
|
||||
['key' => 'category', 'text' => 'Kategorie', 'modal' => false, 'table' => ['filter' => 'select', 'filterOptions' => []]],
|
||||
['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
||||
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']],
|
||||
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
|
||||
@@ -22,6 +23,15 @@ class AssetManagementController extends TTCrud
|
||||
$this->additionalJSVariables['ASSET_ADMIN'] = '0';
|
||||
$this->columns = array_filter($this->columns, fn($col) => $col['key'] !== 'actions');
|
||||
}
|
||||
|
||||
$categories = AssetManagementModel::getDistinctCategories();
|
||||
$categoryOptions = array_map(fn($cat) => ['value' => $cat, 'text' => $cat], $categories);
|
||||
foreach ($this->columns as &$column) {
|
||||
if ($column['key'] === 'category') {
|
||||
$column['table']['filterOptions'] = $categoryOptions;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +52,12 @@ class AssetManagementController extends TTCrud
|
||||
$json = json_decode(file_get_contents('php://input'), true);
|
||||
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
|
||||
$filters = $json['filters'] ?? [];
|
||||
$order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC'];
|
||||
$order = $json['order'] ?? ['key' => 'name', 'order' => 'ASC'];
|
||||
|
||||
// Map virtual column 'assetDetails' to actual 'name' column for sorting
|
||||
if (isset($order['key']) && $order['key'] === 'assetDetails') {
|
||||
$order['key'] = 'name';
|
||||
}
|
||||
|
||||
// Fetch paginated assets
|
||||
$assets = AssetManagementModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order);
|
||||
@@ -277,6 +292,18 @@ class AssetManagementController extends TTCrud
|
||||
self::returnJson(['success' => true, 'message' => 'Reservierung gelöscht.']);
|
||||
}
|
||||
|
||||
protected function getCategoriesAction()
|
||||
{
|
||||
$searchTerm = $this->request->q ?? '';
|
||||
$categories = AssetManagementModel::getDistinctCategories($searchTerm);
|
||||
|
||||
$result = array_map(function($category) {
|
||||
return ['value' => $category, 'text' => $category];
|
||||
}, $categories);
|
||||
|
||||
self::returnJson($result);
|
||||
}
|
||||
|
||||
protected function printLabelAction() {
|
||||
if (!$this->user->can('AssetAdmin')) {
|
||||
self::sendError("Permission denied", 403);
|
||||
|
||||
@@ -4,6 +4,7 @@ class AssetManagementModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public string $name;
|
||||
public ?string $description;
|
||||
public ?string $category;
|
||||
public ?int $mainImageId; // Renamed from imageId
|
||||
public ?string $imageIds; // Changed to JSON (will be a string in PHP)
|
||||
public string $assetNumber;
|
||||
@@ -35,8 +36,7 @@ class AssetManagementModel extends TTCrudBaseModel {
|
||||
foreach ($searchTerms as $term) {
|
||||
if (empty(trim($term))) continue;
|
||||
$escapedTerm = $db->real_escape_string($term);
|
||||
// For each term, search in name, assetNumber, and description.
|
||||
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')";
|
||||
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%' OR `category` LIKE '%$escapedTerm%')";
|
||||
}
|
||||
|
||||
if (!empty($searchConditions)) {
|
||||
@@ -99,8 +99,8 @@ class AssetManagementModel extends TTCrudBaseModel {
|
||||
foreach ($searchTerms as $term) {
|
||||
if (empty(trim($term))) continue;
|
||||
$escapedTerm = $db->real_escape_string($term);
|
||||
// For each term, search in name, assetNumber, and description.
|
||||
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')";
|
||||
// For each term, search in name, assetNumber, description, and category.
|
||||
$searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%' OR `category` LIKE '%$escapedTerm%')";
|
||||
}
|
||||
|
||||
if (!empty($searchConditions)) {
|
||||
@@ -128,4 +128,26 @@ class AssetManagementModel extends TTCrudBaseModel {
|
||||
|
||||
return $result->fetch_assoc()['count'];
|
||||
}
|
||||
|
||||
public static function getDistinctCategories(string $searchTerm = ''): array {
|
||||
$db = self::getDB();
|
||||
$table = self::getFullyQualifiedTable();
|
||||
|
||||
$sql = "SELECT DISTINCT `category` FROM $table WHERE `category` IS NOT NULL AND `category` != ''";
|
||||
|
||||
if (!empty($searchTerm)) {
|
||||
$escapedTerm = $db->real_escape_string($searchTerm);
|
||||
$sql .= " AND `category` LIKE '%$escapedTerm%'";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY `category` ASC LIMIT 20";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$categories = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$categories[] = $row['category'];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
}
|
||||
@@ -549,23 +549,23 @@ class CpeprovisioningController extends mfBaseController {
|
||||
"ORDER_URL" => $this->getUrl("Order"),
|
||||
"NETWORKS" => NetworkModel::getAll(),
|
||||
"ROUTER_OPTIONS" => [
|
||||
['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'],
|
||||
['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'],
|
||||
['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'],
|
||||
['value' => 'FritzBox 6670 Cable', 'text' => 'FritzBox 6670 Cable (Inet, Phone, IPTV)'],
|
||||
// General Options
|
||||
['value' => 'eigener Router', 'text' => 'Eigener Router'],
|
||||
['value' => 'anderes CPE', 'text' => 'Anderes CPE'],
|
||||
// PPPoE/DHCP Routers
|
||||
['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'],
|
||||
['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'],
|
||||
['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'],
|
||||
['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'],
|
||||
['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'],
|
||||
['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'],
|
||||
['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'],
|
||||
// Static Routers
|
||||
['value' => 'Mikrotik HAP AC', 'text' => 'Mikrotik HAP AC (Inet, IPTV)'],
|
||||
['value' => 'Mikrotik HEX S', 'text' => 'Mikrotik HEX S (Inet, IPTV)'],
|
||||
['value' => 'Mikrotik RB3011', 'text' => 'Mikrotik RB3011 (Inet, IPTV)'],
|
||||
// CMTS Routers
|
||||
// Legacy
|
||||
['value' => 'FritzBox 6490 Cable', 'text' => 'FritzBox 6490 Cable (Inet, Phone, IPTV)'],
|
||||
['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'],
|
||||
['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'],
|
||||
['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'],
|
||||
['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'],
|
||||
],
|
||||
"ROUTER_SHIPPING_DATA" => [
|
||||
"TP-Link Archer C80" => ["weight" => 1, "length" => 35, "width" => 24, "height" => 8],
|
||||
|
||||
@@ -32,7 +32,7 @@ class Emailnotification {
|
||||
if($object_data !== false) $this->object_data = $object_data;
|
||||
}
|
||||
|
||||
public function addAttachment($filepath = null, $content = null, $name = false, $c_type = "application/octet-stream", $disposition = "attachment", $encoding = "base64" , $charset = "utf-8") {
|
||||
public function addAttachment($filepath = null, $content = null, $name = false, $c_type = "application/octet-stream", $disposition = "attachment", $encoding = "base64", $charset = "utf-8") {
|
||||
$attachment = [
|
||||
"file" => $filepath,
|
||||
"content" => $content,
|
||||
|
||||
@@ -681,6 +681,8 @@ class InvoiceController extends mfBaseController {
|
||||
}
|
||||
|
||||
// save Invoiceposition
|
||||
// first round price
|
||||
$position->price_gross = round($position->price_gross, 4);
|
||||
if (!$position->save()) {
|
||||
$invoice->rollbackTransaction();
|
||||
die("Error saving Invoiceposition");
|
||||
@@ -703,7 +705,7 @@ class InvoiceController extends mfBaseController {
|
||||
}
|
||||
|
||||
$invoice->total = $total_net;
|
||||
$invoice->total_gross = $total_gross;
|
||||
$invoice->total_gross = round($total_gross, 4);
|
||||
//$invoice->total_vat = $total_vat;
|
||||
|
||||
if (!$invoice->save()) {
|
||||
@@ -778,6 +780,207 @@ class InvoiceController extends mfBaseController {
|
||||
|
||||
}
|
||||
|
||||
protected function manualExportBmd() {
|
||||
if(!$this->me->can("Billing")) {
|
||||
$this->redirect("Dashboard");
|
||||
}
|
||||
//var_dump($this->request->get());
|
||||
|
||||
$csv_header = "\u{FEFF}satzart;konto;gkonto;belegnr;belegdatum;zziel;skontopz;skontotage;buchsymbol;buchcode;prozent;steuercode;betrag;steuer;text;";
|
||||
$csv_header .= "bank-iban-nr;bank-swiftcode;bank-mandatsid;bank-mandatsdatum;bank-mandatskz;bank-letztereinzug;zvsperre;bankeinzug;";
|
||||
$csv_header .= "kost;kobetrag";
|
||||
|
||||
$csv_out = "";
|
||||
|
||||
//var_dump($filter);exit;
|
||||
$filter = [
|
||||
"lock" => 1,
|
||||
"exported" => 0,
|
||||
];
|
||||
|
||||
if($this->request->manual_invoice_date_from) {
|
||||
$date_from = Layout::dateToInt($this->request->manual_invoice_date_from);
|
||||
if($date_from) {
|
||||
$filter["invoice_date"] = ["from" => $date_from];
|
||||
}
|
||||
}
|
||||
if($this->request->manual_invoice_date_to) {
|
||||
$date_to = Layout::dateToInt($this->request->manual_invoice_date_to);
|
||||
if($date_to) {
|
||||
$filter["invoice_date"] = ["to" => $date_to];
|
||||
}
|
||||
}
|
||||
|
||||
//var_dump($filter);exit;
|
||||
|
||||
if(!ManualInvoiceModel::count($filter)) {
|
||||
$this->layout()->setFlash("Keine Rechnungen zum Exportieren gefunden.");
|
||||
$this->redirect("Invoice");
|
||||
}
|
||||
foreach(ManualInvoiceModel::getAll($filter) as $invoice) {
|
||||
if($invoice->exported) {
|
||||
die("wtf");
|
||||
}
|
||||
|
||||
$billingaddress = new Address($invoice->billingaddress_id);
|
||||
if(!$billingaddress->id) {
|
||||
die("Billingaddresse für Rechnung {$invoice->invoice_number} not found");
|
||||
}
|
||||
|
||||
$kostentraeger = [];
|
||||
//var_dump($invoice->getProperty("positions"));
|
||||
//$vat_total_gross = 0;
|
||||
foreach($invoice->getProperty("positions") as $position) {
|
||||
if(!array_key_exists($position->position_group, $kostentraeger)) {
|
||||
$kostentraeger[$position->position_group] = 0;
|
||||
}
|
||||
//$kostentraeger[$position->position_group] += $position->price_gross;
|
||||
//$vat_total_gross += $position->price_gross - $position->price_total;
|
||||
|
||||
$price = $position->price_total;
|
||||
/*if($position->discount) {
|
||||
$price -= ($price / 100) * $position->discount;
|
||||
}*/
|
||||
if($invoice->gesamtrabatt) {
|
||||
$price -= ($price / 100) * $invoice->gesamtrabatt;
|
||||
}
|
||||
|
||||
$kostentraeger[$position->position_group] += $price;
|
||||
}
|
||||
|
||||
$total_gross = $invoice->total_gross;
|
||||
/*if($invoice->gesamtrabatt) {
|
||||
$total_gross -= round(($total_gross / 100) * $invoice->gesamtrabatt, 4);
|
||||
}*/
|
||||
|
||||
$total = $invoice->total;
|
||||
/*if($invoice->gesamtrabatt) {
|
||||
$total -= round(($total / 100) * $invoice->gesamtrabatt, 4);
|
||||
}*/
|
||||
|
||||
if($invoice->total_gross) {
|
||||
$vatrate = 20;
|
||||
}
|
||||
if($invoice->total == $invoice->total_gross && $invoice->fibu_cost_area != "domestic") {
|
||||
$vatrate = "0";
|
||||
} else {
|
||||
$vatrate = "20";
|
||||
}
|
||||
$vat = $total_gross - $total;
|
||||
$vat *= -1;
|
||||
//$vat_total_gross *= -1;
|
||||
|
||||
if($invoice->total < 0) {
|
||||
$buchsymbol = "GU";
|
||||
} else {
|
||||
$buchsymbol = "AR";
|
||||
}
|
||||
|
||||
$fibu_account = $invoice->fibu_account_number;
|
||||
|
||||
$buchungstext = "[".$invoice->customer_number."]";
|
||||
if($invoice->company) {
|
||||
$buchungstext .= " ".$invoice->company;
|
||||
} elseif($invoice->firstname || $invoice->lastname) {
|
||||
$buchungstext .= " ".$invoice->firstname." ".$invoice->lastname;
|
||||
}
|
||||
|
||||
$buchungstext = str_replace(["\n","\r", ";"], "", $buchungstext);
|
||||
$buchcode = "1";
|
||||
$is_sepa = ($invoice->billing_type == "sepa");
|
||||
|
||||
$iban = "";
|
||||
$bic = "";
|
||||
$sepa_id = "";
|
||||
$sepa_date = false;
|
||||
$last_invoice_date = false;
|
||||
$mandatskz = "";
|
||||
|
||||
if($is_sepa) {
|
||||
$iban = $invoice->bank_account_iban;
|
||||
$bic = $invoice->bank_account_bic;
|
||||
$sepa_id = "R".$fibu_account;
|
||||
if($billingaddress->sepa_date) {
|
||||
$sepa_date = new DateTime("@".$billingaddress->sepa_date);
|
||||
$sepa_date->setTimezone(new DateTimeZone("Europe/Vienna"));
|
||||
|
||||
if($billingaddress->last_invoice_date) {
|
||||
$sepa_last_date = new DateTime("@".$billingaddress->last_invoice_date);
|
||||
|
||||
$data["sepa_last_date"] = $sepa_last_date->format("Y-m-d");
|
||||
|
||||
$last_invoice_date = new DateTime("@".$billingaddress->last_invoice_date);
|
||||
$last_invoice_date->setTimezone(new DateTimeZone("Europe/Vienna"));
|
||||
if($last_invoice_date->format("Y-m-d") < $sepa_date->format("Y-m-d")) {
|
||||
$last_invoice_date = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$mandatskz = ($last_invoice_date ? "1" : "0");
|
||||
|
||||
$three_years_ago = new DateTime("now");
|
||||
$three_years_ago->modify("-3 years");
|
||||
|
||||
if($mandatskz == "0") {
|
||||
while($sepa_date->format("Y-m-d") < $three_years_ago->format("Y-m-d")) {
|
||||
$sepa_date->modify("+1 year");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$kost = $invoice->fibu_cost_account;
|
||||
|
||||
$csv_out .= "0;";
|
||||
$csv_out .= $fibu_account.";";
|
||||
$csv_out .= $invoice->fibu_cost_account.";";
|
||||
$csv_out .= $invoice->invoice_number.";";
|
||||
$csv_out .= date("d.m.Y", $invoice->invoice_date).";";
|
||||
$csv_out .= ($invoice->fibu_payment_due === null) ? ";" : $invoice->fibu_payment_due.";";
|
||||
$csv_out .= ($invoice->fibu_payment_skonto) ? $invoice->fibu_payment_skonto.";" : ";";
|
||||
$csv_out .= ($invoice->fibu_payment_skonto_rate) ? $invoice->fibu_payment_skonto_rate.";" : ";";
|
||||
$csv_out .= $buchsymbol.";";
|
||||
$csv_out .= $buchcode.";";
|
||||
$csv_out .= $vatrate.";";
|
||||
$csv_out .= $invoice->fibu_taxcode.";";
|
||||
$csv_out .= number_format($total_gross, 2, ",", "").";";
|
||||
$csv_out .= number_format($vat, 2, ",", "").";";
|
||||
$csv_out .= $buchungstext.";";
|
||||
|
||||
$csv_out .= $iban.";";
|
||||
$csv_out .= $bic.";";
|
||||
$csv_out .= $sepa_id.";";
|
||||
$csv_out .= ($sepa_date ? $sepa_date->format("d.m.Y") : "").";";
|
||||
$csv_out .= $mandatskz.";";
|
||||
$csv_out .= ($last_invoice_date ? $last_invoice_date->format("d.m.Y") : "").";";
|
||||
$csv_out .= ($is_sepa ? 0 : 10).";";
|
||||
$csv_out .= ($is_sepa ? 1 : 0);
|
||||
|
||||
|
||||
if(count($kostentraeger) >= 2) {
|
||||
foreach($kostentraeger as $kostelle => $kobetrag) {
|
||||
$kobetrag_text = number_format($kobetrag, 2, ",", "");
|
||||
$csv_out .= "\n1;;;;;;;;;;;;;;;;;;;;;;;$kostelle;$kobetrag_text;";
|
||||
}
|
||||
}
|
||||
|
||||
///var_dump($kostentraeger);
|
||||
$csv_out .= "\n";
|
||||
|
||||
|
||||
}
|
||||
//exit;
|
||||
/*$this->layout()->setFlash("Export erfolgreich abgeschlossen", "success");
|
||||
$this->redirect("Invoice");*/
|
||||
|
||||
header("Content-type: text/csv; charset=utf-8");
|
||||
header('Content-disposition: attachment; filename="tt-mrech-export-bmd-'.date('Y-m-d_H-i-s').'.csv"');
|
||||
|
||||
echo $csv_header."\n".$csv_out;
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function exportBmdAction() {
|
||||
if(!$this->me->can("Billing")) {
|
||||
$this->redirect("Dashboard");
|
||||
|
||||
@@ -105,10 +105,12 @@ class ManualInvoiceController extends TTCrud
|
||||
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
|
||||
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
|
||||
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
|
||||
"{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->leistungszeitraum) . "</td></tr>" : "",
|
||||
"{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->externe_referenz) . "</td></tr>" : "",
|
||||
"{{ leistungszeitraumHtml }}" => ($invoice->performance_period ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->performance_period) . "</td></tr>" : "",
|
||||
"{{ externeReferenzHtml }}" => ($invoice->external_reference ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->external_reference) . "</td></tr>" : "",
|
||||
"{{ vatHtml }}" => ($invoice->uid ?? '') ? "<tr><td>Ihre UID:</td><td>" . $invoice->uid . "</td></tr>" : "",
|
||||
"{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2))
|
||||
"{{ qrCodeHtml }}" => ($invoice->total_gross ?? 0) >= 0
|
||||
? '<td style="vertical-align: top; padding-right: 10px;"><img alt="QR-Code" src="' . $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2)) . '" style="display: block; height: 100%; max-height: 3.5cm; width: auto;"></td>'
|
||||
: ''
|
||||
];
|
||||
|
||||
$headerHtml = str_replace(array_keys($replacements), array_values($replacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html"));
|
||||
@@ -208,8 +210,6 @@ class ManualInvoiceController extends TTCrud
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $post['id'] ?? null;
|
||||
$recipientEmail = $post['email'] ?? null;
|
||||
$subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH';
|
||||
$bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team';
|
||||
|
||||
if (!$id || !$recipientEmail) {
|
||||
self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']);
|
||||
@@ -222,6 +222,19 @@ class ManualInvoiceController extends TTCrud
|
||||
return;
|
||||
}
|
||||
|
||||
// Format invoice date for display
|
||||
$invoiceDateFormatted = date('d.m.Y', $invoice->invoice_date);
|
||||
|
||||
// Set default subject and body with invoice number and date
|
||||
$defaultSubject = "Ihre Rechnung {$invoice->invoice_number} vom {$invoiceDateFormatted}";
|
||||
$defaultBody = "Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung Nr. {$invoice->invoice_number} vom {$invoiceDateFormatted}.\n\nMit freundlichen Grüßen\nIhr XINON Team";
|
||||
|
||||
$subject = $post['subject'] ?? $defaultSubject;
|
||||
$bodyText = $post['body'] ?? $defaultBody;
|
||||
|
||||
// Convert literal \n strings to actual newlines (in case frontend sends escaped strings)
|
||||
$bodyText = str_replace('\n', "\n", $bodyText);
|
||||
|
||||
// Generate PDF
|
||||
$pdf_filename = $this->createPDFAction(true);
|
||||
if (!$pdf_filename || !file_exists($pdf_filename)) {
|
||||
@@ -232,19 +245,33 @@ class ManualInvoiceController extends TTCrud
|
||||
$pdfContent = file_get_contents($pdf_filename);
|
||||
|
||||
// --- HTML Email Generation ---
|
||||
$logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
|
||||
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
|
||||
$logoToolExists = file_exists($logoToolPath);
|
||||
$logoXinonExists = file_exists($logoXinonPath);
|
||||
|
||||
// Construct HTML Body
|
||||
$html = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Rechnung</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
|
||||
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
|
||||
// Construct HTML Body with Outlook compatibility
|
||||
$html = '<!DOCTYPE html>';
|
||||
$html .= '<html lang="de" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">';
|
||||
$html .= '<head>';
|
||||
$html .= '<meta charset="UTF-8">';
|
||||
$html .= '<meta http-equiv="X-UA-Compatible" content="IE=edge">';
|
||||
$html .= '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
||||
$html .= '<title>Rechnung</title>';
|
||||
$html .= '<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->';
|
||||
$html .= '<style>body { font-family: Arial, sans-serif; color: #333; margin: 0; padding: 0; }</style>';
|
||||
$html .= '</head>';
|
||||
$html .= '<body style="margin:0;padding:20px;background-color:#f3f4f6;">';
|
||||
|
||||
// Logos
|
||||
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
|
||||
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
|
||||
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
|
||||
// Outlook-safe container table
|
||||
$html .= '<!--[if mso]><table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" align="center"><tr><td><![endif]-->';
|
||||
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;">';
|
||||
|
||||
// Logo with Outlook-safe sizing
|
||||
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom:1px solid #e5e7eb;padding-bottom:15px;">';
|
||||
if ($logoXinonExists) {
|
||||
$html .= '<!--[if mso]><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td align="center"><![endif]-->';
|
||||
$html .= '<img src="cid:logo_xinon" alt="XINON GmbH" width="150" height="50" style="display:block;width:150px;height:50px;max-width:150px;margin:0 auto;">';
|
||||
$html .= '<!--[if mso]></td></tr></table><![endif]-->';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
|
||||
@@ -254,7 +281,9 @@ class ManualInvoiceController extends TTCrud
|
||||
|
||||
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
|
||||
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
|
||||
$html .= '</div></div></body></html>';
|
||||
$html .= '</div></div>';
|
||||
$html .= '<!--[if mso]></td></tr></table><![endif]-->';
|
||||
$html .= '</body></html>';
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
@@ -269,12 +298,11 @@ class ManualInvoiceController extends TTCrud
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Port = 587;
|
||||
|
||||
// Logos
|
||||
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
|
||||
// Logo embedding
|
||||
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
|
||||
|
||||
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
|
||||
$mail->setFrom('thetool@xinon.at', 'XINON TheTool');
|
||||
$mail->setFrom('thetool@xinon.at', 'XINON GmbH - Rechnungswesen');
|
||||
|
||||
$customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname);
|
||||
$mail->addAddress($recipientEmail, $customerName);
|
||||
@@ -283,7 +311,10 @@ class ManualInvoiceController extends TTCrud
|
||||
$mail->Body = $html;
|
||||
$mail->AltBody = strip_tags($bodyText);
|
||||
|
||||
$mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf');
|
||||
// Attachment filename: YYYY-MM-DD_InvoiceNumber_Rechnung.pdf
|
||||
$invoiceDateFile = date('Y-m-d', $invoice->invoice_date);
|
||||
$attachmentFilename = "{$invoiceDateFile}_{$invoice->invoice_number}_Rechnung.pdf";
|
||||
$mail->addStringAttachment($pdfContent, $attachmentFilename, 'base64', 'application/pdf');
|
||||
|
||||
$mail->send();
|
||||
|
||||
@@ -320,10 +351,17 @@ class ManualInvoiceController extends TTCrud
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
|
||||
// Log download in journal
|
||||
// Update status to 'gesendet' (same as email)
|
||||
if ($invoice->status === 'erstellt') {
|
||||
$invoice->status = 'gesendet';
|
||||
$invoice->save();
|
||||
}
|
||||
|
||||
// Log download in journal with status change
|
||||
ManualInvoiceJournalModel::create([
|
||||
'manualinvoiceId' => $id,
|
||||
'text' => 'Rechnung heruntergeladen',
|
||||
'statusChange' => 'gesendet',
|
||||
'createBy' => $me->id,
|
||||
'create' => time()
|
||||
]);
|
||||
@@ -349,20 +387,42 @@ class ManualInvoiceController extends TTCrud
|
||||
$data['invoice_date'] = strtotime($data['invoice_date']);
|
||||
}
|
||||
|
||||
$data = array_merge([
|
||||
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
|
||||
'invoice_date' => $data['invoice_date'] ?? time(),
|
||||
'status' => 'erstellt',
|
||||
'fibu_payment_skonto' => 0,
|
||||
'fibu_payment_skonto_rate' => 0,
|
||||
'gesamtrabatt' => 0,
|
||||
'total' => 0,
|
||||
'total_gross' => 0,
|
||||
'create_by' => $me->id,
|
||||
'edit_by' => $me->id,
|
||||
'create' => time(),
|
||||
'edit' => time()
|
||||
], $data);
|
||||
// Always generate invoice number (override any null from frontend)
|
||||
$data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber();
|
||||
$data['invoice_date'] = $data['invoice_date'] ?? time();
|
||||
$data['status'] = 'erstellt';
|
||||
$data['fibu_payment_skonto'] = $data['fibu_payment_skonto'] ?? 0;
|
||||
$data['fibu_payment_skonto_rate'] = $data['fibu_payment_skonto_rate'] ?? 0;
|
||||
|
||||
$data['total_discount'] = $data['total_discount'] ?? $data['gesamtrabatt'] ?? 0;
|
||||
$data['performance_period'] = $data['performance_period'] ?? $data['leistungszeitraum'] ?? null;
|
||||
$data['introductory_text'] = $data['introductory_text'] ?? $data['einleitender_text'] ?? null;
|
||||
$data['external_reference'] = $data['external_reference'] ?? $data['externe_referenz'] ?? null;
|
||||
unset($data['gesamtrabatt'], $data['leistungszeitraum'], $data['einleitender_text'], $data['externe_referenz'], $data['billing_delivery']);
|
||||
|
||||
$data['total'] = $data['total'] ?? 0;
|
||||
$data['total_gross'] = $data['total_gross'] ?? 0;
|
||||
$data['lock'] = 0;
|
||||
$data['exported'] = 0;
|
||||
|
||||
if (($data['billing_type'] ?? '') === 'sepa' && ($data['billingaddress_id'] ?? null)) {
|
||||
$address = new Address($data['billingaddress_id']);
|
||||
if ($address->id) {
|
||||
$data['bank_account_bank'] = $address->bank_account_bank;
|
||||
$data['bank_account_owner'] = $address->bank_account_owner;
|
||||
$data['bank_account_iban'] = str_replace(' ', '', $address->bank_account_iban ?? '');
|
||||
$data['bank_account_bic'] = str_replace(' ', '', $address->bank_account_bic ?? '');
|
||||
if ($address->sepa_date) {
|
||||
$data['sepa_date'] = date('Y-m-d', $address->sepa_date);
|
||||
}
|
||||
$data['sepa_id'] = 'R' . ($data['fibu_account_number'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$data['create_by'] = $me->id;
|
||||
$data['edit_by'] = $me->id;
|
||||
$data['create'] = time();
|
||||
$data['edit'] = time();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -389,9 +449,15 @@ class ManualInvoiceController extends TTCrud
|
||||
unset($data['positions']);
|
||||
}
|
||||
|
||||
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') {
|
||||
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
|
||||
return false;
|
||||
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id']))) {
|
||||
if ($invoice->lock == 1) {
|
||||
$this->infoMessages['update'] = 'Rechnung ist gesperrt und kann nicht bearbeitet werden';
|
||||
return false;
|
||||
}
|
||||
if ($invoice->status === 'exportiert') {
|
||||
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert invoice_date from string to timestamp if needed
|
||||
@@ -432,23 +498,39 @@ class ManualInvoiceController extends TTCrud
|
||||
$me->loadMe();
|
||||
|
||||
foreach ($this->tempPositions as $position) {
|
||||
// Skip empty positions
|
||||
if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue;
|
||||
$articleName = $position['warehousearticle_name'] ?? $position['product_name'] ?? '';
|
||||
if (empty($articleName) || ($position['amount'] ?? 0) == 0) continue;
|
||||
|
||||
// Map _group to position_group
|
||||
$groupName = $position['_group'] ?? null;
|
||||
unset($position['_group']);
|
||||
$amount = floatval($position['amount']);
|
||||
$price = floatval($position['price']);
|
||||
$discount = floatval($position['discount'] ?? 0);
|
||||
$vatrate = floatval($position['vatrate'] ?? 0);
|
||||
$priceAfterDiscount = $amount * $price * (1 - $discount / 100);
|
||||
$priceGross = $priceAfterDiscount * (1 + $vatrate / 100);
|
||||
|
||||
ManualInvoicepositionModel::create(array_merge([
|
||||
ManualInvoicepositionModel::create([
|
||||
'manualinvoice_id' => $invoiceId,
|
||||
'position_group' => $groupName,
|
||||
'unit' => 'Stk.',
|
||||
'discount' => 0,
|
||||
'position_group' => $position['_group'] ?? null,
|
||||
'matchcode' => $position['matchcode'] ?? null,
|
||||
'warehousearticle_id' => $position['warehousearticle_id'] ?? $position['product_id'] ?? 0,
|
||||
'warehousearticle_name' => $articleName,
|
||||
'product_info' => $position['product_info'] ?? '',
|
||||
'amount' => $amount,
|
||||
'unit' => $position['unit'] ?? 'Stk.',
|
||||
'price' => $price,
|
||||
'discount' => $discount,
|
||||
'price_total' => $priceAfterDiscount,
|
||||
'price_gross' => $priceGross,
|
||||
'vatrate' => $vatrate,
|
||||
'fibu_cost_account' => $position['fibu_cost_account'] ?? null,
|
||||
'fibu_cost_account_legacy' => $position['fibu_cost_account_legacy'] ?? null,
|
||||
'fibu_taxcode' => $position['fibu_taxcode'] ?? null,
|
||||
'options' => $position['options'] ?? null,
|
||||
'create_by' => $me->id,
|
||||
'edit_by' => $me->id,
|
||||
'create' => time(),
|
||||
'edit' => time()
|
||||
], $position));
|
||||
]);
|
||||
}
|
||||
$this->tempPositions = [];
|
||||
}
|
||||
@@ -458,17 +540,13 @@ class ManualInvoiceController extends TTCrud
|
||||
|
||||
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
|
||||
$subtotal = array_sum(array_column($positions, 'price_total'));
|
||||
$totalDiscount = $invoice->total_discount ?? 0;
|
||||
$netTotal = $subtotal * (1 - $totalDiscount / 100);
|
||||
|
||||
// Apply gesamtrabatt (total discount) if exists
|
||||
$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
|
||||
$discountAmount = $subtotal * ($gesamtrabatt / 100);
|
||||
$netTotal = $subtotal - $discountAmount;
|
||||
|
||||
// Calculate gross total with VAT applied after discount
|
||||
$grossTotal = 0;
|
||||
foreach ($positions as $pos) {
|
||||
$positionNet = $pos->price_total;
|
||||
$positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100);
|
||||
$positionAfterDiscount = $positionNet * (1 - $totalDiscount / 100);
|
||||
$grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100);
|
||||
}
|
||||
|
||||
@@ -530,11 +608,11 @@ class ManualInvoiceController extends TTCrud
|
||||
'id' => $pos->id,
|
||||
'manualinvoice_id' => $pos->manualinvoice_id,
|
||||
'_group' => $pos->position_group ?? '',
|
||||
'billing_id' => $pos->billing_id,
|
||||
'contract_id' => $pos->contract_id,
|
||||
'matchcode' => $pos->matchcode,
|
||||
'product_id' => $pos->product_id,
|
||||
'product_name' => $pos->product_name,
|
||||
'warehousearticle_id' => $pos->warehousearticle_id,
|
||||
'warehousearticle_name' => $pos->warehousearticle_name,
|
||||
'product_id' => $pos->warehousearticle_id,
|
||||
'product_name' => $pos->warehousearticle_name,
|
||||
'product_info' => $pos->product_info,
|
||||
'amount' => $pos->amount,
|
||||
'unit' => $pos->unit ?? 'Stk.',
|
||||
@@ -580,19 +658,20 @@ class ManualInvoiceController extends TTCrud
|
||||
|
||||
foreach ($existingCredits as $credit) {
|
||||
foreach ($credit->getProperty('positions') as $creditPos) {
|
||||
$key = $creditPos->product_id . '_' . $creditPos->matchcode;
|
||||
$key = $creditPos->warehousearticle_id . '_' . $creditPos->matchcode;
|
||||
$creditedAmounts[$key] = ($creditedAmounts[$key] ?? 0) + abs($creditPos->amount);
|
||||
}
|
||||
}
|
||||
|
||||
$availablePositions = [];
|
||||
foreach ($positions as $pos) {
|
||||
$key = $pos->product_id . '_' . $pos->matchcode;
|
||||
$key = $pos->warehousearticle_id . '_' . $pos->matchcode;
|
||||
$availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0);
|
||||
if ($availableAmount > 0) {
|
||||
$availablePositions[] = [
|
||||
'id' => $pos->id,
|
||||
'product_name' => $pos->product_name,
|
||||
'warehousearticle_name' => $pos->warehousearticle_name,
|
||||
'product_name' => $pos->warehousearticle_name,
|
||||
'product_info' => $pos->product_info,
|
||||
'original_amount' => $pos->amount,
|
||||
'credited_amount' => $creditedAmounts[$key] ?? 0,
|
||||
@@ -600,7 +679,8 @@ class ManualInvoiceController extends TTCrud
|
||||
'unit' => $pos->unit ?? 'Stk.',
|
||||
'price' => $pos->price,
|
||||
'vatrate' => $pos->vatrate,
|
||||
'product_id' => $pos->product_id,
|
||||
'warehousearticle_id' => $pos->warehousearticle_id,
|
||||
'product_id' => $pos->warehousearticle_id,
|
||||
'matchcode' => $pos->matchcode,
|
||||
'fibu_cost_account' => $pos->fibu_cost_account,
|
||||
'fibu_taxcode' => $pos->fibu_taxcode
|
||||
@@ -626,6 +706,12 @@ class ManualInvoiceController extends TTCrud
|
||||
|
||||
if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) {
|
||||
self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($originalInvoice->lock == 1) {
|
||||
self::returnJson(['success' => false, 'message' => 'Originalrechnung ist gesperrt und kann nicht gutgeschrieben werden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$me = new User();
|
||||
@@ -634,10 +720,10 @@ class ManualInvoiceController extends TTCrud
|
||||
$invoiceData = [
|
||||
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
|
||||
'invoice_date' => time(),
|
||||
'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null,
|
||||
'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
|
||||
'externe_referenz' => $originalInvoice->externe_referenz ?? null,
|
||||
'gesamtrabatt' => 0,
|
||||
'performance_period' => $originalInvoice->performance_period ?? null,
|
||||
'introductory_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
|
||||
'external_reference' => $originalInvoice->external_reference ?? null,
|
||||
'total_discount' => 0,
|
||||
'owner_id' => $originalInvoice->owner_id,
|
||||
'billingaddress_id' => $originalInvoice->billingaddress_id,
|
||||
'customer_number' => $originalInvoice->customer_number,
|
||||
@@ -663,7 +749,6 @@ class ManualInvoiceController extends TTCrud
|
||||
'email' => $originalInvoice->email,
|
||||
'uid' => $originalInvoice->uid,
|
||||
'billing_type' => $originalInvoice->billing_type,
|
||||
'billing_delivery' => $originalInvoice->billing_delivery,
|
||||
'bank_account_bank' => $originalInvoice->bank_account_bank,
|
||||
'bank_account_owner' => $originalInvoice->bank_account_owner,
|
||||
'bank_account_iban' => $originalInvoice->bank_account_iban,
|
||||
@@ -673,6 +758,8 @@ class ManualInvoiceController extends TTCrud
|
||||
'vatgroup_id' => $originalInvoice->vatgroup_id,
|
||||
'credit_for_invoice_id' => $originalInvoiceId,
|
||||
'status' => 'erstellt',
|
||||
'lock' => 0,
|
||||
'exported' => 0,
|
||||
'create' => time(),
|
||||
'edit' => time(),
|
||||
'create_by' => $me->id,
|
||||
@@ -681,6 +768,7 @@ class ManualInvoiceController extends TTCrud
|
||||
|
||||
if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($positions as $pos) {
|
||||
@@ -688,8 +776,8 @@ class ManualInvoiceController extends TTCrud
|
||||
ManualInvoicepositionModel::create([
|
||||
'manualinvoice_id' => $creditInvoiceId,
|
||||
'position_group' => null,
|
||||
'product_id' => $pos['product_id'],
|
||||
'product_name' => $pos['product_name'],
|
||||
'warehousearticle_id' => $pos['warehousearticle_id'] ?? $pos['product_id'] ?? 0,
|
||||
'warehousearticle_name' => $pos['warehousearticle_name'] ?? $pos['product_name'] ?? '',
|
||||
'product_info' => $pos['product_info'] ?? '',
|
||||
'amount' => -abs($pos['amount']),
|
||||
'unit' => $pos['unit'] ?? 'Stk.',
|
||||
@@ -701,8 +789,6 @@ class ManualInvoiceController extends TTCrud
|
||||
'matchcode' => $pos['matchcode'] ?? null,
|
||||
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
|
||||
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
|
||||
'contract_id' => 0,
|
||||
'billing_id' => null,
|
||||
'create_by' => $me->id,
|
||||
'edit_by' => $me->id,
|
||||
'create' => time(),
|
||||
@@ -718,7 +804,11 @@ class ManualInvoiceController extends TTCrud
|
||||
protected function beforeDelete(): bool {
|
||||
if ($id = $this->request->id) {
|
||||
$invoice = ManualInvoiceModel::get($id);
|
||||
if ($invoice && $invoice->status === 'exported') {
|
||||
if ($invoice && $invoice->lock == 1) {
|
||||
$this->infoMessages['delete'] = 'Rechnung ist gesperrt und kann nicht gelöscht werden';
|
||||
return false;
|
||||
}
|
||||
if ($invoice && ($invoice->status === 'exported' || $invoice->status === 'exportiert')) {
|
||||
$this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden';
|
||||
return false;
|
||||
}
|
||||
@@ -732,4 +822,119 @@ class ManualInvoiceController extends TTCrud
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getArticleVatInfoAction() {
|
||||
$articleId = $_GET['article_id'] ?? null;
|
||||
$vatarea = $_GET['vatarea'] ?? 'domestic';
|
||||
|
||||
if (!$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Article ID required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Article not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
$vatgroupId = $article->vatgroup_id;
|
||||
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
|
||||
|
||||
if (!$vatrate) {
|
||||
self::returnJson(['success' => false, 'message' => 'Vatrate not found for vatgroup ' . $vatgroupId . ' and area ' . $vatarea]);
|
||||
return;
|
||||
}
|
||||
|
||||
$prices = [];
|
||||
if (!empty($article->cheapestSellPrice)) {
|
||||
$pricesData = json_decode($article->cheapestSellPrice, true);
|
||||
if (is_array($pricesData)) {
|
||||
$prices = $pricesData;
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'description' => $article->description,
|
||||
'vatgroup_id' => $article->vatgroup_id,
|
||||
'unit' => $article->unit
|
||||
],
|
||||
'prices' => $prices,
|
||||
'vatgroup_id' => $vatgroupId,
|
||||
'fibu_cost_account' => $vatrate->account,
|
||||
'fibu_cost_account_legacy' => $vatrate->legacy_account,
|
||||
'fibu_taxcode' => $vatrate->taxcode,
|
||||
'vatrate' => $vatrate->rate
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getCustomerBillingInfoAction() {
|
||||
$addressId = $_GET['address_id'] ?? null;
|
||||
$vatgroupId = $_GET['vatgroup_id'] ?? 2;
|
||||
|
||||
if (!$addressId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Address ID required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$address = new Address($addressId);
|
||||
if (!$address->id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Address not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
$vatarea = 'domestic';
|
||||
if ($address->country_id) {
|
||||
$country = new Country($address->country_id);
|
||||
if ($country->id && $country->isocode != TT_HOMECOUNTRY_ISOCODE) {
|
||||
$vatarea = $country->is_eu ? 'eu' : 'other';
|
||||
}
|
||||
}
|
||||
|
||||
if ($address->uid && substr(strtolower(preg_replace('/[^a-z0-9]/i', '', $address->uid)), 0, 3) == 'atu') {
|
||||
$vatarea = 'domestic';
|
||||
}
|
||||
|
||||
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
|
||||
$taxText = $vatrate ? $vatrate->invoice_text : '';
|
||||
|
||||
$db = $this->db();
|
||||
$sepaLimit = null;
|
||||
$res = $db->query("SELECT manual_invoice_sepa_limit FROM Address WHERE id = " . intval($addressId));
|
||||
if ($res && $row = $res->fetch_assoc()) {
|
||||
$sepaLimit = $row['manual_invoice_sepa_limit'] ? floatval($row['manual_invoice_sepa_limit']) : null;
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'billing_type' => $address->billing_type ?: 'invoice',
|
||||
'manual_invoice_sepa_limit' => $sepaLimit,
|
||||
'vatarea' => $vatarea,
|
||||
'tax_text' => $taxText,
|
||||
'bank_account_bank' => $address->bank_account_bank,
|
||||
'bank_account_owner' => $address->bank_account_owner,
|
||||
'bank_account_iban' => $address->bank_account_iban,
|
||||
'bank_account_bic' => $address->bank_account_bic,
|
||||
'sepa_date' => $address->sepa_date,
|
||||
'sepa_id' => $address->sepa_id
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getTaxTextAction() {
|
||||
$vatgroupId = $_GET['vatgroup_id'] ?? 2;
|
||||
$vatarea = $_GET['vatarea'] ?? 'domestic';
|
||||
|
||||
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'tax_text' => $vatrate ? $vatrate->invoice_text : '',
|
||||
'vatrate' => $vatrate ? $vatrate->rate : 20
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ class ManualInvoiceModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?string $invoice_number;
|
||||
public int $invoice_date;
|
||||
public ?string $leistungszeitraum;
|
||||
public ?string $einleitender_text;
|
||||
public ?string $externe_referenz;
|
||||
public float $gesamtrabatt;
|
||||
public ?string $performance_period;
|
||||
public ?string $introductory_text;
|
||||
public ?string $external_reference;
|
||||
public float $total_discount;
|
||||
public int $owner_id;
|
||||
public int $billingaddress_id;
|
||||
public int $customer_number;
|
||||
@@ -33,7 +33,6 @@ class ManualInvoiceModel extends TTCrudBaseModel {
|
||||
public ?string $email;
|
||||
public ?string $uid;
|
||||
public string $billing_type;
|
||||
public string $billing_delivery;
|
||||
public ?string $bank_account_bank;
|
||||
public ?string $bank_account_owner;
|
||||
public ?string $bank_account_iban;
|
||||
@@ -44,6 +43,8 @@ class ManualInvoiceModel extends TTCrudBaseModel {
|
||||
public ?int $bmd_export_date;
|
||||
public ?int $date_delivered;
|
||||
public string $status;
|
||||
public int $lock = 0;
|
||||
public int $exported = 0;
|
||||
public ?int $credit_for_invoice_id;
|
||||
public int $create_by;
|
||||
public int $edit_by;
|
||||
|
||||
@@ -4,11 +4,9 @@ class ManualInvoicepositionModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?int $manualinvoice_id;
|
||||
public ?string $position_group;
|
||||
public ?int $billing_id;
|
||||
public int $contract_id;
|
||||
public ?string $matchcode;
|
||||
public int $product_id;
|
||||
public string $product_name;
|
||||
public int $warehousearticle_id;
|
||||
public string $warehousearticle_name;
|
||||
public ?string $product_info;
|
||||
public float $amount;
|
||||
public string $unit;
|
||||
|
||||
474
application/MobileApp/MobileAppController.php
Normal file
474
application/MobileApp/MobileAppController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
409
application/MobileApp/Modules/Lager/Inventur/InventurHandler.php
Normal file
409
application/MobileApp/Modules/Lager/Inventur/InventurHandler.php
Normal 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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
581
application/MobileApp/Modules/Lager/Movement/MovementHandler.php
Normal file
581
application/MobileApp/Modules/Lager/Movement/MovementHandler.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
113
application/MobileApp/Shared/MobileAppBaseHandler.php
Normal file
113
application/MobileApp/Shared/MobileAppBaseHandler.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,12 @@ class OpenAccessId extends mfBaseModel {
|
||||
$resp_data = Rimoapi::assignOaid($this->oaid, $ftu_data['id']);
|
||||
|
||||
// update OAID export data
|
||||
$exp_data_update = json_decode($this->export_data);
|
||||
|
||||
//$exp_data_update = json_decode($this->export_data);
|
||||
$exp_data_update = $this->getExportData();
|
||||
if(!property_exists($exp_data_update, "rimo")) {
|
||||
$exp_data_update->rimo = new StdClass();
|
||||
}
|
||||
$exp_data_update->rimo->ftu_id = $ftu_data['id'];
|
||||
$exp_data_update->rimo->ftu_name = $ftu_data['name'];
|
||||
$exp_data_update->rimo->ftu_assigned_date = date("U");
|
||||
@@ -306,11 +311,11 @@ class OpenAccessId extends mfBaseModel {
|
||||
|
||||
public function getExportData($key = false) {
|
||||
if(!$this->export_data) {
|
||||
return [];
|
||||
return new StdClass();
|
||||
} else {
|
||||
$exdata = json_decode($this->export_data);
|
||||
if(!is_object($exdata)) {
|
||||
return [];
|
||||
return new StdClass();
|
||||
}
|
||||
|
||||
if(!$key) {
|
||||
|
||||
@@ -68,6 +68,27 @@ class Order extends mfBaseModel {
|
||||
return $terminations;
|
||||
}
|
||||
|
||||
public function getSnoppProduct() {
|
||||
foreach($this->getProperty("products") as $product) {
|
||||
if($product->snopp_order_id) return $product;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getOaidProduct() {
|
||||
foreach($this->getProperty("products") as $product) {
|
||||
if($product->oaid) return $product;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getPreorderProduct() {
|
||||
foreach($this->getProperty("products") as $product) {
|
||||
if($product->preorder_id) return $product;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getShippingdate() {
|
||||
if(!$this->id) {
|
||||
return false;
|
||||
|
||||
@@ -359,7 +359,7 @@ class OrderController extends mfBaseController {
|
||||
return $new_filter;
|
||||
}
|
||||
|
||||
protected function addAction() {
|
||||
public function addAction() {
|
||||
//var_dump($this->request->filter);exit;
|
||||
|
||||
|
||||
@@ -969,6 +969,12 @@ class OrderController extends mfBaseController {
|
||||
}
|
||||
|
||||
$product_data = [];
|
||||
if(array_key_exists("preorder_id", $p) && $p["preorder_id"]) {
|
||||
$product_data["preorder_id"] = $p["preorder_id"];
|
||||
}
|
||||
if(array_key_exists("oaid", $p) && $p["oaid"]) {
|
||||
$product_data["oaid"] = $p["oaid"];
|
||||
}
|
||||
$product_data["order_id"] = $new_id;
|
||||
$product_data["product_id"] = $p["product_id"];
|
||||
$product_data['amount'] = (!empty($p['amount'])) ? $p['amount'] : 1;
|
||||
@@ -1367,6 +1373,145 @@ class OrderController extends mfBaseController {
|
||||
$this->returnJson(["status" => "OK", "order" => ['id' => $order_id]]);
|
||||
}
|
||||
|
||||
protected function createSnoppOrderAction() {
|
||||
$order_id = $this->request->id;
|
||||
|
||||
if(!$order_id || $order_id < 1) {
|
||||
$this->layout()->setFlash("Bestellung nicht gefunden.", "error");
|
||||
$this->redirect("Order");
|
||||
}
|
||||
$order = new Order($order_id);
|
||||
if(!$order->id) {
|
||||
$this->layout()->setFlash("Bestellung nicht gefunden.", "error");
|
||||
$this->redirect("Order");
|
||||
}
|
||||
|
||||
$order_product = false;
|
||||
|
||||
$product_snopp_id = false;
|
||||
$products_noterm = false;
|
||||
|
||||
// find product
|
||||
foreach($order->products as $op) {
|
||||
// check for valid internet access product
|
||||
if(!in_array($op->product->producttech_id, TT_PRODUCTTECH_IDS_INTERNET_ACCESSS)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if($op->oaid) {
|
||||
$order_product = $op;
|
||||
break;
|
||||
}
|
||||
if($op->preorder_id) {
|
||||
$order_product = $op;
|
||||
break;
|
||||
}
|
||||
// if product has a snopp product_id, then this must be it
|
||||
if($op->product->getAttributeValue("oan_pid_snopp")) {
|
||||
$order_product = $op;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(!$order_product) {
|
||||
$this->layout()->setFlash("Kein für SNOPP-Bestellungen geeignetes Produkt in dieser Bestellung gefunden.", "error");
|
||||
$this->redirect("Order", "Index", ["id" => $order->id]);
|
||||
}
|
||||
|
||||
// order in snopp
|
||||
$snopp_prod_id = $op->product->getAttributeValue("oan_pid_snopp");
|
||||
if(!$snopp_prod_id) {
|
||||
$this->layout()->setFlash("SNOPP Product ID fehlt im Produkt (".$order_product->product->name.").", "error");
|
||||
$this->redirect("Order", "Index", ["id" => $order->id]);
|
||||
}
|
||||
|
||||
// find snopp api credentials
|
||||
$api_creds = $order_product->product->getOwnerSnoppApiCredentials();
|
||||
$this->log->debug(__METHOD__.": Snopp Api Creds: ".print_r($api_creds, true));
|
||||
if(!$api_creds) {
|
||||
$this->layout()->setFlash("Produktbesitzer hat keinen SNOPP Api Key", "error");
|
||||
$this->redirect("Order");
|
||||
}
|
||||
$baseurl = $api_creds["prod"]["url"];
|
||||
$apikey = $api_creds["prod"]["key"];
|
||||
$snopp = new Snoppapi($baseurl, $apikey);
|
||||
|
||||
$ext_id = false;
|
||||
|
||||
if($order_product->oaid) {
|
||||
$ext_id = $order_product->oaid;
|
||||
} elseif($order_product->preorder_id) {
|
||||
$preorder = new Preorder($order_product->preorder_id);
|
||||
if($preorder->id && $preorder->campaign->fulfillment == "citycom_oan") {
|
||||
$ext_id = "SDIHome_xtc{$preorder->adb_wohneinheit_id}_1700000000";
|
||||
} elseif($preorder->id) {
|
||||
if($preorder->adb_wohneinheit->oaid) {
|
||||
$ext_id = $preorder->adb_wohneinheit->oaid;
|
||||
} elseif($preorder->adb_wohneinheit->extref) {
|
||||
$ext_id = $preorder->adb_wohneinheit->extref;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// search for address in snopp
|
||||
$search_data = [
|
||||
"street" => $order->owner->street,
|
||||
"zip" => $order->owner->zip,
|
||||
"city" => $order->owner->city,
|
||||
];
|
||||
$homes = $snopp->searchAddress($search_data);
|
||||
if(!$homes) {
|
||||
$this->layout()->setFlash("Home in Snopp nicht gefunden", "error");
|
||||
$this->redirect("Order", "Index", ["id" => $order->id]);
|
||||
}
|
||||
|
||||
$home = reset($homes);
|
||||
$ext_id = $home->oan_id;
|
||||
}
|
||||
|
||||
if(!$ext_id) {
|
||||
$this->layout()->setFlash("Konnte keine OAID oder External ID zur Adresse finden.", "error");
|
||||
$this->redirect("Order", "Index", ["id" => $order->id]);
|
||||
}
|
||||
|
||||
$data = [
|
||||
"oan_id" => ($order_product->oaid) ?: $ext_id,
|
||||
"product_id" => $snopp_prod_id,
|
||||
"extref" => ($op->order->partner_number) ?: $op->order->owner->customer_number,
|
||||
"execution_date" => (new DateTime("now"))->setTimezone(new DateTimeZone("Europe/Vienna"))->format("c"),
|
||||
"name" => $order->owner->getCompanyOrName(),
|
||||
"street" => $order->owner->street,
|
||||
"zip" => $order->owner->zip,
|
||||
"city" => $order->owner->city,
|
||||
"phone" => $order->owner->phone,
|
||||
"mobile" => $order->owner->mobile,
|
||||
"email" => $order->owner->email,
|
||||
];
|
||||
|
||||
|
||||
$resp = $snopp->submitOrder($data);
|
||||
|
||||
if(!$resp) {
|
||||
$this->layout()->setFlash("Fehler beim Bestellen im Snopp.", "error");
|
||||
$this->redirect("Order", "Index", ["id" => $order->id]);
|
||||
}
|
||||
|
||||
if($resp->status != "Created") {
|
||||
$this->layout()->setFlash("Konnte nicht bestellt werden: '{$resp->result->message}'", "error");
|
||||
$this->redirect("Order", "Index", ["id" => $order->id]);
|
||||
}
|
||||
|
||||
if($resp->result->order_id) {
|
||||
$order_product->snopp_order_id = $resp->result->order_id;
|
||||
} else {
|
||||
$order_product->snopp_order_id = 1;
|
||||
}
|
||||
$order_product->save();
|
||||
|
||||
$this->layout()->setFlash("Bestellung erfolgreich in SNOPP erstellt.", "success");
|
||||
$this->redirect("Order", "Index", ["id" => $order->id]);
|
||||
|
||||
}
|
||||
|
||||
protected function deleteAction() {
|
||||
if(!$this->me->is(["Admin","salespartner"])) {
|
||||
$this->layout()->setFlash("Keine Berechtigung", "error");
|
||||
|
||||
@@ -49,10 +49,6 @@ class OrderProduct extends mfBaseModel {
|
||||
public function getProperty($name) {
|
||||
if($this->$name == null) {
|
||||
|
||||
if(!$this->id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if($name == "cpeprovisioning") {
|
||||
$this->cpeprovisioning = CpeprovisioningModel::getFirst(["orderproduct_id" => $this->id]);
|
||||
return $this->cpeprovisioning;
|
||||
|
||||
@@ -5,6 +5,8 @@ class OrderProductModel
|
||||
public $order_id;
|
||||
public $product_id;
|
||||
public $termination_id;
|
||||
public $oaid;
|
||||
public $preorder_id;
|
||||
public $voicenumber;
|
||||
public $voiceplan_id;
|
||||
public $domain;
|
||||
@@ -212,6 +214,13 @@ class OrderProductModel
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists("preorder_id", $filter)) {
|
||||
$preorder_id = $filter['preorder_id'];
|
||||
if (is_numeric($preorder_id)) {
|
||||
$where .= " AND preorder_id=$preorder_id";
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists("voicenumber", $filter)) {
|
||||
$voicenumber = FronkDB::singleton()->escape($filter['voicenumber']);
|
||||
if ($voicenumber) {
|
||||
|
||||
@@ -26,6 +26,7 @@ class Preorder extends mfBaseModel {
|
||||
private $statusjournals;
|
||||
private $cancel_request_status;
|
||||
private $cancel_request_creator;
|
||||
private $orderproduct;
|
||||
|
||||
protected function beforeUpdate($data) {
|
||||
if(!array_key_exists("edit_by", $data)) {
|
||||
@@ -736,19 +737,27 @@ class Preorder extends mfBaseModel {
|
||||
$first_ctag = $search_ctag - ($search_ctag % $ctags_per_home);
|
||||
$last_ctag = $first_ctag + $ctags_per_home - 1;
|
||||
|
||||
$mgmt_ctag_exists = false;
|
||||
|
||||
$mgmt_ctag = null;
|
||||
$ctag_range = [];
|
||||
for($i = $first_ctag; $i <= $last_ctag; $i++) {
|
||||
if(!PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name])) {
|
||||
if($i == $last_ctag) {
|
||||
$ctag = PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name]);
|
||||
if(!$ctag) {
|
||||
if($i == $last_ctag && !$mgmt_ctag_exists) {
|
||||
// mgmt ctag should be the last in range
|
||||
$mgmt_ctag = $i;
|
||||
continue;
|
||||
}
|
||||
$ctag_range[] = $i;
|
||||
|
||||
} else {
|
||||
if($ctag->service_type == "mgmt") {
|
||||
$this->log->debug(__METHOD__.": mgmt ctag ($i / stag $stag) exists already\n");
|
||||
$mgmt_ctag_exists = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$ctag_range, $mgmt_ctag];
|
||||
}
|
||||
|
||||
@@ -1682,6 +1691,13 @@ class Preorder extends mfBaseModel {
|
||||
return $this->editor;
|
||||
}
|
||||
|
||||
if($name == "orderproduct") {
|
||||
$op = OrderProductModel::getFirst(["preorder_id" => $this->id]);
|
||||
if(!$op) return null;
|
||||
$this->orderproduct = $op;
|
||||
return $this->orderproduct;
|
||||
}
|
||||
|
||||
if($name == "creator") {
|
||||
$user = mfValuecache::singleton()->get("Worker-id-" . $this->create_by);
|
||||
if($user) {
|
||||
|
||||
@@ -1048,6 +1048,171 @@ class PreorderController extends mfBaseController {
|
||||
$this->layout()->set("no_filename", false);
|
||||
}
|
||||
|
||||
protected function createOrderFromPreorderAction() {
|
||||
$preorder_id = $this->request->preorder_id;
|
||||
|
||||
if(!is_numeric($preorder_id) || $preorder_id < 1) {
|
||||
$this->layout()->setFlash("Vorbestellung nicht gefunden!", "error");
|
||||
$this->redirect("Preorder", "Index");
|
||||
}
|
||||
|
||||
$preorder = new Preorder($preorder_id);
|
||||
if(!$preorder->id) {
|
||||
$this->layout()->setFlash("Vorbestellung nicht gefunden!", "error");
|
||||
$this->redirect("Preorder", "Index");
|
||||
}
|
||||
|
||||
$order_data = [];
|
||||
$order_data["preorder_id"] = $preorder->id;
|
||||
|
||||
$owner_data = [];
|
||||
foreach(["company","uid","firstname","lastname","street","zip","city","phone","email"] as $field) {
|
||||
if(!trim($preorder->$field)) {
|
||||
$owner_data[$field] = "";
|
||||
}
|
||||
$owner_data[$field] = trim($preorder->$field);
|
||||
}
|
||||
|
||||
// search owner in Address and add owner_id ...
|
||||
$owner = false;
|
||||
$owners = AddressModel::search($owner_data);
|
||||
foreach($owners as $o) {
|
||||
if(!$this->me->is("employee")) {
|
||||
// external salespartners must not use addresses with customer_number
|
||||
if($o->customer_number) continue;
|
||||
// otherwise use with address
|
||||
$owner = $o;
|
||||
} else {
|
||||
// every address can be used as fallback
|
||||
$owner = $o;
|
||||
|
||||
// if we are employees, customers with customer_number and fibu_primary_account have precedence
|
||||
// but still use addresses with only customer_number as fallback
|
||||
if($o->customer_number) {
|
||||
$owner = $o;
|
||||
if($o->fibu_primary_account) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($owner && $owner->id) {
|
||||
$order_data["owner_id"] = $owner->id;
|
||||
$order_data["owner"] = $owner;
|
||||
} else {
|
||||
foreach($owner_data as $field => $value) {
|
||||
if(!$preorder->$field) continue;
|
||||
$order_data["owner_".$field] = $value;
|
||||
}
|
||||
$order_data["new_owner"] = 1;
|
||||
}
|
||||
|
||||
if($preorder->order_date) {
|
||||
$order_data["order_date"] = $preorder->order_date;
|
||||
} else {
|
||||
$order_data["order_date"] = $preorder->create;
|
||||
}
|
||||
|
||||
$operator = false;
|
||||
$campaign = $preorder->campaign;
|
||||
if(is_array($campaign->active_operators) && count($campaign->active_operators)) {
|
||||
$campaign_operator = reset($campaign->active_operators);
|
||||
$operator = $campaign_operator->operator;
|
||||
}
|
||||
|
||||
if(!$operator) {
|
||||
$this->layout()->setFlash("Kampagne hat keinen Netzbetreiber!", "error");
|
||||
$this->redirect("Preorder", "Index", ["filter" => ["preordercampaign_id" => $campaign->id]], "preorder=$preorder_id");
|
||||
}
|
||||
|
||||
// try product with correct network id
|
||||
$product = ProductModel::getFirst([
|
||||
"external_id" => $operator->id,
|
||||
"network_id" => $campaign->network_id,
|
||||
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
|
||||
"name" => "%OAN%",
|
||||
"active" => true
|
||||
]);
|
||||
if(!$product) {
|
||||
// else use any product from operator
|
||||
$product = ProductModel::getFirst([
|
||||
"external_id" => $operator->id,
|
||||
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
|
||||
"name" => "%OAN%",
|
||||
"active" => true
|
||||
]);
|
||||
}
|
||||
if(!$product) {
|
||||
// else use any product from operator
|
||||
$product = ProductModel::getFirst([
|
||||
"external_id" => $operator->id,
|
||||
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
|
||||
"active" => true
|
||||
]);
|
||||
}
|
||||
if($operator->id == 1) {
|
||||
if(!$product) {
|
||||
$product = ProductModel::getFirst([
|
||||
"external" => 0,
|
||||
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
|
||||
"network_id" => $campaign->network_id,
|
||||
"active" => true
|
||||
]);
|
||||
}
|
||||
if(!$product) {
|
||||
$product = ProductModel::getFirst([
|
||||
"external" => 0,
|
||||
"productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
|
||||
"name" => "%OAN%",
|
||||
"active" => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
//var_dump($product);exit;
|
||||
if(!$product) {
|
||||
$this->layout()->setFlash("Keine Produkte für Netzbetreiber gefunden!", "error");
|
||||
$this->redirect("Preorder", "Index", ["filter" => ["preordercampaign_id" => $campaign->id]], "preorder=$preorder_id");
|
||||
}
|
||||
|
||||
$product_data = [];
|
||||
$product_data["preorder_id"] = $preorder->id;
|
||||
$product_data["oaid"] = $preorder->oaid;
|
||||
$product_data["product_id"] = $product->id;
|
||||
$product_data['amount'] = 1;
|
||||
$product_data["pos"] = 1;
|
||||
$product_data["description"] = "";
|
||||
$product_data["price"] = trim($product->price) ? Layout::commaToDot(trim($product->price)) : 0;
|
||||
$product_data["price_setup"] = trim($product->price_setup) ? Layout::commaToDot(trim($product->price_setup)) : 0;
|
||||
|
||||
$product_data["billing_delay"] = ($product->billing_delay) ? $product->billing_delay : 0;
|
||||
if($product_data["billing_delay"] > 6) {
|
||||
$product_data["billing_delay"] = 6;
|
||||
}
|
||||
$product_data["billing_period"] = $product->billing_period;
|
||||
$product_data["contract_term"] = $product->contract_term;
|
||||
|
||||
if($this->me->is("Admin")) {
|
||||
$product_data["price_nne"] = $product->price_nne;
|
||||
$product_data["price_nbe"] = $product->price_nbe;
|
||||
}
|
||||
|
||||
$order_data["products"] = [1 => OrderProductModel::create($product_data)];
|
||||
|
||||
//var_dump($order_data["products"]);exit;
|
||||
$order = new Order();
|
||||
$order->update($order_data);
|
||||
|
||||
//var_dump($owner_data);exit;
|
||||
|
||||
$oc = new OrderController();
|
||||
|
||||
$this->layout()->set("order", $order);
|
||||
return $oc->addAction();
|
||||
|
||||
|
||||
}
|
||||
|
||||
protected function apiAction() {
|
||||
$do = $this->request->do;
|
||||
$data = [];
|
||||
@@ -1420,8 +1585,14 @@ class PreorderController extends mfBaseController {
|
||||
|
||||
$new_remark = date("d.m.Y").": ".$new_remark;
|
||||
|
||||
$api_creds = $preorder->getNetownerRimoApiCredentials();
|
||||
$this->log->debug(__METHOD__.": Rimo Api Creds: ".print_r($api_creds, true));
|
||||
if(!$api_creds) return false;
|
||||
|
||||
$apikey = $api_creds["prod"]["key"];
|
||||
|
||||
// upload remark to Rimo
|
||||
if(!Rimoapi::addRemark($workorder->rimo_id, $new_remark)) {
|
||||
if(!Rimoapi::addRemark($apikey, $workorder->rimo_id, $new_remark)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use chillerlan\QRCode\Output\QROutputInterface;
|
||||
|
||||
class PreorderBillingInvoice extends mfBaseModel {
|
||||
protected $forcestr = ["company", "zip", "email", "phone"];
|
||||
private $owner;
|
||||
private $netowner;
|
||||
private $positions;
|
||||
private $pdf;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<?php
|
||||
|
||||
class PreorderCtag extends mfBaseModel {
|
||||
protected $forcestr = ["product_name","product_info","matchcode"];
|
||||
protected $forcestr = ["network","ext_id","ext_name"];
|
||||
private $preorder;
|
||||
private $invoice;
|
||||
private $adb_wohneinheit;
|
||||
private $creator;
|
||||
private $editor;
|
||||
|
||||
@@ -153,7 +151,7 @@ class PreorderCtag extends mfBaseModel {
|
||||
$model = new PreorderCtag();
|
||||
|
||||
$table_fields = [
|
||||
"preorder_id", "network", "stag", "ctag", "service_type", "ext_id", "ext_status", "deleted",
|
||||
"preorder_id", "network", "stag", "ctag", "service_type", "ext_id", "ext_name", "ext_status", "deleted",
|
||||
"create_by","edit_by","create","edit"
|
||||
];
|
||||
|
||||
@@ -203,7 +201,7 @@ class PreorderCtag extends mfBaseModel {
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT PreorderCtag.* FROM PreorderCtag
|
||||
WHERE $where
|
||||
ORDER BY preorder_id,stag,ctag LIMIT 1";
|
||||
ORDER BY ctag LIMIT 1";
|
||||
//var_dump($sql);exit;
|
||||
mfLoghandler::singleton()->debug($sql);
|
||||
$res = $db->query($sql);
|
||||
@@ -230,7 +228,7 @@ class PreorderCtag extends mfBaseModel {
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT PreorderCtag.* FROM PreorderCtag
|
||||
WHERE $where
|
||||
ORDER BY preorder_id DESC,stag DESC,ctag DESC LIMIT 1";
|
||||
ORDER BY ctag DESC LIMIT 1";
|
||||
//var_dump($sql);exit;
|
||||
mfLoghandler::singleton()->debug($sql);
|
||||
$res = $db->query($sql);
|
||||
|
||||
@@ -337,6 +337,16 @@ class PreorderlogisticsController extends mfBaseController {
|
||||
}
|
||||
}
|
||||
|
||||
// Date filter for sent date (Versanddatum range)
|
||||
if (!empty($filter['sent_date']) && is_array($filter['sent_date'])) {
|
||||
if (!empty($filter['sent_date']['from'])) {
|
||||
$new_filter['add-where'] .= " AND Preorderlogistics.sent >= " . intval($filter['sent_date']['from']);
|
||||
}
|
||||
if (!empty($filter['sent_date']['to'])) {
|
||||
$new_filter['add-where'] .= " AND Preorderlogistics.sent <= " . intval($filter['sent_date']['to']);
|
||||
}
|
||||
}
|
||||
|
||||
$new_filter["status_code"] = 140;
|
||||
$new_filter["deleted"] = 0;
|
||||
$new_filter["unit_count<="] = 2;
|
||||
|
||||
@@ -54,6 +54,28 @@ class Product extends mfBaseModel {
|
||||
|
||||
}
|
||||
|
||||
public function getAttributeValue($name) {
|
||||
$attributes = $this->getProperty("attributes");
|
||||
if(!array_key_exists($name, $attributes) || !$attributes[$name]->value) {
|
||||
return null;
|
||||
}
|
||||
return $attributes[$name]->value;
|
||||
}
|
||||
|
||||
public function getOwnerSnoppApiCredentials() {
|
||||
$owner = $this->getProperty("owner");
|
||||
if(!$owner) return false;
|
||||
|
||||
foreach(TT_SNOPP_API_CREDS as $api_creds) {
|
||||
if($api_creds["address_id"] == $owner->id) {
|
||||
return $api_creds;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public function getProperty($name) {
|
||||
if($this->$name == null) {
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ class ProductModel {
|
||||
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
|
||||
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
|
||||
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
|
||||
LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
|
||||
WHERE $where
|
||||
GROUP BY Product.id
|
||||
ORDER BY Productgroup.name,Producttech.name,Product.name LIMIT 1
|
||||
@@ -135,6 +136,7 @@ class ProductModel {
|
||||
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
|
||||
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
|
||||
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
|
||||
LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
|
||||
WHERE $where
|
||||
GROUP BY Product.id
|
||||
) as p
|
||||
@@ -160,6 +162,7 @@ class ProductModel {
|
||||
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
|
||||
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
|
||||
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
|
||||
LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
|
||||
WHERE $where
|
||||
GROUP BY Product.id
|
||||
ORDER BY Productgroup.name,Producttech.name,Product.name
|
||||
@@ -169,7 +172,7 @@ class ProductModel {
|
||||
if(is_array($limit) && count($limit)) {
|
||||
if(is_numeric($limit['start']) && is_numeric($limit['count'])) {
|
||||
$sql .= " LIMIT ".$limit['start'].", ".$limit['count'];
|
||||
} elseif(is_numeric($count)) {
|
||||
} elseif(is_numeric($limit['count'])) {
|
||||
$sql .= " LIMIT ".$limit['count'];
|
||||
}
|
||||
}
|
||||
@@ -233,6 +236,15 @@ class ProductModel {
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("network_id", $filter)) {
|
||||
$network_id = $filter['network_id'];
|
||||
if(is_numeric($network_id)) {
|
||||
$where .= " AND ProductNetwork.network_id=$network_id";
|
||||
} elseif(is_array($network_id) && count($network_id)) {
|
||||
$where .= " AND ProductNetwork.network_id IN (". implode(",", $network_id).")";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("name", $filter)) {
|
||||
$name = $db->escape($filter['name']);
|
||||
if($name) {
|
||||
@@ -284,7 +296,7 @@ class ProductModel {
|
||||
|
||||
if(array_key_exists("attributevalue", $filter)) {
|
||||
$attributevalue = $db->escape($filter['attributevalue']);
|
||||
if($attributevalue) {
|
||||
if(strlen($attributevalue)) {
|
||||
$where .= " AND ProductAttribute.value = '$attributevalue'";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,45 @@ use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
class RadiusController extends mfBaseController {
|
||||
private User $me;
|
||||
private bool $isApiCall = false;
|
||||
|
||||
private array $apiAllowedActions = [
|
||||
'ProxyUnsecureHTTPRequestToRadius',
|
||||
'GenieacsRunSpeedtest',
|
||||
'GenieacsGetSpeedtestResult',
|
||||
'GenieacsGetDeviceByIp',
|
||||
'GenieacsGetDeviceByMac',
|
||||
'GenieacsRefreshDevice',
|
||||
'GenieacsRebootDevice',
|
||||
'GenieacsGetDeviceInfo',
|
||||
'GenieacsPing',
|
||||
'GenieacsRemoteAccess',
|
||||
'GenieacsEventLog',
|
||||
'GenieacsNetworkStructure',
|
||||
];
|
||||
|
||||
protected function init(): void {
|
||||
$this->needlogin=true;
|
||||
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
|
||||
|
||||
if ($apiKey && in_array($this->action, $this->apiAllowedActions)) {
|
||||
$me = new User();
|
||||
$me->loadByApikey($apiKey);
|
||||
|
||||
if ($me->id) {
|
||||
$this->me = $me;
|
||||
$this->isApiCall = true;
|
||||
$this->needlogin = false;
|
||||
if (!defined('INTERNAL_USER_ID')) {
|
||||
define('INTERNAL_USER_ID', $me->id);
|
||||
}
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, X-API-Key");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->needlogin = true;
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
$this->layout()->set("me", $me);
|
||||
@@ -51,20 +87,32 @@ class RadiusController extends mfBaseController {
|
||||
|
||||
$acs = $this->getGenieACS();
|
||||
|
||||
// Set speedtest parameters on the device
|
||||
$acs->setParameterValues($deviceId, [
|
||||
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
|
||||
if (!$resolvedId) self::sendError("Device not found in GenieACS");
|
||||
|
||||
$acs->getParameterValues($resolvedId, [
|
||||
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start',
|
||||
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect',
|
||||
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess',
|
||||
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'
|
||||
]);
|
||||
|
||||
sleep(2);
|
||||
|
||||
$acs->setParameterValues($resolvedId, [
|
||||
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start' => 1,
|
||||
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1,
|
||||
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true
|
||||
]);
|
||||
|
||||
// Get device and extract IP
|
||||
$device = $acs->getDevice($deviceId);
|
||||
$ip = GenieACS::getExternalIP($device);
|
||||
sleep(3);
|
||||
|
||||
$device = $acs->getDevice($resolvedId);
|
||||
$managementIp = GenieACS::getManagementIP($device);
|
||||
$externalIp = GenieACS::getExternalIP($device);
|
||||
$ip = $externalIp ?: $managementIp;
|
||||
|
||||
if (!$ip) self::sendError("Could not determine device IP");
|
||||
|
||||
// Trigger speedtest via external API
|
||||
$url = "http://acs.xinon.at:5000/run-speedtest";
|
||||
$apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ";
|
||||
$data = json_encode(['ip' => $ip]);
|
||||
@@ -84,9 +132,8 @@ class RadiusController extends mfBaseController {
|
||||
|
||||
if ($response === false) self::sendError("Failed to connect to speedtest server");
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Speedtest started']);
|
||||
self::returnJson(['success' => true, 'message' => 'Speedtest started', 'ip' => $ip, 'serverResponse' => json_decode($response, true)]);
|
||||
} catch (Exception $e) {
|
||||
$this->log->debug("Speedtest Error", ['error' => $e->getMessage()]);
|
||||
self::sendError("Error running speedtest: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -101,11 +148,12 @@ class RadiusController extends mfBaseController {
|
||||
|
||||
$acs = $this->getGenieACS();
|
||||
|
||||
// Request parameter refresh
|
||||
$acs->getParameterValues($deviceId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']);
|
||||
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
|
||||
if (!$resolvedId) self::sendError("Device not found in GenieACS");
|
||||
|
||||
// Get device info with full data
|
||||
$device = $acs->getDevice($deviceId);
|
||||
$acs->getParameterValues($resolvedId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']);
|
||||
|
||||
$device = $acs->getDevice($resolvedId);
|
||||
|
||||
if (!$device) self::sendError("Device not found");
|
||||
|
||||
@@ -178,6 +226,19 @@ class RadiusController extends mfBaseController {
|
||||
return new GenieACS($host, $username, $password);
|
||||
}
|
||||
|
||||
private function resolveDeviceId(string $deviceId, GenieACS $acs): ?string {
|
||||
if (strpos($deviceId, ':') !== false) {
|
||||
$device = $acs->getDeviceByMac($deviceId);
|
||||
if ($device) {
|
||||
$resolvedId = GenieACS::getDeviceId($device);
|
||||
if ($resolvedId) return $resolvedId;
|
||||
if (isset($device['_id'])) return $device['_id'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return $deviceId;
|
||||
}
|
||||
|
||||
protected function genieacsGetDeviceByIpAction() {
|
||||
try {
|
||||
$ip = $_GET['ip'] ?? null;
|
||||
@@ -218,6 +279,73 @@ class RadiusController extends mfBaseController {
|
||||
}
|
||||
}
|
||||
|
||||
protected function genieacsGetDeviceByMacAction() {
|
||||
try {
|
||||
$mac = $_GET['mac'] ?? null;
|
||||
$this->log->debug("genieacsGetDeviceByMacAction", ['mac' => $mac]);
|
||||
if (!$mac) self::sendError("MAC address is required");
|
||||
|
||||
$acs = $this->getGenieACS();
|
||||
$matchedDevice = $acs->getDeviceByMac($mac);
|
||||
|
||||
if (!$matchedDevice) {
|
||||
self::returnJson(['success' => false, 'message' => 'No device found with this MAC address']);
|
||||
return;
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'deviceId' => GenieACS::getDeviceId($matchedDevice),
|
||||
'deviceInfo' => GenieACS::getDeviceInfo($matchedDevice),
|
||||
'mac' => $mac,
|
||||
'externalIp' => GenieACS::getExternalIP($matchedDevice),
|
||||
'managementIp' => GenieACS::getManagementIP($matchedDevice)
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
$this->log->debug("GetDeviceByMac Error", ['error' => $e->getMessage()]);
|
||||
self::sendError("Error fetching device: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function genieacsRefreshDeviceAction() {
|
||||
try {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$deviceId = $input['deviceId'] ?? null;
|
||||
$this->log->debug("genieacsRefreshDeviceAction", ['deviceId' => $deviceId]);
|
||||
|
||||
if (!$deviceId) self::sendError("Device ID is required");
|
||||
|
||||
$acs = $this->getGenieACS();
|
||||
|
||||
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
|
||||
if (!$resolvedId) self::sendError("Device not found in GenieACS");
|
||||
|
||||
$acs->getParameterValues($resolvedId, [
|
||||
'InternetGatewayDevice.DeviceInfo.HardwareVersion',
|
||||
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
|
||||
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.MACAddress',
|
||||
'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.ExternalIPAddress',
|
||||
'InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.SSID',
|
||||
'InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.KeyPassphrase',
|
||||
'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.HostName',
|
||||
'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.IPAddress',
|
||||
'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.MACAddress'
|
||||
]);
|
||||
|
||||
$device = $acs->getDevice($resolvedId);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'deviceInfo' => GenieACS::getDeviceInfo($device),
|
||||
'externalIp' => GenieACS::getExternalIP($device),
|
||||
'managementIp' => GenieACS::getManagementIP($device)
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
$this->log->debug("RefreshDevice Error", ['error' => $e->getMessage()]);
|
||||
self::sendError("Error refreshing device: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function genieacsRebootDeviceAction() {
|
||||
try {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -310,8 +438,11 @@ class RadiusController extends mfBaseController {
|
||||
if (!$deviceId) self::sendError("Device ID is required");
|
||||
|
||||
$acs = $this->getGenieACS();
|
||||
$creds = $acs->createRemoteUser($deviceId);
|
||||
|
||||
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
|
||||
if (!$resolvedId) self::sendError("Device not found in GenieACS");
|
||||
|
||||
$creds = $acs->createRemoteUser($resolvedId);
|
||||
if (!$creds) self::sendError("Could not obtain credentials for FritzBox");
|
||||
|
||||
$url = "http://acs.xinon.at:5000/read-fritz-eventlog";
|
||||
@@ -362,8 +493,11 @@ class RadiusController extends mfBaseController {
|
||||
if (!$deviceId) self::sendError("Device ID is required");
|
||||
|
||||
$acs = $this->getGenieACS();
|
||||
$creds = $acs->createRemoteUser($deviceId);
|
||||
|
||||
$resolvedId = $this->resolveDeviceId($deviceId, $acs);
|
||||
if (!$resolvedId) self::sendError("Device not found in GenieACS");
|
||||
|
||||
$creds = $acs->createRemoteUser($resolvedId);
|
||||
if (!$creds) self::sendError("Could not obtain credentials for FritzBox");
|
||||
|
||||
$url = "http://acs.xinon.at:5000/read-fritz";
|
||||
@@ -564,6 +698,261 @@ class RadiusController extends mfBaseController {
|
||||
|
||||
}
|
||||
|
||||
// ========== AVM Scanner Methods ==========
|
||||
|
||||
private static $avmScannerStateFile = null;
|
||||
|
||||
private static $avmPrefixes = [
|
||||
'00:04:0E', '00:15:0C', '00:1A:4F', '00:1C:4A', '00:1F:3F', '00:24:FE',
|
||||
'04:B4:FE', '08:96:D7', '08:B6:57', '0C:72:74',
|
||||
'1C:ED:6F',
|
||||
'24:65:11', '2C:3A:FD', '2C:91:AB',
|
||||
'34:31:C4', '34:81:C4', '34:E1:A9', '38:10:D5', '3C:37:12', '3C:A6:2F',
|
||||
'44:4E:6D', '48:5D:35',
|
||||
'50:E6:36', '5C:49:79',
|
||||
'60:B5:8D',
|
||||
'74:42:7F', '7C:FF:4D',
|
||||
'80:23:95',
|
||||
'98:9B:CB', '98:A9:65', '9C:C7:A6',
|
||||
'B0:F2:08', 'B4:FC:7D', 'BC:05:43',
|
||||
'C0:25:06', 'C8:0E:14', 'CC:CE:1E',
|
||||
'D0:12:CB', 'D4:24:DD', 'DC:15:C8', 'DC:39:6F',
|
||||
'E0:08:55', 'E0:28:6D', 'E8:DF:70',
|
||||
'F0:B0:14'
|
||||
];
|
||||
|
||||
private function getAvmScannerStatePath(): string {
|
||||
return BASEDIR . '/files/avm_scanner.json';
|
||||
}
|
||||
|
||||
private function loadAvmScannerState(): array {
|
||||
$path = $this->getAvmScannerStatePath();
|
||||
if (file_exists($path)) {
|
||||
$content = file_get_contents($path);
|
||||
$state = json_decode($content, true);
|
||||
if (is_array($state)) return $state;
|
||||
}
|
||||
return [
|
||||
'scanning' => false,
|
||||
'stopRequested' => false,
|
||||
'progress' => ['current' => 0, 'total' => 0],
|
||||
'currentDevice' => null,
|
||||
'startedAt' => null,
|
||||
'startedBy' => null,
|
||||
'lastUpdated' => date('c'),
|
||||
'devices' => []
|
||||
];
|
||||
}
|
||||
|
||||
private function saveAvmScannerState(array $state): void {
|
||||
$dir = dirname($this->getAvmScannerStatePath());
|
||||
if (!is_dir($dir)) @mkdir($dir, 0777, true);
|
||||
$state['lastUpdated'] = date('c');
|
||||
file_put_contents($this->getAvmScannerStatePath(), json_encode($state, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private function isAvmMac(string $mac): bool {
|
||||
$mac = strtoupper(trim($mac));
|
||||
$prefix = substr($mac, 0, 8);
|
||||
return in_array($prefix, self::$avmPrefixes);
|
||||
}
|
||||
|
||||
private function fetchRadiusUsersFromApi(): array {
|
||||
$url = "http://radius.xinon.at/api.php";
|
||||
$opts = [
|
||||
"http" => [
|
||||
"method" => "GET",
|
||||
"header" => "Authorization: Basic " . base64_encode("admin:saveman"),
|
||||
"timeout" => 120
|
||||
]
|
||||
];
|
||||
$context = stream_context_create($opts);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
if ($response === false) return [];
|
||||
$data = json_decode($response, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
private function fetchRadacctForUser(string $username): ?array {
|
||||
$url = "http://radius.xinon.at/api.php?action2=fetchRadacct&username=" . urlencode($username);
|
||||
$opts = [
|
||||
"http" => [
|
||||
"method" => "GET",
|
||||
"header" => "Authorization: Basic " . base64_encode("admin:saveman"),
|
||||
"timeout" => 10
|
||||
]
|
||||
];
|
||||
$context = stream_context_create($opts);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
if ($response === false) return null;
|
||||
$data = json_decode($response, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
protected function avmScannerGetStateAction() {
|
||||
$state = $this->loadAvmScannerState();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($state);
|
||||
die();
|
||||
}
|
||||
|
||||
protected function avmScannerGetUsersAction() {
|
||||
$users = $this->fetchRadiusUsersFromApi();
|
||||
$debug = $_GET['debug'] ?? false;
|
||||
|
||||
$macUsers = 0;
|
||||
$avmMacUsers = 0;
|
||||
$notInAcs = 0;
|
||||
$macPrefixCounts = [];
|
||||
|
||||
$avmUsers = [];
|
||||
foreach ($users as $user) {
|
||||
$username = trim($user['username'] ?? '');
|
||||
if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue;
|
||||
$macUsers++;
|
||||
|
||||
$prefix = strtoupper(substr($username, 0, 8));
|
||||
$macPrefixCounts[$prefix] = ($macPrefixCounts[$prefix] ?? 0) + 1;
|
||||
|
||||
if (!$this->isAvmMac($username)) continue;
|
||||
$avmMacUsers++;
|
||||
|
||||
$info = $user['info'] ?? '';
|
||||
if (stripos($info, 'ACS') !== false) continue;
|
||||
$notInAcs++;
|
||||
|
||||
$user['username'] = $username;
|
||||
$avmUsers[] = $user;
|
||||
}
|
||||
|
||||
if ($debug) {
|
||||
arsort($macPrefixCounts);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'totalFromApi' => count($users),
|
||||
'macAddressUsers' => $macUsers,
|
||||
'avmMacUsers' => $avmMacUsers,
|
||||
'notInAcs' => $notInAcs,
|
||||
'topMacPrefixes' => array_slice($macPrefixCounts, 0, 30, true),
|
||||
'avmPrefixes' => self::$avmPrefixes
|
||||
]);
|
||||
die();
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['users' => $avmUsers, 'count' => count($avmUsers)]);
|
||||
die();
|
||||
}
|
||||
|
||||
protected function avmScannerStartAction() {
|
||||
$state = $this->loadAvmScannerState();
|
||||
if ($state['scanning']) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Scan already running']);
|
||||
die();
|
||||
}
|
||||
|
||||
// Count how many users will be scanned (for immediate feedback)
|
||||
$users = $this->fetchRadiusUsersFromApi();
|
||||
$count = 0;
|
||||
foreach ($users as $user) {
|
||||
$username = trim($user['username'] ?? '');
|
||||
if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue;
|
||||
if (!$this->isAvmMac($username)) continue;
|
||||
$info = $user['info'] ?? '';
|
||||
if (stripos($info, 'ACS') !== false) continue;
|
||||
$count++;
|
||||
}
|
||||
|
||||
// Spawn background script using nohup to ensure it runs independently
|
||||
$scriptPath = BASEDIR . '/scripts/avm_scanner.php';
|
||||
$logPath = BASEDIR . '/files/avm_scanner.log';
|
||||
$cmd = "nohup php " . escapeshellarg($scriptPath) . " >> " . escapeshellarg($logPath) . " 2>&1 &";
|
||||
shell_exec($cmd);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true, 'message' => 'Scan started', 'total' => $count]);
|
||||
die();
|
||||
}
|
||||
|
||||
protected function avmScannerStopAction() {
|
||||
$state = $this->loadAvmScannerState();
|
||||
$state['stopRequested'] = true;
|
||||
$this->saveAvmScannerState($state);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true, 'message' => 'Stop requested']);
|
||||
die();
|
||||
}
|
||||
|
||||
protected function avmScannerToggleErledigtAction() {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$mac = $input['mac'] ?? null;
|
||||
if (!$mac) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'MAC address required']);
|
||||
die();
|
||||
}
|
||||
|
||||
$state = $this->loadAvmScannerState();
|
||||
foreach ($state['devices'] as &$device) {
|
||||
if ($device['mac'] === $mac) {
|
||||
$device['erledigt'] = !($device['erledigt'] ?? false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->saveAvmScannerState($state);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true]);
|
||||
die();
|
||||
}
|
||||
|
||||
private function detectFritzPort(string $ip): ?int {
|
||||
$url = "https://acs.xinon.at/detect-port";
|
||||
$data = json_encode(['fritz_ip' => $ip, 'timeout' => 3]);
|
||||
$opts = [
|
||||
"http" => [
|
||||
"method" => "POST",
|
||||
"header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n",
|
||||
"content" => $data,
|
||||
"timeout" => 15
|
||||
],
|
||||
"ssl" => ["verify_peer" => false, "verify_peer_name" => false]
|
||||
];
|
||||
$context = stream_context_create($opts);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
if ($response) {
|
||||
$json = json_decode($response, true);
|
||||
if ($json && $json['success'] && isset($json['port'])) {
|
||||
return (int)$json['port'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function detectFritzDevice(string $ip, int $port): ?array {
|
||||
$url = "https://acs.xinon.at/detect-device";
|
||||
$data = json_encode(['fritz_ip' => $ip, 'fritz_port' => (string)$port]);
|
||||
$opts = [
|
||||
"http" => [
|
||||
"method" => "POST",
|
||||
"header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n",
|
||||
"content" => $data,
|
||||
"timeout" => 15
|
||||
],
|
||||
"ssl" => ["verify_peer" => false, "verify_peer_name" => false]
|
||||
];
|
||||
$context = stream_context_create($opts);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
if ($response) {
|
||||
$json = json_decode($response, true);
|
||||
if ($json && $json['success'] && isset($json['device'])) {
|
||||
return $json['device'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Smalot\PdfParser\Parser;
|
||||
|
||||
class RimoWorkorder extends mfBaseModel {
|
||||
private $adb_wohneinheit;
|
||||
private $termination;
|
||||
@@ -57,6 +59,113 @@ class RimoWorkorder extends mfBaseModel {
|
||||
return $ah;
|
||||
}
|
||||
|
||||
public function parseAha(): array {
|
||||
if (!$this->id || !$this->adb_wohneinheit_id) return ['success' => false, 'message' => 'Missing ID'];
|
||||
$preorder = PreorderModel::getFirstActive(["adb_wohneinheit_id" => $this->adb_wohneinheit_id]);
|
||||
if (!$preorder?->id) return ['success' => false, 'message' => 'No active Preorder'];
|
||||
$workorder = WorkorderModel::getFirst(['preorderId' => $preorder->id]);
|
||||
if (!$workorder || !($pdf = $this->getAha())) return ['success' => false, 'message' => 'No Workorder or PDF'];
|
||||
|
||||
try {
|
||||
$dropkabel = $this->parseDropkabelFromPdf($pdf);
|
||||
$map = $this->extractMapFromPdf($pdf);
|
||||
$meta = json_decode($workorder->metadata ?: '{}', true) ?: [];
|
||||
$mapFileId = null;
|
||||
|
||||
if ($map) {
|
||||
if ($oldId = ($meta['dropcable']['map_file_id'] ?? null)) {
|
||||
$old = new File($oldId); if ($old->id) try { $old->delete(); } catch (Exception $e) {}
|
||||
}
|
||||
$fn = 'aha_lageplan_' . $this->id . '_' . time() . '.png';
|
||||
$file = FileModel::create(['name' => 'AHA Lageplan ' . $this->rimo_name, 'filename' => $fn,
|
||||
'store_filename' => $fn, 'orig_filename' => 'AHA_Lageplan_' . $this->rimo_name . '.png',
|
||||
'mimetype' => 'image/png', 'subfolder' => 'aha_maps', 'create_by' => 1]);
|
||||
if ($file->save()) {
|
||||
$dir = MFUPLOAD_FILE_SAVE_PATH . '/aha_maps';
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
if (file_put_contents("$dir/$fn", $map)) $mapFileId = $file->id;
|
||||
}
|
||||
}
|
||||
$meta['dropcable'] = ['rimo_workorder_id' => $this->id, 'rimo_name' => $this->rimo_name,
|
||||
'parsed_at' => time(), 'entries' => $dropkabel, 'map_file_id' => $mapFileId];
|
||||
$workorder->metadata = json_encode($meta);
|
||||
WorkorderModel::update((array)$workorder);
|
||||
return ['success' => true, 'dropkabel_count' => count($dropkabel), 'has_map' => (bool)$mapFileId];
|
||||
} catch (Exception $e) {
|
||||
$this->log->error(__METHOD__ . ": " . $e->getMessage());
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public static function autoParseForWorkorder(int $workorderId, bool $force = false): void {
|
||||
$wo = WorkorderModel::get($workorderId);
|
||||
if (!$wo) return;
|
||||
$meta = json_decode($wo->metadata ?? '{}', true);
|
||||
if (($force || empty($meta['dropcable']['parsed_at'])) && $wo->preorderId) {
|
||||
$pre = new Preorder($wo->preorderId);
|
||||
$rimos = $pre->adb_wohneinheit_id ? RimoWorkorderModel::search(['adb_wohneinheit_id' => $pre->adb_wohneinheit_id]) : [];
|
||||
if (!empty($rimos[0])) (new self($rimos[0]->id))->parseAha();
|
||||
}
|
||||
}
|
||||
|
||||
private function parseDropkabelFromPdf(string $pdf): array {
|
||||
$result = [];
|
||||
$text = (new Parser())->parseContent($pdf)->getPages()[0]?->getText() ?? '';
|
||||
|
||||
if (!preg_match('/Dropkabel:\s*\n(.+?)(?:Lage:|$)/s', $text, $m)) {
|
||||
// Try alternative pattern without strict newline requirement
|
||||
if (!preg_match('/Dropkabel[:\s]*(.+?)(?:Lage|Anschluss|$)/si', $text, $m)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
$started = false;
|
||||
foreach (explode("\n", $m[1]) as $line) {
|
||||
$line = trim($line);
|
||||
if (!$line) continue;
|
||||
|
||||
// Check for header line (ID and Type columns)
|
||||
if (stripos($line, 'ID') !== false && (stripos($line, 'Type') !== false || stripos($line, 'Typ') !== false)) {
|
||||
$started = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flexible cable ID pattern - matches F26-K009, F-ABC123-K01, F-XYZ(1)-K02, etc.
|
||||
if ($started && preg_match('#^([A-Z][A-Z0-9()/_-]*-K\d+)\s+(.+)$#i', $line, $p)) {
|
||||
$rest = $p[2]; $status = '';
|
||||
foreach (['Planfreigabe', 'Plan released', 'Grobplanung', 'Executed', 'Ausgeführt', 'Detailed planning', 'Detailplanung'] as $s)
|
||||
if (preg_match('/\b' . preg_quote($s, '/') . '\s*$/i', $rest)) {
|
||||
$status = $s; $rest = trim(preg_replace('/\b' . preg_quote($s, '/') . '\s*$/i', '', $rest)); break;
|
||||
}
|
||||
$lp = $li = '';
|
||||
if (preg_match_all('/(\d+)\s*m\b/', $rest, $lens, PREG_SET_ORDER)) {
|
||||
$lp = ($lens[0][1] ?? '') . ' m'; $li = ($lens[1][1] ?? '') . ' m';
|
||||
$rest = preg_replace('/\d+\s*m\b/', '', $rest);
|
||||
}
|
||||
$result[] = ['cable_id' => trim($p[1]), 'type' => trim(preg_replace('/\s+/', ' ', $rest)),
|
||||
'laenge_plan' => $lp, 'laenge_ist' => $li, 'status' => $status];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function extractMapFromPdf(string $pdf): ?string {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'aha_'); file_put_contents($tmp, $pdf);
|
||||
$out = tempnam(sys_get_temp_dir(), 'aha_img_'); unlink($out);
|
||||
exec(sprintf('pdftoppm -png -f 1 -l 1 -r 150 %s %s 2>&1', escapeshellarg($tmp), escapeshellarg($out)), $_, $ret);
|
||||
@unlink($tmp);
|
||||
$outFile = file_exists("$out-1.png") ? "$out-1.png" : "$out.png";
|
||||
if ($ret !== 0 || !file_exists($outFile)) return null;
|
||||
$img = @imagecreatefromstring(file_get_contents($outFile)); @unlink($outFile);
|
||||
if (!$img) return null;
|
||||
$h = imagesy($img); $cropY = (int)($h * 0.42); $cropH = (int)($h * 0.84) - $cropY;
|
||||
$cropped = imagecrop($img, ['x' => 60, 'y' => $cropY, 'width' => imagesx($img) - 90, 'height' => $cropH]);
|
||||
imagedestroy($img); if (!$cropped) return null;
|
||||
ob_start(); imagepng($cropped, null, 6); $content = ob_get_clean(); imagedestroy($cropped);
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function getProperty($name) {
|
||||
if($this->$name == null) {
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class RimoWorkorderController extends mfBaseController {
|
||||
|
||||
protected function downloadAhaAction() {
|
||||
$workorder_id = $this->request->id;
|
||||
$inline = !empty($this->request->inline);
|
||||
|
||||
if(!$workorder_id || $workorder_id < 1) {
|
||||
header("HTTP/1.1 400 Bad Request");
|
||||
@@ -34,10 +35,33 @@ class RimoWorkorderController extends mfBaseController {
|
||||
exit;
|
||||
}
|
||||
|
||||
header("Content-type: text/pdf");
|
||||
header('Content-disposition: attachment; filename="'.$workorder->rimo_name.'_AHA.pdf"');
|
||||
$filename = $workorder->rimo_name.'_AHA.pdf';
|
||||
$disposition = $inline ? 'inline' : 'attachment';
|
||||
|
||||
header("Content-type: application/pdf");
|
||||
header('Content-disposition: '.$disposition.'; filename="'.$filename.'"');
|
||||
echo $return;
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function parseAhaAction() {
|
||||
header('Content-Type: application/json');
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $post['id'] ?? $this->request->id ?? null;
|
||||
|
||||
if (!$id || $id < 1) {
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid workorder id.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$wo = new RimoWorkorder($id);
|
||||
if (!$wo->id) {
|
||||
echo json_encode(['success' => false, 'message' => 'RimoWorkorder nicht gefunden.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode($wo->parseAha());
|
||||
exit;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,6 +12,9 @@ class UserController extends mfBaseController
|
||||
{
|
||||
private $me;
|
||||
|
||||
// User IDs allowed to manage (add/edit/delete) users
|
||||
private const ALLOWED_USER_MANAGER_IDS = [2, 5, 9, 6, 89, 145, 24];
|
||||
|
||||
protected function init($request = null)
|
||||
{
|
||||
$this->needlogin = true;
|
||||
@@ -24,6 +27,11 @@ class UserController extends mfBaseController
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') $this->postData = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
|
||||
private function canManageUsers(): bool
|
||||
{
|
||||
return in_array($this->me->id, self::ALLOWED_USER_MANAGER_IDS);
|
||||
}
|
||||
|
||||
protected function indexAction($request)
|
||||
{
|
||||
if (!$this->isAdmin()) {
|
||||
@@ -32,6 +40,7 @@ class UserController extends mfBaseController
|
||||
|
||||
Helper::renderVue($this, "User", "Benutzer", [
|
||||
"IS_ADMIN" => $this->me->isAdmin(),
|
||||
"CAN_MANAGE_USERS" => $this->canManageUsers(),
|
||||
"USERS" => array_map(fn($user) => [
|
||||
"username" => $user->username,
|
||||
"name" => $user->name,
|
||||
@@ -53,6 +62,7 @@ class UserController extends mfBaseController
|
||||
|
||||
protected function formAction() {
|
||||
if (!$this->isAdmin()) $this->redirect("Dashboard");
|
||||
if (!$this->canManageUsers()) $this->redirect("User");
|
||||
|
||||
$id = $this->request->id;
|
||||
$user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User();
|
||||
@@ -178,6 +188,7 @@ class UserController extends mfBaseController
|
||||
|
||||
protected function generateApikeyAction($request) {
|
||||
if (!$this->isAdmin()) $this->redirect("Dashboard");
|
||||
if (!$this->canManageUsers()) $this->redirect("User");
|
||||
|
||||
$id = $request['id'];
|
||||
if (!is_numeric($id) || $id < 1) {
|
||||
@@ -207,6 +218,11 @@ class UserController extends mfBaseController
|
||||
unset($r->address_id);
|
||||
}
|
||||
|
||||
// Only allowed users can create/edit other users
|
||||
if ($this->isAdmin() && !$this->canManageUsers()) {
|
||||
self::redirect('User');
|
||||
}
|
||||
|
||||
if (!$id && !$r->username) self::redirect('User');
|
||||
|
||||
$user = new User($id);
|
||||
@@ -569,7 +585,7 @@ class UserController extends mfBaseController
|
||||
}
|
||||
|
||||
protected function impersonateAction() {
|
||||
if(!$this->me->isAdmin() || $this->me->address_id != 1) {
|
||||
if(!$this->me->isAdmin() || $this->me->address_id != 1 || !$this->canManageUsers()) {
|
||||
header("HTTP/1.1 403 Forbidden");
|
||||
exit;
|
||||
}
|
||||
@@ -590,6 +606,10 @@ class UserController extends mfBaseController
|
||||
|
||||
protected function sendLoginEmailAction()
|
||||
{
|
||||
if (!$this->canManageUsers()) {
|
||||
self::sendError("Keine Berechtigung.");
|
||||
}
|
||||
|
||||
$id = $this->request->id;
|
||||
if (!$id || !is_numeric($id)) {
|
||||
self::sendError("Benutzer-ID fehlt oder ist ungültig.");
|
||||
|
||||
@@ -114,8 +114,11 @@ class VoicenumberController extends mfBaseController {
|
||||
$number_data['port_out_date'] = self::dateToTimestamp($r->port_out_date);
|
||||
}
|
||||
|
||||
if($r->disabled === "1") {
|
||||
$number_data['disabled'] = 1;
|
||||
if($r->disabled == "1") {
|
||||
if(!$number->disabled) {
|
||||
$number_data['disabled'] = date('U');
|
||||
$number_data['disabled_by'] = $this->me->id;
|
||||
}
|
||||
switch($r->disabled_reason) {
|
||||
case "ported_out":
|
||||
$number_data['disabled_reason'] = "ported_out";
|
||||
@@ -123,6 +126,9 @@ class VoicenumberController extends mfBaseController {
|
||||
case "ported_back":
|
||||
$number_data['disabled_reason'] = "ported_back";
|
||||
break;
|
||||
case "contract_cancelled":
|
||||
$number_data['disabled_reason'] = "contract_cancelled";
|
||||
break;
|
||||
case "legacy":
|
||||
$number_data['disabled_reason'] = "legacy";
|
||||
break;
|
||||
|
||||
@@ -17,6 +17,7 @@ class VoicenumberModel {
|
||||
public $ported_out;
|
||||
public $disabled;
|
||||
public $disabled_reason;
|
||||
public $disabled_by;
|
||||
public $enable_on_date;
|
||||
public $comment;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class WarehouseArticleController extends TTCrud {
|
||||
['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]],
|
||||
['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']],
|
||||
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]],
|
||||
['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false],
|
||||
['key' => 'vatgroup_id', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 2, 'text' => 'Dienstleistungen'], ['value' => 3, 'text' => 'Handelswaren']]], 'table' => false],
|
||||
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
|
||||
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
|
||||
['key' => 'warningAmount', 'text' => 'Warnmenge', 'required' => true,'modal' => ['type' => 'number'], 'table' => false],
|
||||
@@ -111,7 +111,7 @@ class WarehouseArticleController extends TTCrud {
|
||||
if ($categoryId) {
|
||||
$category = WarehouseCategory::get($categoryId);
|
||||
if ($category && $category->articleNumberPrefix) {
|
||||
$expectedPrefix = $category->articleNumberPrefix;
|
||||
$expectedPrefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
|
||||
$articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix));
|
||||
if ($articlePrefix !== $expectedPrefix) {
|
||||
self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen.");
|
||||
@@ -178,7 +178,7 @@ class WarehouseArticleController extends TTCrud {
|
||||
if (!$category) self::sendError("Kategorie nicht gefunden");
|
||||
if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix");
|
||||
|
||||
$prefix = $category->articleNumberPrefix;
|
||||
$prefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
// Get all existing article numbers with this prefix, sorted
|
||||
@@ -253,7 +253,7 @@ class WarehouseArticleController extends TTCrud {
|
||||
];
|
||||
|
||||
$pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars);
|
||||
$wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
|
||||
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
|
||||
|
||||
$filename = $pdf->render($wkhtmltopdfArgs);
|
||||
|
||||
@@ -262,4 +262,30 @@ class WarehouseArticleController extends TTCrud {
|
||||
readfile($filename);
|
||||
die();
|
||||
}
|
||||
|
||||
protected function printLabelsByCategoryAction() {
|
||||
$categoryId = intval($this->request->categoryId);
|
||||
if (!$categoryId) {
|
||||
self::sendError("Kategorie nicht angegeben", 400);
|
||||
}
|
||||
|
||||
$articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']);
|
||||
if (empty($articles)) {
|
||||
self::sendError("Keine Artikel in dieser Kategorie gefunden", 404);
|
||||
}
|
||||
|
||||
$pdf_vars = ['articles' => $articles];
|
||||
$pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars);
|
||||
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
|
||||
|
||||
$filename = $pdf->render($wkhtmltopdfArgs);
|
||||
|
||||
$category = WarehouseCategory::get($categoryId);
|
||||
$categoryName = $category ? $category->name : 'category-' . $categoryId;
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"');
|
||||
readfile($filename);
|
||||
die();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class WarehouseArticleModel extends TTCrudBaseModel {
|
||||
public ?int $isEndOfLife;
|
||||
public string $unit;
|
||||
public ?int $isSerialDocumentation;
|
||||
public int $revenueAccount;
|
||||
public int $vatgroup_id;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ class WarehouseCategory extends TTCrudBaseModel {
|
||||
public string $description;
|
||||
public ?string $articleNumberPrefix;
|
||||
public int $create;
|
||||
public int $create_by;
|
||||
public ?int $create_by;
|
||||
public ?int $edit;
|
||||
public ?int $edit_by;
|
||||
}
|
||||
@@ -16,7 +16,39 @@ class WarehouseCategoryController extends TTCrud {
|
||||
];
|
||||
// @formatter:on
|
||||
|
||||
protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']];
|
||||
protected array $additionalActions = [
|
||||
['key' => 'printLabels', 'title' => 'Labels drucken', 'class' => 'fas fa-print text-primary'],
|
||||
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']
|
||||
];
|
||||
|
||||
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
|
||||
|
||||
public function printLabelsAction() {
|
||||
$categoryId = intval($this->request->id);
|
||||
$articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']);
|
||||
|
||||
if (empty($articles)) {
|
||||
echo "Keine Artikel in dieser Kategorie.";
|
||||
die();
|
||||
}
|
||||
|
||||
$pdf_vars = [
|
||||
'articles' => $articles
|
||||
];
|
||||
|
||||
$pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars);
|
||||
$wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
|
||||
|
||||
$filename = $pdf->render($wkhtmltopdfArgs);
|
||||
|
||||
$category = WarehouseCategory::get($categoryId);
|
||||
$categoryName = $category ? $category->name : 'category-' . $categoryId;
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"');
|
||||
readfile($filename);
|
||||
die();
|
||||
}
|
||||
|
||||
protected function beforeCreate(): bool {
|
||||
$this->postData['articleNumberPrefix'] = $this->getNextFreePrefix();
|
||||
|
||||
245
application/WarehouseMovement/WarehouseMovementController.php
Normal file
245
application/WarehouseMovement/WarehouseMovementController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
140
application/WarehouseMovement/WarehouseMovementModel.php
Normal file
140
application/WarehouseMovement/WarehouseMovementModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class WarehouseOfferController extends TTCrud
|
||||
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
|
||||
$this->postData['status'] = 'new';
|
||||
$this->postData['version'] = 1;
|
||||
$this->postData['validity'] = 14;
|
||||
$this->postData['validity'] = 31;
|
||||
$this->postData['alternativePositions'] = json_encode([]);
|
||||
return true;
|
||||
}
|
||||
@@ -366,10 +366,13 @@ class WarehouseOfferController extends TTCrud
|
||||
$version = $this->request->version ?? null;
|
||||
$offerData = null;
|
||||
|
||||
$versionDate = null; // Date when this version was created (for validity calculation)
|
||||
|
||||
if ($version) {
|
||||
$historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version);
|
||||
if ($historyEntry && !empty($historyEntry->data)) {
|
||||
$offerData = json_decode($historyEntry->data);
|
||||
$versionDate = $historyEntry->create; // Use version creation date
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +380,10 @@ class WarehouseOfferController extends TTCrud
|
||||
$offer = WarehouseOfferModel::get($id);
|
||||
if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden');
|
||||
$offerData = $offer;
|
||||
|
||||
// Get latest history entry for current version's date
|
||||
$latestHistory = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $offer->version);
|
||||
$versionDate = $latestHistory ? $latestHistory->create : $offer->create;
|
||||
}
|
||||
|
||||
|
||||
@@ -432,11 +439,12 @@ class WarehouseOfferController extends TTCrud
|
||||
"alternativeTotal" => $alternativeTotal,
|
||||
"offerNumber" => $offerData->offerNumber,
|
||||
"offerDate" => $offerData->create,
|
||||
"versionDate" => $versionDate ?? $offerData->create, // Date for validity calculation
|
||||
"offerEditorName" => $editor ? $editor->name : 'Unbekannt',
|
||||
"includeTax" => true,
|
||||
"vatRate" => 0.20,
|
||||
"offerText" => $offerData->notes ?? '',
|
||||
"validity" => $offerData->validity ?? 14,
|
||||
"validity" => $offerData->validity ?? 31,
|
||||
"closingText" => $offerData->closingText ?? '',
|
||||
"bank_iban" => TT_INVOICE_BANK_IBAN,
|
||||
"bank_bic" => TT_INVOICE_BANK_BIC,
|
||||
|
||||
@@ -406,9 +406,72 @@ $appendToBody
|
||||
];
|
||||
|
||||
try {
|
||||
$createdMovementIds = [];
|
||||
|
||||
// Create warehouse movements for delivery statuses
|
||||
if (in_array($postData['status'], ['partiallyDelivered', 'fullyDelivered'])
|
||||
&& isset($postData['deliveryData']) && is_array($postData['deliveryData'])) {
|
||||
|
||||
// Get location ID from request or use default (K1 Fladnitz 150)
|
||||
$locationId = intval($postData['locationId'] ?? 0);
|
||||
if ($locationId <= 0) {
|
||||
// Default to K1 Fladnitz 150
|
||||
$allLocations = WarehouseLocationModel::getAll();
|
||||
$defaultLocation = null;
|
||||
foreach ($allLocations as $loc) {
|
||||
if ($loc->title === 'K1 Fladnitz 150') {
|
||||
$defaultLocation = $loc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$locationId = $defaultLocation ? $defaultLocation->id : 1;
|
||||
}
|
||||
|
||||
// Prepare delivery data with articleId from order positions
|
||||
$positions = json_decode($order->positions, true) ?: [];
|
||||
$deliveryDataWithArticleIds = [];
|
||||
|
||||
foreach ($postData['deliveryData'] as $index => $delivery) {
|
||||
if (isset($positions[$index])) {
|
||||
$delivery['articleId'] = $positions[$index]['article'];
|
||||
}
|
||||
$deliveryDataWithArticleIds[] = $delivery;
|
||||
}
|
||||
|
||||
$createdMovementIds = $this->createMovementsForDelivery(
|
||||
intval($postData['orderId']),
|
||||
$deliveryDataWithArticleIds,
|
||||
$locationId
|
||||
);
|
||||
|
||||
if (!empty($createdMovementIds)) {
|
||||
// Update order with linked movement IDs
|
||||
$existingMovementIds = $order->linkedMovementIds
|
||||
? json_decode($order->linkedMovementIds, true) : [];
|
||||
$allMovementIds = array_merge($existingMovementIds, $createdMovementIds);
|
||||
$orderAsArray['linkedMovementIds'] = json_encode($allMovementIds);
|
||||
|
||||
// Add movement info to log message
|
||||
$fullLogMessage .= ($fullLogMessage ? "\n\n" : "") .
|
||||
count($createdMovementIds) . " Lagerbewegung(en) erstellt.";
|
||||
$log['message'] = trim($fullLogMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Store delivery note file IDs
|
||||
if (!empty($postData['deliveryNoteFileIds'])) {
|
||||
$existingFileIds = $order->deliveryNoteFileIds
|
||||
? json_decode($order->deliveryNoteFileIds, true) : [];
|
||||
$allFileIds = array_merge($existingFileIds, $postData['deliveryNoteFileIds']);
|
||||
$orderAsArray['deliveryNoteFileIds'] = json_encode($allFileIds);
|
||||
}
|
||||
|
||||
if ($postData['status'] !== 'noChanges') {
|
||||
$orderAsArray['status'] = $postData['status'];
|
||||
WarehouseOrderModel::update($orderAsArray);
|
||||
} elseif (!empty($orderAsArray['linkedMovementIds']) || !empty($orderAsArray['deliveryNoteFileIds'])) {
|
||||
// Update even if status didn't change but we added linked data
|
||||
WarehouseOrderModel::update($orderAsArray);
|
||||
}
|
||||
|
||||
// Only create a log entry if there's actually something to log
|
||||
@@ -416,7 +479,11 @@ $appendToBody
|
||||
WarehouseLogModel::create($log);
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Log entry created']);
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => 'Log entry created',
|
||||
'createdMovementIds' => $createdMovementIds
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]);
|
||||
}
|
||||
@@ -485,6 +552,107 @@ $appendToBody
|
||||
}
|
||||
}
|
||||
|
||||
protected function createMovementsForDelivery(int $orderId, array $deliveryData, int $locationId): array {
|
||||
$order = WarehouseOrderModel::get($orderId);
|
||||
$createdMovementIds = [];
|
||||
|
||||
foreach ($deliveryData as $delivery) {
|
||||
$deliveredAmount = floatval($delivery['amount']);
|
||||
$articleId = intval($delivery['articleId']);
|
||||
|
||||
// Only create movements for items actually delivered
|
||||
if ($deliveredAmount <= 0 || $articleId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find or create WarehouseItem for article + location
|
||||
$existingItems = WarehouseItemModel::getAll([
|
||||
'articleId' => $articleId,
|
||||
'warehouseLocationId' => $locationId
|
||||
]);
|
||||
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
|
||||
|
||||
if (!$warehouseItem) {
|
||||
// Create new warehouse item with zero quantity
|
||||
$warehouseItemId = WarehouseItemModel::create([
|
||||
'articleId' => $articleId,
|
||||
'warehouseLocationId' => $locationId,
|
||||
'quantity' => 0
|
||||
]);
|
||||
$warehouseItem = WarehouseItemModel::get($warehouseItemId);
|
||||
}
|
||||
|
||||
$quantityBefore = $warehouseItem->quantity;
|
||||
$quantityAfter = $quantityBefore + $deliveredAmount;
|
||||
|
||||
// Create warehouse movement
|
||||
$movementData = [
|
||||
'movementNumber' => WarehouseMovementModel::generateMovementNumber(),
|
||||
'movementType' => 'IN',
|
||||
'articleId' => $articleId,
|
||||
'warehouseLocationId' => $locationId,
|
||||
'warehouseItemId' => $warehouseItem->id,
|
||||
'quantity' => $deliveredAmount,
|
||||
'quantityBefore' => $quantityBefore,
|
||||
'quantityAfter' => $quantityAfter,
|
||||
'reasonCategory' => 'Warenlieferung',
|
||||
'linkedOrderId' => $orderId,
|
||||
'note' => "Lagereingang aus Bestellung {$order->orderNumber}",
|
||||
'userId' => $this->user->id,
|
||||
'createBy' => $this->user->id,
|
||||
'create' => time()
|
||||
];
|
||||
|
||||
$movementId = WarehouseMovementModel::create($movementData);
|
||||
$createdMovementIds[] = $movementId;
|
||||
|
||||
// Update warehouse item quantity
|
||||
$warehouseItem->quantity = $quantityAfter;
|
||||
WarehouseItemModel::update((array)$warehouseItem);
|
||||
}
|
||||
|
||||
return $createdMovementIds;
|
||||
}
|
||||
|
||||
protected function getLinkedMovementsAction() {
|
||||
$orderId = $this->request->orderId;
|
||||
if (empty($orderId)) {
|
||||
self::returnJson(['error' => 'Order ID is required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$order = WarehouseOrderModel::get($orderId);
|
||||
$linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
|
||||
|
||||
$movements = [];
|
||||
foreach ($linkedMovementIds as $movementId) {
|
||||
$movement = WarehouseMovementModel::get($movementId);
|
||||
if ($movement) {
|
||||
$article = $movement->getArticle();
|
||||
$location = $movement->getLocation();
|
||||
$movements[] = [
|
||||
'id' => $movement->id,
|
||||
'movementNumber' => $movement->movementNumber,
|
||||
'quantity' => $movement->quantity,
|
||||
'articleName' => $article ? $article->title : 'Unbekannt',
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'create' => $movement->create
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson($movements);
|
||||
}
|
||||
|
||||
protected function getLocationsAction() {
|
||||
$locations = WarehouseLocationModel::getAll();
|
||||
$result = array_map(function($loc) {
|
||||
return [
|
||||
'value' => $loc->id,
|
||||
'text' => $loc->title
|
||||
];
|
||||
}, $locations);
|
||||
self::returnJson($result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -32,6 +32,8 @@ class WarehouseOrderModel extends TTCrudBaseModel {
|
||||
public string $delAddrPLZ;
|
||||
public int $editor;
|
||||
public ?string $note;
|
||||
public ?string $linkedMovementIds = null;
|
||||
public ?string $deliveryNoteFileIds = null;
|
||||
public string $positions;
|
||||
public ?int $sendShippingNote;
|
||||
public int $create;
|
||||
|
||||
@@ -6,7 +6,7 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
|
||||
//@formatter:off
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']],
|
||||
['key' => 'shippingNoteNumber', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap', 'filter' => 'search']],
|
||||
['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true],
|
||||
['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']],
|
||||
['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [
|
||||
@@ -56,6 +56,7 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
]);
|
||||
$this->postData['positions'] = json_encode($this->postData['positions']);
|
||||
if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']);
|
||||
$this->postData['shippingNoteNumber'] = WarehouseShippingNoteModel::generateShippingNoteNumber();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -130,7 +131,10 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
// Get billing address info
|
||||
$billingAddress = null;
|
||||
if ($shippingNote->billingAddressId) {
|
||||
$billingAddress = Address::getOne($shippingNote->billingAddressId);
|
||||
$billingAddress = new Address($shippingNote->billingAddressId);
|
||||
if (!$billingAddress->id) {
|
||||
$billingAddress = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine price type ONCE (not in loop for performance)
|
||||
@@ -486,10 +490,6 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
"bank_bank" => TT_INVOICE_BANK_BANK,
|
||||
"bank_owner" => TT_INVOICE_BANK_OWNER];
|
||||
|
||||
// Replace placeholders in header
|
||||
// create shipping note in this format LS2024-X0001
|
||||
// pad number on the left side with zeros
|
||||
$shippingNoteNumber = "LS" . date("Y", $shippingNote->create) . "-" . str_pad($shippingNote->id, 4, "0", STR_PAD_LEFT);
|
||||
$headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html");
|
||||
$headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $headerHtml);
|
||||
@@ -504,7 +504,7 @@ class WarehouseShippingNoteController extends TTCrud {
|
||||
$headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml);
|
||||
$headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml);
|
||||
$headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml);
|
||||
$headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNoteNumber, $headerHtml);
|
||||
$headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNote->shippingNoteNumber, $headerHtml);
|
||||
$headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml);
|
||||
|
||||
$headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class WarehouseShippingNoteModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?string $shippingNoteNumber = null;
|
||||
public ?int $billingAddressId;
|
||||
public ?string $type;
|
||||
public ?string $metadata;
|
||||
@@ -21,4 +22,23 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel {
|
||||
public ?int $eShopOrderId;
|
||||
public ?int $create;
|
||||
public ?int $createBy;
|
||||
|
||||
public static function generateShippingNoteNumber(): string {
|
||||
$year = date('Y');
|
||||
$prefix = "LS{$year}-X";
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT shippingNoteNumber FROM WarehouseShippingNote
|
||||
WHERE shippingNoteNumber LIKE '{$prefix}%'
|
||||
ORDER BY shippingNoteNumber DESC LIMIT 1");
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
$lastNumber = intval(substr($row['shippingNoteNumber'], -4));
|
||||
$nextNumber = $lastNumber + 1;
|
||||
} else {
|
||||
$nextNumber = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad((string)$nextNumber, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class WorkorderModel extends TTCrudBaseModel
|
||||
public ?string $additionalInfo;
|
||||
public ?string $cableLength;
|
||||
public ?string $cableType;
|
||||
public ?string $metadata;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
|
||||
@@ -199,4 +200,62 @@ class WorkorderModel extends TTCrudBaseModel
|
||||
$result = $db->query($sql);
|
||||
return $result ? $result->fetch_assoc()['count'] : 0;
|
||||
}
|
||||
|
||||
public static function getTechnicalData(int $workorderId): ?array {
|
||||
$workorder = self::get($workorderId);
|
||||
if (!$workorder || !$workorder->preorderId) return null;
|
||||
|
||||
$preorder = new Preorder($workorder->preorderId);
|
||||
if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null;
|
||||
|
||||
$wohneinheit = $preorder->adb_wohneinheit;
|
||||
if (!$wohneinheit) return null;
|
||||
|
||||
$defaultCluster = '';
|
||||
if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) {
|
||||
$defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? '';
|
||||
}
|
||||
|
||||
$patchposition = [
|
||||
'equipmentName' => $wohneinheit->getPatchEqString(),
|
||||
'equipmentPort' => $wohneinheit->patch_port,
|
||||
'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster,
|
||||
'shelf' => $wohneinheit->patch_shelf,
|
||||
'module' => $wohneinheit->patch_module,
|
||||
];
|
||||
|
||||
// Get dropcable data from metadata
|
||||
$dropkabelData = [];
|
||||
$ahaParsed = null;
|
||||
$mapFile = null;
|
||||
if (!empty($workorder->metadata)) {
|
||||
$metadata = json_decode($workorder->metadata, true);
|
||||
if (!empty($metadata['dropcable'])) {
|
||||
$ahaParsed = $metadata['dropcable']['parsed_at'] ?? null;
|
||||
$dropkabelData = $metadata['dropcable']['entries'] ?? [];
|
||||
if ($mapFileId = $metadata['dropcable']['map_file_id'] ?? null) {
|
||||
$file = new File($mapFileId);
|
||||
if ($file->id) {
|
||||
$mapFile = ['id' => $file->id, 'name' => $file->name, 'download_url' => '/File/show?id=' . $file->id];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rimoWorkorders = [];
|
||||
if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) {
|
||||
foreach ($wohneinheit->rimo_workorders as $wo) {
|
||||
$rimoWorkorders[] = [
|
||||
'id' => $wo->id, 'rimoName' => $wo->rimo_name, 'rimoId' => $wo->rimo_id,
|
||||
'rimoStatus' => $wo->rimo_status, 'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'patchposition' => $patchposition,
|
||||
'rimoWorkorders' => $rimoWorkorders,
|
||||
'dropcable' => ['parsed_at' => $ahaParsed, 'entries' => $dropkabelData, 'map_file' => $mapFile],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class WorkorderAdminController extends WorkorderBaseController
|
||||
public function indexAction()
|
||||
{
|
||||
$this->createWorkordersFromPreorders();
|
||||
$this->autoCompleteDocumentedWorkorders();
|
||||
$this->archiveWorkorders();
|
||||
parent::indexAction();
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ class WorkorderBaseController extends TTCrud
|
||||
$translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap);
|
||||
}
|
||||
|
||||
// Auto-parse AHA if enabled and not yet parsed
|
||||
if ($tenantConfig?->showTechnicalData) {
|
||||
RimoWorkorder::autoParseForWorkorder((int)$this->request->workorderId);
|
||||
}
|
||||
|
||||
$responseDocs = [];
|
||||
$typeCounts = [];
|
||||
@@ -141,6 +145,13 @@ class WorkorderBaseController extends TTCrud
|
||||
return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves technical data (patchposition and AHA Blatt info) for a workorder.
|
||||
*/
|
||||
protected function getTechnicalData(int $workorderId): ?array {
|
||||
return WorkorderModel::getTechnicalData($workorderId);
|
||||
}
|
||||
|
||||
//region BACKGROUND TASKS
|
||||
/**
|
||||
* Creates new workorders from preorders based on tenant configurations.
|
||||
@@ -272,5 +283,50 @@ class WorkorderBaseController extends TTCrud
|
||||
}
|
||||
file_put_contents($lockFile, time());
|
||||
}
|
||||
|
||||
protected function autoCompleteDocumentedWorkorders()
|
||||
{
|
||||
$lockFile = TEMP_DIR . "/task_auto_complete_workorders.lock";
|
||||
if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) return;
|
||||
|
||||
foreach (WorkorderTenantConfigModel::getAll() as $config) {
|
||||
$filter = json_decode($config->autoCompleteFilter ?? '', true);
|
||||
if (empty($filter)) continue;
|
||||
|
||||
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
|
||||
if (empty($networks)) continue;
|
||||
|
||||
$networkIds = array_map(fn($n) => $n->id, $networks);
|
||||
$campaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
|
||||
if (empty($campaignIds)) continue;
|
||||
|
||||
$filter['preordercampaign_id'] = $campaignIds;
|
||||
$matchingPreorders = PreorderModel::searchActive($filter);
|
||||
if (empty($matchingPreorders)) continue;
|
||||
|
||||
$preorderIds = array_map(fn($p) => $p->id, $matchingPreorders);
|
||||
$preorderIdSet = array_flip($preorderIds);
|
||||
|
||||
$workorders = WorkorderModel::getAll([
|
||||
'status' => ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved', 'documented', 'archived'],
|
||||
'preorderId' => $preorderIds
|
||||
]);
|
||||
|
||||
foreach ($workorders as $workorder) {
|
||||
if (!isset($preorderIdSet[$workorder->preorderId])) continue;
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'completed';
|
||||
WorkorderModel::update((array)$workorder);
|
||||
WorkorderJournalModel::create([
|
||||
'workorderId' => $workorder->id,
|
||||
'text' => 'Dokumentation automatisch akzeptiert (Auto-Complete Filter).',
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'),
|
||||
'create' => time(),
|
||||
'createBy' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
file_put_contents($lockFile, time());
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
|
||||
@@ -121,6 +121,23 @@ class WorkorderCompanyController extends WorkorderBaseController {
|
||||
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']);
|
||||
}
|
||||
|
||||
protected function clearAppointmentAction() {
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A';
|
||||
$workorder->appointmentDate = null;
|
||||
$workorder->status = 'assigned';
|
||||
WorkorderModel::update((array)$workorder);
|
||||
|
||||
WorkorderJournalModel::create([
|
||||
'workorderId' => $workorder->id, 'text' => "Termin gelöscht (war: {$oldDateFormatted}).",
|
||||
'create' => time(), 'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gelöscht.']);
|
||||
}
|
||||
|
||||
protected function requestInterventionAction() {
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['journalText'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
$workorder = WorkorderModel::get($this->postData['workorderId']);
|
||||
@@ -167,14 +184,23 @@ class WorkorderCompanyController extends WorkorderBaseController {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']);
|
||||
return;
|
||||
}
|
||||
self::returnJson([
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'documentationTypes' => json_decode($tenantConfig->documentationTypes, true),
|
||||
'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired,
|
||||
'interventionTypes' => json_decode($tenantConfig->interventionTypes, true),
|
||||
'requireCableLength' => $tenantConfig->requireCableLength,
|
||||
'requireCableType' => $tenantConfig->requireCableType
|
||||
]);
|
||||
'requireCableType' => $tenantConfig->requireCableType,
|
||||
'showTechnicalData' => (bool)$tenantConfig->showTechnicalData,
|
||||
'tiefbauSeesNormalDocs' => (bool)$tenantConfig->tiefbauSeesNormalDocs,
|
||||
];
|
||||
|
||||
if ($tenantConfig->showTechnicalData) {
|
||||
$response['technicalData'] = $this->getTechnicalData((int)$this->request->workorderId);
|
||||
}
|
||||
|
||||
self::returnJson($response);
|
||||
}
|
||||
|
||||
protected function uploadDocumentationAction() {
|
||||
|
||||
304
application/WorkorderDashboard/WorkorderDashboardController.php
Normal file
304
application/WorkorderDashboard/WorkorderDashboardController.php
Normal 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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -375,4 +375,38 @@ class WorkorderMphAdminController extends WorkorderMphBaseController
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']);
|
||||
}
|
||||
|
||||
protected function unassignWorkorderAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
if ($workorder->status === 'new') self::sendError("Arbeitsauftrag ist nicht zugewiesen.");
|
||||
if (in_array($workorder->status, ['completed', 'cancelled'])) self::sendError("Arbeitsauftrag kann nicht mehr geändert werden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$oldCompany = $workorder->companyId ? WorkorderCompanyModel::get($workorder->companyId) : null;
|
||||
$oldCompanyName = $oldCompany ? $oldCompany->name : 'Unbekannt';
|
||||
|
||||
$workorder->status = 'new';
|
||||
$workorder->companyId = null;
|
||||
$workorder->assignmentDate = null;
|
||||
$workorder->deadlineDate = null;
|
||||
$workorder->appointmentDate = null;
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
$reason = !empty($this->postData['reason']) ? " Grund: " . $this->postData['reason'] : '';
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Zuweisung aufgehoben (vorher: $oldCompanyName).$reason",
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Zuweisung wurde aufgehoben.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class WorkorderTenantConfigController extends TTCrud {
|
||||
$data['interventionTypes'] = json_encode($data['interventionTypes'] ?? []);
|
||||
$data['workorderCreationFilters'] ??= '{}';
|
||||
$data['workorderActiveFilters'] ??= '{}';
|
||||
$data['autoCompleteFilter'] ??= null;
|
||||
|
||||
if (empty($data['id'])) {
|
||||
$data['create'] = time();
|
||||
|
||||
@@ -8,10 +8,13 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel {
|
||||
public string $documentationTypes; // JSON
|
||||
public string $workorderCreationFilters; // JSON
|
||||
public ?string $workorderActiveFilters; // JSON
|
||||
public ?string $autoCompleteFilter; // JSON
|
||||
public ?string $interventionTypes; // JSON
|
||||
public int $civilEngineeringDocsRequired;
|
||||
public int $requireCableLength;
|
||||
public int $requireCableType;
|
||||
public int $showTechnicalData = 0;
|
||||
public int $tiefbauSeesNormalDocs = 0;
|
||||
public int $enableWorkorder;
|
||||
public int $enableWorkorderMph;
|
||||
public int $create;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"stomp-php/stomp-php": "^5",
|
||||
"phpmailer/phpmailer": "^6.9",
|
||||
"pear2/net_routeros": "dev-develop@dev",
|
||||
"matthiasmullie/minify": "^1.3"
|
||||
"matthiasmullie/minify": "^1.3",
|
||||
"smalot/pdfparser": "^2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
35
db/migrations/20260115121818_preorder_ctag_add_ext_name.php
Normal file
35
db/migrations/20260115121818_preorder_ctag_add_ext_name.php
Normal 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") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
50
db/migrations/20260117120000_add_shipping_note_number.php
Normal file
50
db/migrations/20260117120000_add_shipping_note_number.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
43
db/migrations/20260117150000_add_order_movement_linking.php
Normal file
43
db/migrations/20260117150000_add_order_movement_linking.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
db/migrations/20260119170000_add_metadata_to_workorder.php
Normal file
30
db/migrations/20260119170000_add_metadata_to_workorder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user