Merge branch 'master' into fronkdev
This commit is contained in:
@@ -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."]);
|
||||
|
||||
@@ -726,16 +726,24 @@ class AddressController extends mfBaseController {
|
||||
}
|
||||
|
||||
$xinon_project = new XinonProject();
|
||||
$tickets = $xinon_project->searchSupportTickets('', 0, ['pageSize' => 100,
|
||||
'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [$address->customer_number]]]])]);
|
||||
$filterParams = ['pageSize' => 100,
|
||||
'filters' => json_encode([['customField6' => ['operator' => '=', 'values' => [(string)$address->customer_number]]]])];
|
||||
|
||||
$tickets = $xinon_project->searchSupportTickets('', 0, $filterParams) ?? [];
|
||||
|
||||
$shippingNotes = array_map(function ($shippingNote) {
|
||||
$shippingNote->createByName = (new User($shippingNote->createBy))->getAbbrName();
|
||||
return $shippingNote;
|
||||
}, WarehouseShippingNoteModel::getAll(['billingAddressId' => $address->id]));
|
||||
|
||||
Helper::renderVue($this,"AddressTickets",
|
||||
"Tickets und Lieferscheine von Kunden: " . $address->getCompanyOrName() . '(' . $address->customer_number . ')', ["TICKETS" => $tickets,"SHIPPING_NOTES" => $shippingNotes,"ADDRESS" => $address]);
|
||||
$customerName = str_replace(["\r", "\n"], ' ', $address->getCompanyOrName());
|
||||
Helper::renderVue($this,"AddressTickets", "Tickets und Lieferscheine", [
|
||||
"TICKETS" => $tickets,
|
||||
"SHIPPING_NOTES" => $shippingNotes,
|
||||
"CUSTOMER_NAME" => $customerName,
|
||||
"CUSTOMER_NUMBER" => $address->customer_number,
|
||||
"HIDE_PAGE_TITLE" => true
|
||||
]);
|
||||
}
|
||||
|
||||
protected function sendServicePinAction() {
|
||||
|
||||
@@ -7,7 +7,7 @@ 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' => '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']],
|
||||
@@ -42,7 +42,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);
|
||||
|
||||
@@ -265,9 +265,10 @@ class CalendarModel
|
||||
continue;
|
||||
}
|
||||
if ($data['all_day_event'] == 1) {
|
||||
if (in_array("Feiertag", $categories)) {
|
||||
if (is_array($categories) && in_array("Feiertag", $categories)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$starttime = date("Y-m-d", $data['start_time']);
|
||||
$endtime = date("Y-m-d", $data['end_time']);
|
||||
} else {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -208,8 +208,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 +220,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 +243,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 +279,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 +296,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 +309,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();
|
||||
|
||||
@@ -349,20 +378,21 @@ 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['gesamtrabatt'] = $data['gesamtrabatt'] ?? 0;
|
||||
$data['total'] = $data['total'] ?? 0;
|
||||
$data['total_gross'] = $data['total_gross'] ?? 0;
|
||||
$data['lock'] = 0;
|
||||
$data['exported'] = 0;
|
||||
$data['create_by'] = $me->id;
|
||||
$data['edit_by'] = $me->id;
|
||||
$data['create'] = time();
|
||||
$data['edit'] = time();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -389,9 +419,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
|
||||
@@ -626,6 +662,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();
|
||||
@@ -673,6 +715,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 +725,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) {
|
||||
@@ -718,7 +763,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 +781,49 @@ 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;
|
||||
}
|
||||
|
||||
// Map revenueAccount to vatgroup_id
|
||||
// revenueAccount 0 = Dienstleistungen = vatgroup_id 2
|
||||
// revenueAccount 1 = Handelswaren = vatgroup_id 3
|
||||
$vatgroupId = $article->revenueAccount == 0 ? 2 : 3;
|
||||
|
||||
// Get vatrate for this vatgroup and area
|
||||
$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;
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'description' => $article->description,
|
||||
'revenueAccount' => $article->revenueAccount
|
||||
],
|
||||
'vatgroup_id' => $vatgroupId,
|
||||
'fibu_cost_account' => $vatrate->account,
|
||||
'fibu_cost_account_legacy' => $vatrate->legacy_account,
|
||||
'fibu_taxcode' => $vatrate->taxcode,
|
||||
'vatrate' => $vatrate->rate
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,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;
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
|
||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||
|
||||
/**
|
||||
* Warehouse Stocktake Handler
|
||||
*
|
||||
* Handles all endpoints for the Warehouse Stocktake PWA.
|
||||
* Migrated from WarehouseStocktakePWAController with new structure.
|
||||
*/
|
||||
class WarehouseStocktakeHandler extends MobileAppBaseHandler {
|
||||
|
||||
protected $requiredPermission = 'WarehouseUser';
|
||||
protected $appName = 'WarehouseStocktake';
|
||||
protected $viewTemplate = 'MobileApp/WarehouseStocktake';
|
||||
|
||||
/**
|
||||
* Get active stocktakes that user can participate in
|
||||
* GET /MobileApp/WarehouseStocktake/getActiveStocktakes
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stocktake details
|
||||
* GET /MobileApp/WarehouseStocktake/getStocktake?id=X
|
||||
*/
|
||||
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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article by QR code or article number
|
||||
* GET /MobileApp/WarehouseStocktake/getArticle?code=X
|
||||
*/
|
||||
public function getArticleAction() {
|
||||
$code = $this->request->code;
|
||||
|
||||
if (!$code) {
|
||||
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = null;
|
||||
|
||||
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
|
||||
// Also accept WH: for backwards compatibility
|
||||
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
||||
$articleId = intval($matches[1]);
|
||||
} else {
|
||||
// Try to find by article number
|
||||
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
|
||||
if ($article) {
|
||||
$articleId = $article->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get category name
|
||||
$category = WarehouseCategory::get($article->category_id);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'title' => $article->title,
|
||||
'description' => $article->description ?? '',
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'categoryName' => $category ? $category->name : '',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search articles by text with optional category filter
|
||||
* GET /MobileApp/WarehouseStocktake/searchArticles?query=X&categoryId=Y
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories for browsing
|
||||
* GET /MobileApp/WarehouseStocktake/getCategories
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if article is already scanned in stocktake
|
||||
* GET /MobileApp/WarehouseStocktake/checkAlreadyScanned?stocktakeId=X&articleId=Y
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a scanned item
|
||||
* POST /MobileApp/WarehouseStocktake/submitScan
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// Verify stocktake exists and is in progress
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'in_progress') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify article exists
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->db();
|
||||
|
||||
// If overwrite mode is enabled, mark existing item as overwritten
|
||||
if ($overwrite && $overwriteItemId) {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
|
||||
// Mark old item as overwritten by new item
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
|
||||
|
||||
$finalQuantity = $quantity;
|
||||
|
||||
// Log the overwrite
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'overwrittenItemId' => $overwriteItemId,
|
||||
]);
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isOverwrite' => true,
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this article was already scanned in this stocktake (non-overwritten)
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
// Update existing entry - add to quantity
|
||||
$newQuantity = $existing->countedQuantity + $quantity;
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET
|
||||
countedQuantity = {$newQuantity},
|
||||
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
|
||||
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
|
||||
scannedAt = " . time() . ",
|
||||
scannedBy = {$this->user->id}
|
||||
WHERE id = {$existing->id}");
|
||||
|
||||
$itemId = $existing->id;
|
||||
$finalQuantity = $newQuantity;
|
||||
$isUpdate = true;
|
||||
} else {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
$finalQuantity = $quantity;
|
||||
$isUpdate = false;
|
||||
}
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
// Log the scan
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'totalQuantity' => $finalQuantity,
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isUpdate' => $isUpdate,
|
||||
]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => $isUpdate
|
||||
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
|
||||
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isUpdate' => $isUpdate,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent scans for current user in a stocktake
|
||||
* GET /MobileApp/WarehouseStocktake/getMyScans?stocktakeId=X
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress stats
|
||||
* GET /MobileApp/WarehouseStocktake/getProgress?stocktakeId=X
|
||||
*/
|
||||
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();
|
||||
|
||||
// Total scanned items
|
||||
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
|
||||
$totalRow = $totalResult->fetch_assoc();
|
||||
$totalScanned = intval($totalRow['count']);
|
||||
|
||||
// My scanned items
|
||||
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
|
||||
$myRow = $myResult->fetch_assoc();
|
||||
$myScanned = intval($myRow['count']);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'progress' => [
|
||||
'totalScanned' => $totalScanned,
|
||||
'myScanned' => $myScanned,
|
||||
'status' => $stocktake->status,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?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() {
|
||||
// We handle auth ourselves
|
||||
$this->needlogin = false;
|
||||
|
||||
// Try to load user if session exists
|
||||
$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) {
|
||||
// Normalize names
|
||||
$moduleName = ucfirst(strtolower($module));
|
||||
$submoduleName = ucfirst(strtolower($submodule));
|
||||
|
||||
// Check authentication for API calls
|
||||
if (!$this->user || !$this->user->id) {
|
||||
self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build handler path
|
||||
$handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php";
|
||||
|
||||
if (!file_exists($handlerFile)) {
|
||||
self::returnJson(['success' => false, 'error' => "Module 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
<?php
|
||||
|
||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||
|
||||
/**
|
||||
* Inventur (Stocktake) Handler
|
||||
*
|
||||
* Handles all endpoints for the Lager > Inventur module.
|
||||
* API Base: /MobileApp/Lager/Inventur/{action}
|
||||
*/
|
||||
class InventurHandler extends MobileAppBaseHandler {
|
||||
|
||||
protected $requiredPermission = 'WarehouseUser';
|
||||
|
||||
/**
|
||||
* Get active stocktakes
|
||||
* GET /MobileApp/Lager/Inventur/getActiveStocktakes
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stocktake details
|
||||
*/
|
||||
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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article by QR code or article number
|
||||
*/
|
||||
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 : '',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search articles
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if already scanned
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit scan
|
||||
*/
|
||||
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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get my scans
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress
|
||||
*/
|
||||
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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||
|
||||
/**
|
||||
* Movement (Stock Movement) Handler
|
||||
*
|
||||
* Handles all endpoints for the Lager > Movement module.
|
||||
* API Base: /MobileApp/Lager/Movement/{action}
|
||||
*/
|
||||
class MovementHandler extends MobileAppBaseHandler {
|
||||
|
||||
protected $requiredPermission = 'WarehouseUser';
|
||||
|
||||
/**
|
||||
* Get available locations (Office + Außenlager only)
|
||||
* GET /MobileApp/Lager/Movement/getLocations
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article by QR code or article number
|
||||
* GET /MobileApp/Lager/Movement/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;
|
||||
}
|
||||
|
||||
$category = WarehouseCategory::get($article->category_id);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'title' => $article->title,
|
||||
'description' => $article->description ?? '',
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'categoryName' => $category ? $category->name : '',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search articles
|
||||
* GET /MobileApp/Lager/Movement/searchArticles?query=X
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reason categories for a movement type
|
||||
* GET /MobileApp/Lager/Movement/getReasonCategories?type=IN|OUT|ADJUSTMENT
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current stock for an article at a location
|
||||
* GET /MobileApp/Lager/Movement/getCurrentStock?articleId=X&locationId=X
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a stock movement
|
||||
* POST /MobileApp/Lager/Movement/submitMovement
|
||||
*/
|
||||
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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent movements by current user
|
||||
* GET /MobileApp/Lager/Movement/getMyMovements
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get movement types with labels
|
||||
* GET /MobileApp/Lager/Movement/getMovementTypes
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+404
-153
@@ -17,6 +17,25 @@ class PopController extends mfBaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function getMapCategories()
|
||||
{
|
||||
$categories = [];
|
||||
foreach (PopModel::$categoryArray as $id => $cat) {
|
||||
$categories[] = [
|
||||
'id' => $id,
|
||||
'name' => $cat['name'],
|
||||
'icon' => 'assets/img/markers/pop_' . $id . '.png',
|
||||
];
|
||||
}
|
||||
|
||||
$categories[] = [
|
||||
'id' => null,
|
||||
'name' => 'Unbekannt',
|
||||
'icon' => 'assets/img/markers/pop_unknown.png',
|
||||
];
|
||||
return $categories;
|
||||
}
|
||||
|
||||
protected function indexAction()
|
||||
{
|
||||
$networks = array_map(function ($network) {
|
||||
@@ -30,7 +49,7 @@ class PopController extends mfBaseController
|
||||
return [
|
||||
"id" => $pop->id,
|
||||
"name" => $pop->name,
|
||||
"category" => $pop->category,
|
||||
"category" => $pop->category ?: 99,
|
||||
"networkArea" => $pop->networks,
|
||||
"location" => $pop->location,
|
||||
"state" => $pop->state,
|
||||
@@ -45,6 +64,8 @@ class PopController extends mfBaseController
|
||||
];
|
||||
}, PopModel::getAlladv());
|
||||
|
||||
$categories = $this->getMapCategories();
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl(""),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
@@ -55,11 +76,20 @@ class PopController extends mfBaseController
|
||||
],
|
||||
"NETWORKS" => $networks,
|
||||
"POPS" => $pops,
|
||||
"CATEGORIES" => $categories,
|
||||
"IS_ADMIN" => $this->me->is("Admin"),
|
||||
"MAPBOX_TOKEN" => TT_MAPBOX_TILE_API_TOKEN,
|
||||
];
|
||||
|
||||
$this->layout()->set("vueViewName", "Pop");
|
||||
$this->layout()->set("JSGlobals", $JSGlobals);
|
||||
$this->layout()->set("additionalCSS", [
|
||||
"assets/css/leaflet.css",
|
||||
]);
|
||||
$this->layout()->set("additionalJS", [
|
||||
"assets/js/leaflet.js",
|
||||
"assets/js/leaflet.MakiMarkers.js"
|
||||
]);
|
||||
$this->layout()->setTemplate("VueViews/Vue");
|
||||
|
||||
}
|
||||
@@ -112,6 +142,7 @@ class PopController extends mfBaseController
|
||||
{
|
||||
$network_id = 90;
|
||||
$this->layout()->set("network_id", $network_id);
|
||||
$this->layout()->set("categories", $this->getMapCategories());
|
||||
$this->layout()->setTemplate("Pop/Map");
|
||||
}
|
||||
|
||||
@@ -258,28 +289,23 @@ class PopController extends mfBaseController
|
||||
$home_id = $this->request->home_id;
|
||||
|
||||
if (!$fiber_id && !$home_id) {
|
||||
return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige Faser-ID oder Home-ID']));
|
||||
return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige ID']));
|
||||
}
|
||||
|
||||
if ($home_id) {
|
||||
if ($home_id && !$fiber_id) {
|
||||
$sql = "SELECT id FROM FiberPlanFiber WHERE home_id = '" . $db->escape($home_id) . "' LIMIT 1";
|
||||
$res = $db->query($sql);
|
||||
if ($db->num_rows($res)) {
|
||||
$row = $db->fetch_array($res);
|
||||
$fiber_id = $row['id'];
|
||||
} else {
|
||||
return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Keine Faser für Home-ID gefunden']));
|
||||
}
|
||||
}
|
||||
|
||||
$fiber = new FiberPlanFiber($fiber_id);
|
||||
|
||||
if (!$fiber->id) {
|
||||
return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Faser nicht gefunden']));
|
||||
}
|
||||
|
||||
$this->log->debug("Lade Faser-Strecke für Faser ID: $fiber_id");
|
||||
|
||||
$details = $fiber->toArray();
|
||||
$details['customer_cable_type'] = $fiber->customer_cable_type;
|
||||
$details['customer_cable_fiber_nr'] = $fiber->customer_cable_fiber_nr;
|
||||
@@ -293,26 +319,217 @@ class PopController extends mfBaseController
|
||||
|
||||
if ($fiber->address || $fiber->home_id) {
|
||||
$customerGps = $this->geocodeAddress($fiber->address, $fiber->home_id);
|
||||
if ($customerGps) {
|
||||
$details['customer_gps'] = $customerGps;
|
||||
$this->log->debug("GPS für Kunde gefunden: " . json_encode($customerGps));
|
||||
}
|
||||
if ($customerGps) $details['customer_gps'] = $customerGps;
|
||||
}
|
||||
|
||||
$debug = [];
|
||||
$debug['start_fiber'] = [
|
||||
'id' => $fiber->id,
|
||||
'fiber_nr_cable' => $fiber->fiber_nr_cable,
|
||||
'branch_type' => $fiber->branch_type,
|
||||
'branch_cable_nr' => $fiber->branch_cable_nr,
|
||||
'branch_fiber_nr' => $fiber->branch_fiber_nr
|
||||
];
|
||||
|
||||
if ($home_id) {
|
||||
$this->log->debug("=== MODUS: Rückwärts-Trace (von home_id) ===");
|
||||
$cableChain = $this->buildCompleteCableChain($fiber);
|
||||
if (count($cableChain) > 0) {
|
||||
$mainCable = $cableChain[0]['cable'];
|
||||
$mainFiber = $cableChain[0]['fiber'];
|
||||
$cable_route_data = FiberPlanCableModel::getCableRoute($mainCable->id);
|
||||
$cable_route_array = [];
|
||||
foreach ($cable_route_data as $station) $cable_route_array[] = $station['name'];
|
||||
$allFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null);
|
||||
$fibersArray = [];
|
||||
foreach ($allFibers as $f) {
|
||||
$fibersArray[] = [
|
||||
'id' => $f->id, 'fiber_nr_cable' => $f->fiber_nr_cable,
|
||||
'fiber_color' => $f->fiber_color, 'fiber_color_hex' => $f->fiber_color_hex,
|
||||
'bundle_nr' => $f->bundle_nr, 'bundle_color' => $f->bundle_color, 'bundle_color_hex' => $f->bundle_color_hex,
|
||||
'branch_type' => $f->branch_type, 'branch_cable_nr' => $f->branch_cable_nr,
|
||||
'branch_from_location' => $f->branch_from_location, 'branch_fiber_nr' => $f->branch_fiber_nr
|
||||
];
|
||||
}
|
||||
|
||||
$branchPoints = [];
|
||||
$sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
|
||||
FROM FiberPlanDispatcher WHERE network_id = 90 AND object_type = 4 AND gps_lat IS NOT NULL";
|
||||
$res = $db->query($sql);
|
||||
while ($data = $db->fetch_array($res)) {
|
||||
$branchPoints[] = ['id' => $data['id'], 'name' => $data['name'], 'gps_lat' => $data['gps_lat'], 'gps_long' => $data['gps_long'], 'object_type' => intval($data['object_type']), 'type' => $data['type']];
|
||||
}
|
||||
|
||||
$details['cable_info'] = [
|
||||
'id' => $mainCable->id, 'description' => $mainCable->description, 'fibers' => $fibersArray,
|
||||
'diameter' => $mainCable->diameter, 'cable_route_array' => $cable_route_array,
|
||||
'cable_route_full' => $cable_route_data, 'coordinates' => $mainCable->coordinates,
|
||||
'location' => $mainFiber->location, 'branch_points' => $branchPoints
|
||||
];
|
||||
|
||||
$allCablesForMatching = [];
|
||||
foreach ($cableChain as $chainItem) {
|
||||
$c = $chainItem['cable'];
|
||||
$coords = json_decode($c->coordinates, true);
|
||||
if ($coords) $allCablesForMatching[] = ['id' => $c->id, 'description' => $c->description, 'coordinates' => $coords];
|
||||
}
|
||||
$sql = "SELECT id, description, coordinates FROM FiberPlanCable WHERE network_id = 90 AND coordinates IS NOT NULL AND coordinates != '' AND coordinates != '[]'";
|
||||
$res = $db->query($sql);
|
||||
while ($c = $db->fetch_array($res)) {
|
||||
$coords = json_decode($c['coordinates'], true);
|
||||
if ($coords) {
|
||||
$exists = false; foreach($allCablesForMatching as $ex) { if($ex['id'] == $c['id']) $exists=true; }
|
||||
if(!$exists) $allCablesForMatching[] = ['id' => $c['id'], 'description' => $c['description'], 'coordinates' => $coords];
|
||||
}
|
||||
}
|
||||
$details['all_cables'] = $allCablesForMatching;
|
||||
}
|
||||
|
||||
if (count($cableChain) > 1) {
|
||||
$details['branch_path'] = $this->buildBranchPathFromChain($cableChain, 1, $debug);
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->log->debug("=== MODUS: Vorwärts-Trace ===");
|
||||
|
||||
if ($fiber->cable_id) {
|
||||
$cable = new FiberPlanCable($fiber->cable_id);
|
||||
if ($cable->id) {
|
||||
$cable_route_data = FiberPlanCableModel::getCableRoute($cable->id);
|
||||
$cable_route_array = [];
|
||||
foreach ($cable_route_data as $station) $cable_route_array[] = $station['name'];
|
||||
|
||||
$branchPoints = [];
|
||||
$sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
|
||||
FROM FiberPlanDispatcher WHERE network_id = 90 AND object_type = 4 AND gps_lat IS NOT NULL";
|
||||
$res = $db->query($sql);
|
||||
while ($data = $db->fetch_array($res)) {
|
||||
$branchPoints[] = ['id' => $data['id'], 'name' => $data['name'], 'gps_lat' => $data['gps_lat'], 'gps_long' => $data['gps_long'], 'object_type' => intval($data['object_type']), 'type' => $data['type']];
|
||||
}
|
||||
|
||||
$allFibers = FiberPlanFiberModel::getByCableAndSheet($cable->id, null);
|
||||
$fibersArray = [];
|
||||
foreach ($allFibers as $f) {
|
||||
$fibersArray[] = [
|
||||
'id' => $f->id, 'fiber_nr_cable' => $f->fiber_nr_cable, 'fiber_color' => $f->fiber_color, 'fiber_color_hex' => $f->fiber_color_hex,
|
||||
'bundle_nr' => $f->bundle_nr, 'bundle_color' => $f->bundle_color, 'bundle_color_hex' => $f->bundle_color_hex,
|
||||
'branch_type' => $f->branch_type, 'branch_cable_nr' => $f->branch_cable_nr,
|
||||
'branch_from_location' => $f->branch_from_location, 'branch_fiber_nr' => $f->branch_fiber_nr
|
||||
];
|
||||
}
|
||||
|
||||
$details['cable_info'] = [
|
||||
'id' => $cable->id, 'description' => $cable->description, 'fibers' => $fibersArray,
|
||||
'diameter' => $cable->diameter, 'cable_route_array' => $cable_route_array,
|
||||
'cable_route_full' => $cable_route_data, 'coordinates' => $cable->coordinates,
|
||||
'location' => $fiber->location, 'branch_points' => $branchPoints
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($fiber->branch_type === 'Abzweigkabel' && $fiber->branch_cable_nr) {
|
||||
$details['branch_path'] = $this->traceBranchPath($fiber, 0, 10, $debug);
|
||||
}
|
||||
|
||||
$allCablesForMatching = [];
|
||||
$sql = "SELECT id, description, coordinates FROM FiberPlanCable WHERE network_id = 90 AND coordinates IS NOT NULL AND coordinates != '' AND coordinates != '[]'";
|
||||
$res = $db->query($sql);
|
||||
while ($c = $db->fetch_array($res)) {
|
||||
$coords = json_decode($c['coordinates'], true);
|
||||
if ($coords) $allCablesForMatching[] = ['id' => $c['id'], 'description' => $c['description'], 'coordinates' => $coords];
|
||||
}
|
||||
$details['all_cables'] = $allCablesForMatching;
|
||||
}
|
||||
|
||||
$details['debug'] = $debug;
|
||||
return mfBaseController::returnJson(mfResponse::Ok(['fiber' => $details]));
|
||||
}
|
||||
|
||||
protected function getAllFiberPathsForHomeAction()
|
||||
{
|
||||
$db = FronkDB::singleton();
|
||||
$home_id = $this->request->home_id;
|
||||
|
||||
if (!$home_id) {
|
||||
return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Ungültige Home-ID']));
|
||||
}
|
||||
|
||||
$sql = "SELECT id FROM FiberPlanFiber WHERE home_id = '" . $db->escape($home_id) . "'";
|
||||
$res = $db->query($sql);
|
||||
|
||||
$fiberIds = [];
|
||||
if ($db->num_rows($res)) {
|
||||
while ($row = $db->fetch_array($res)) {
|
||||
$fiberIds[] = $row['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($fiberIds)) {
|
||||
return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Keine Fasern für Home-ID gefunden']));
|
||||
}
|
||||
|
||||
$globalBranchPoints = [];
|
||||
$sqlBP = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
|
||||
FROM FiberPlanDispatcher
|
||||
WHERE network_id = 90 AND object_type IN (1,2,3,4)
|
||||
AND gps_lat IS NOT NULL AND gps_long IS NOT NULL";
|
||||
$resBP = $db->query($sqlBP);
|
||||
if ($db->num_rows($resBP)) {
|
||||
while ($data = $db->fetch_array($resBP)) {
|
||||
$globalBranchPoints[] = [
|
||||
'id' => $data['id'],
|
||||
'name' => $data['name'],
|
||||
'gps_lat' => $data['gps_lat'],
|
||||
'gps_long' => $data['gps_long'],
|
||||
'object_type' => intval($data['object_type']),
|
||||
'type' => $data['type']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$globalCablesWithCoords = [];
|
||||
$sqlCables = "SELECT id, description, coordinates
|
||||
FROM FiberPlanCable
|
||||
WHERE network_id = 90
|
||||
AND coordinates IS NOT NULL
|
||||
AND coordinates != ''
|
||||
AND coordinates != '[]'";
|
||||
$resCables = $db->query($sqlCables);
|
||||
while ($cableData = $db->fetch_array($resCables)) {
|
||||
$coords = json_decode($cableData['coordinates'], true);
|
||||
if ($coords && is_array($coords) && count($coords) > 0) {
|
||||
$globalCablesWithCoords[] = [
|
||||
'id' => $cableData['id'],
|
||||
'description' => $cableData['description'],
|
||||
'coordinates' => $coords
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$allPaths = [];
|
||||
|
||||
foreach ($fiberIds as $fiber_id) {
|
||||
$fiber = new FiberPlanFiber($fiber_id);
|
||||
if (!$fiber->id) continue;
|
||||
$details = $fiber->toArray();
|
||||
$details['customer_cable_type'] = $fiber->customer_cable_type;
|
||||
$details['customer_cable_fiber_nr'] = $fiber->customer_cable_fiber_nr;
|
||||
$details['customer_connector_type'] = $fiber->customer_connector_type;
|
||||
$details['customer_cable_spec'] = $fiber->customer_cable_spec;
|
||||
$details['customer_fiber_range'] = $fiber->customer_fiber_range;
|
||||
$details['bundle_nr'] = $fiber->bundle_nr;
|
||||
$details['bundle_color'] = $fiber->bundle_color;
|
||||
$details['bundle_color_hex'] = $fiber->bundle_color_hex;
|
||||
$details['fiber_nr_bundle'] = $fiber->fiber_nr_bundle;
|
||||
|
||||
if ($fiber->address || $fiber->home_id) {
|
||||
$customerGps = $this->geocodeAddress($fiber->address, $fiber->home_id);
|
||||
if ($customerGps) {
|
||||
$details['customer_gps'] = $customerGps;
|
||||
}
|
||||
}
|
||||
|
||||
$debug = [];
|
||||
$debug['start_fiber'] = [
|
||||
'id' => $fiber->id,
|
||||
'fiber_nr_cable' => $fiber->fiber_nr_cable,
|
||||
'branch_type' => $fiber->branch_type
|
||||
];
|
||||
|
||||
$cableChain = $this->buildCompleteCableChain($fiber);
|
||||
$debug['cable_chain_count'] = count($cableChain);
|
||||
|
||||
$debug['cable_chain'] = array_map(function($item) {
|
||||
return [
|
||||
'cable_id' => $item['cable']->id,
|
||||
@@ -333,12 +550,17 @@ class PopController extends mfBaseController
|
||||
$cable_route_array[] = $station['name'];
|
||||
}
|
||||
|
||||
$allFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null);
|
||||
$allMainFibers = FiberPlanFiberModel::getByCableAndSheet($mainCable->id, null);
|
||||
$fibersArray = [];
|
||||
foreach ($allFibers as $f) {
|
||||
foreach ($allMainFibers as $f) {
|
||||
$fibersArray[] = [
|
||||
'id' => $f->id,
|
||||
'fiber_nr_cable' => $f->fiber_nr_cable,
|
||||
'fiber_color' => $f->fiber_color,
|
||||
'fiber_color_hex' => $f->fiber_color_hex,
|
||||
'bundle_nr' => $f->bundle_nr,
|
||||
'bundle_color' => $f->bundle_color,
|
||||
'bundle_color_hex' => $f->bundle_color_hex,
|
||||
'branch_type' => $f->branch_type,
|
||||
'branch_cable_nr' => $f->branch_cable_nr,
|
||||
'branch_from_location' => $f->branch_from_location,
|
||||
@@ -346,26 +568,6 @@ class PopController extends mfBaseController
|
||||
];
|
||||
}
|
||||
|
||||
$branchPoints = [];
|
||||
$sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
|
||||
FROM FiberPlanDispatcher
|
||||
WHERE network_id = 90 AND object_type = 4
|
||||
AND gps_lat IS NOT NULL AND gps_long IS NOT NULL";
|
||||
$res = $db->query($sql);
|
||||
|
||||
if ($db->num_rows($res)) {
|
||||
while ($data = $db->fetch_array($res)) {
|
||||
$branchPoints[] = [
|
||||
'id' => $data['id'],
|
||||
'name' => $data['name'],
|
||||
'gps_lat' => $data['gps_lat'],
|
||||
'gps_long' => $data['gps_long'],
|
||||
'object_type' => intval($data['object_type']),
|
||||
'type' => $data['type']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$details['cable_info'] = [
|
||||
'id' => $mainCable->id,
|
||||
'description' => $mainCable->description,
|
||||
@@ -375,139 +577,54 @@ class PopController extends mfBaseController
|
||||
'cable_route_full' => $cable_route_data,
|
||||
'coordinates' => $mainCable->coordinates,
|
||||
'location' => $mainFiber->location,
|
||||
'branch_points' => $branchPoints
|
||||
'branch_points' => $globalBranchPoints
|
||||
];
|
||||
|
||||
$allCablesForMatching = [];
|
||||
|
||||
foreach ($cableChain as $chainItem) {
|
||||
$cable = $chainItem['cable'];
|
||||
|
||||
$coords = $cable->coordinates;
|
||||
$c = $chainItem['cable'];
|
||||
$coords = $c->coordinates;
|
||||
if (is_string($coords)) {
|
||||
$coords = json_decode($coords, true);
|
||||
}
|
||||
|
||||
if ($coords && is_array($coords) && count($coords) > 0) {
|
||||
$allCablesForMatching[] = [
|
||||
'id' => $cable->id,
|
||||
'description' => $cable->description,
|
||||
'id' => $c->id,
|
||||
'description' => $c->description,
|
||||
'coordinates' => $coords
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$sql = "SELECT id, description, coordinates
|
||||
FROM FiberPlanCable
|
||||
WHERE network_id = 90
|
||||
AND coordinates IS NOT NULL
|
||||
AND coordinates != ''
|
||||
AND coordinates != '[]'";
|
||||
$res = $db->query($sql);
|
||||
|
||||
while ($cableData = $db->fetch_array($res)) {
|
||||
$coords = json_decode($cableData['coordinates'], true);
|
||||
|
||||
if ($coords && is_array($coords) && count($coords) > 0) {
|
||||
$exists = false;
|
||||
foreach ($allCablesForMatching as $existing) {
|
||||
if ($existing['id'] == $cableData['id']) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$exists) {
|
||||
$allCablesForMatching[] = [
|
||||
'id' => $cableData['id'],
|
||||
'description' => $cableData['description'],
|
||||
'coordinates' => $coords
|
||||
];
|
||||
foreach ($globalCablesWithCoords as $gc) {
|
||||
$exists = false;
|
||||
foreach ($allCablesForMatching as $existing) {
|
||||
if ($existing['id'] == $gc['id']) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$exists) {
|
||||
$allCablesForMatching[] = $gc;
|
||||
}
|
||||
}
|
||||
|
||||
$details['all_cables'] = $allCablesForMatching;
|
||||
$this->log->debug("Hausanschluss-Matching: " . count($allCablesForMatching) . " Kabel verfügbar");
|
||||
}
|
||||
|
||||
if (count($cableChain) > 1) {
|
||||
$details['branch_path'] = $this->buildBranchPathFromChain($cableChain, 1, $debug);
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->log->debug("=== MODUS: Vorwärts-Trace (von fiber_id) ===");
|
||||
$details['debug'] = $debug;
|
||||
|
||||
if ($fiber->cable_id) {
|
||||
$cable = new FiberPlanCable($fiber->cable_id);
|
||||
if ($cable->id) {
|
||||
$cable_route_data = FiberPlanCableModel::getCableRoute($cable->id);
|
||||
$cable_route_array = [];
|
||||
foreach ($cable_route_data as $station) {
|
||||
$cable_route_array[] = $station['name'];
|
||||
}
|
||||
|
||||
$branchPoints = [];
|
||||
$sql = "SELECT id, description as name, gps_lat, gps_long, object_type, 'dispatcher' as type
|
||||
FROM FiberPlanDispatcher
|
||||
WHERE network_id = 90 AND object_type = 4
|
||||
AND gps_lat IS NOT NULL AND gps_long IS NOT NULL";
|
||||
$res = $db->query($sql);
|
||||
|
||||
if ($db->num_rows($res)) {
|
||||
while ($data = $db->fetch_array($res)) {
|
||||
$branchPoints[] = [
|
||||
'id' => $data['id'],
|
||||
'name' => $data['name'],
|
||||
'gps_lat' => $data['gps_lat'],
|
||||
'gps_long' => $data['gps_long'],
|
||||
'object_type' => intval($data['object_type']),
|
||||
'type' => $data['type']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$allFibers = FiberPlanFiberModel::getByCableAndSheet($cable->id, null);
|
||||
$fibersArray = [];
|
||||
foreach ($allFibers as $f) {
|
||||
$fibersArray[] = [
|
||||
'id' => $f->id,
|
||||
'fiber_nr_cable' => $f->fiber_nr_cable,
|
||||
'branch_type' => $f->branch_type,
|
||||
'branch_cable_nr' => $f->branch_cable_nr,
|
||||
'branch_from_location' => $f->branch_from_location,
|
||||
'branch_fiber_nr' => $f->branch_fiber_nr
|
||||
];
|
||||
}
|
||||
|
||||
$details['cable_info'] = [
|
||||
'id' => $cable->id,
|
||||
'description' => $cable->description,
|
||||
'fibers' => $fibersArray,
|
||||
'diameter' => $cable->diameter,
|
||||
'cable_route_array' => $cable_route_array,
|
||||
'cable_route_full' => $cable_route_data,
|
||||
'coordinates' => $cable->coordinates,
|
||||
'location' => $fiber->location,
|
||||
'branch_points' => $branchPoints
|
||||
];
|
||||
|
||||
if ($cable->cable_route) {
|
||||
$routeArray = json_decode($cable->cable_route, true);
|
||||
if (is_array($routeArray)) {
|
||||
$details['cable_info']['cable_route_array'] = $routeArray;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($fiber->branch_type === 'Abzweigkabel' && $fiber->branch_cable_nr) {
|
||||
$details['branch_path'] = $this->traceBranchPath($fiber, 0, 10, $debug);
|
||||
}
|
||||
$allPaths[] = [
|
||||
'fiber' => $details
|
||||
];
|
||||
}
|
||||
|
||||
$details['debug'] = $debug;
|
||||
|
||||
return mfBaseController::returnJson(mfResponse::Ok(['fiber' => $details]));
|
||||
return mfBaseController::returnJson(mfResponse::Ok(['paths' => $allPaths]));
|
||||
}
|
||||
|
||||
private function buildCompleteCableChain($endFiber)
|
||||
@@ -530,10 +647,13 @@ class PopController extends mfBaseController
|
||||
]);
|
||||
|
||||
while ($depth < $maxDepth) {
|
||||
$currentFiberNr = intval($currentFiber->fiber_nr_cable);
|
||||
|
||||
$sql = "SELECT * FROM FiberPlanFiber
|
||||
WHERE branch_type = 'Abzweigkabel'
|
||||
AND branch_cable_nr = '" . $db->escape($currentCable->description) . "'
|
||||
LIMIT 1";
|
||||
WHERE branch_type = 'Abzweigkabel'
|
||||
AND branch_cable_nr = '" . $db->escape($currentCable->description) . "'
|
||||
AND branch_fiber_nr = $currentFiberNr
|
||||
LIMIT 1";
|
||||
|
||||
$this->log->debug("Depth $depth: Suche Parent-Faser für Kabel: {$currentCable->description}");
|
||||
|
||||
@@ -1267,6 +1387,9 @@ class PopController extends mfBaseController
|
||||
case "getFiberPath":
|
||||
return $this->getFiberPathAction();
|
||||
break;
|
||||
case "getAllFiberPathsForHome":
|
||||
return $this->getAllFiberPathsForHomeAction();
|
||||
break;
|
||||
case "saveCableFibers":
|
||||
return $this->saveCableFibersAction();
|
||||
break;
|
||||
@@ -1276,6 +1399,9 @@ class PopController extends mfBaseController
|
||||
case "getNetworkMapData":
|
||||
return $this->getNetworkMapDataAction();
|
||||
break;
|
||||
case "getSplicePlanForElement":
|
||||
return $this->getSplicePlanForElementAction();
|
||||
break;
|
||||
default:
|
||||
$return = false;
|
||||
}
|
||||
@@ -1459,7 +1585,7 @@ class PopController extends mfBaseController
|
||||
$cables = [];
|
||||
$cableRes = $db->select(
|
||||
"FiberPlanCable",
|
||||
"id, description, fibers, diameter, state, coordinates",
|
||||
"id, description, fibers, diameter, state, coordinates, level, cable_type, status",
|
||||
"network_id=$network_id"
|
||||
);
|
||||
|
||||
@@ -1491,7 +1617,10 @@ class PopController extends mfBaseController
|
||||
'coordinates' => $convertedCoords,
|
||||
'fibers' => $cableData->fibers,
|
||||
'diameter' => $cableData->diameter,
|
||||
'state' => $cableData->state
|
||||
'state' => $cableData->state,
|
||||
'level' => $cableData->level,
|
||||
'cable_type' => $cableData->cable_type,
|
||||
'status' => $cableData->status
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1648,4 +1777,126 @@ class PopController extends mfBaseController
|
||||
'customerConnections' => $customerConnections
|
||||
]));
|
||||
}
|
||||
|
||||
protected function getSplicePlanForElementAction()
|
||||
{
|
||||
$id = $this->request->id;
|
||||
if (!is_numeric($id)) {
|
||||
return mfBaseController::returnJson(mfResponse::BadRequest(['message' => 'Invalid ID']));
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$dispatcherRes = $db->select("FiberPlanDispatcher", "*", "id=$id");
|
||||
if (!$db->num_rows($dispatcherRes)) {
|
||||
return mfBaseController::returnJson(mfResponse::NotFound(['message' => 'Verteiler nicht gefunden']));
|
||||
}
|
||||
$dispatcher = $db->fetch_object($dispatcherRes);
|
||||
$dispatcherName = $dispatcher->description;
|
||||
|
||||
$cableIds = [];
|
||||
$sql = "SELECT DISTINCT cable_id FROM FiberPlanCableStation WHERE station_type='dispatcher' AND station_id=$id";
|
||||
$res = $db->query($sql);
|
||||
if ($db->num_rows($res)) {
|
||||
while ($row = $db->fetch_array($res)) {
|
||||
$cableIds[] = $row['cable_id'];
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
if (!empty($cableIds)) {
|
||||
$cableIdsStr = implode(',', $cableIds);
|
||||
|
||||
$cableMap = [];
|
||||
$cableRes = $db->select("FiberPlanCable", "id, description", "id IN ($cableIdsStr)");
|
||||
while ($c = $db->fetch_object($cableRes)) {
|
||||
$cableMap[$c->id] = $c->description;
|
||||
}
|
||||
|
||||
$escapedName = $db->escape($dispatcherName);
|
||||
$sqlFibers = "SELECT * FROM FiberPlanFiber WHERE cable_id IN ($cableIdsStr) AND (branch_from_location = '$escapedName' OR location = '$escapedName')";
|
||||
|
||||
$fiberRes = $db->query($sqlFibers);
|
||||
|
||||
$rawFibers = [];
|
||||
$targetCableNames = [];
|
||||
|
||||
while ($fiber = $db->fetch_object($fiberRes)) {
|
||||
$rawFibers[] = $fiber;
|
||||
if ($fiber->branch_cable_nr) {
|
||||
$targetCableNames[$fiber->branch_cable_nr] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$targetColorMap = [];
|
||||
|
||||
if (!empty($targetCableNames)) {
|
||||
$namesList = [];
|
||||
foreach (array_keys($targetCableNames) as $name) {
|
||||
$namesList[] = "'" . $db->escape($name) . "'";
|
||||
}
|
||||
$namesStr = implode(',', $namesList);
|
||||
$targetCablesRes = $db->select("FiberPlanCable", "id, description", "description IN ($namesStr)");
|
||||
$targetCableIds = [];
|
||||
$targetCableIdToName = [];
|
||||
while ($tc = $db->fetch_object($targetCablesRes)) {
|
||||
$targetCableIds[] = $tc->id;
|
||||
$targetCableIdToName[$tc->id] = $tc->description;
|
||||
}
|
||||
|
||||
if (!empty($targetCableIds)) {
|
||||
$tcIdsStr = implode(',', $targetCableIds);
|
||||
$targetFibersRes = $db->select("FiberPlanFiber", "cable_id, fiber_nr_cable, fiber_color, fiber_color_hex", "cable_id IN ($tcIdsStr)");
|
||||
while ($tf = $db->fetch_object($targetFibersRes)) {
|
||||
$cName = $targetCableIdToName[$tf->cable_id] ?? null;
|
||||
if ($cName) {
|
||||
$targetColorMap[$cName][$tf->fiber_nr_cable] = [
|
||||
'color' => $tf->fiber_color,
|
||||
'hex' => $tf->fiber_color_hex
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rawFibers as $fiber) {
|
||||
$targetColorInfo = null;
|
||||
if ($fiber->branch_cable_nr && $fiber->branch_fiber_nr) {
|
||||
$targetColorInfo = $targetColorMap[$fiber->branch_cable_nr][$fiber->branch_fiber_nr] ?? null;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'cable_name' => $cableMap[$fiber->cable_id] ?? 'Unknown',
|
||||
'fiber_nr' => $fiber->fiber_nr_cable,
|
||||
'fiber_color' => $fiber->fiber_color,
|
||||
'fiber_color_hex' => $fiber->fiber_color_hex,
|
||||
'bundle_color' => $fiber->bundle_color,
|
||||
'bundle_color_hex' => $fiber->bundle_color_hex,
|
||||
|
||||
'target_cable' => $fiber->branch_cable_nr,
|
||||
'target_fiber' => $fiber->branch_fiber_nr,
|
||||
'target_fiber_color' => $targetColorInfo['color'] ?? null,
|
||||
'target_fiber_color_hex' => $targetColorInfo['hex'] ?? null,
|
||||
'target_bundle_color' => $fiber->branch_bundle_color,
|
||||
'target_bundle_color_hex' => $fiber->branch_bundle_color_hex,
|
||||
|
||||
'connector' => $fiber->connector_nr,
|
||||
'description' => $fiber->comment,
|
||||
'home_id' => $fiber->home_id,
|
||||
'address' => $fiber->address ?? null,
|
||||
'customer_cable_type' => $fiber->customer_cable_type ?? null,
|
||||
'customer_cable_fiber_nr' => $fiber->customer_cable_fiber_nr ?? null,
|
||||
'customer_connector_type' => $fiber->customer_connector_type ?? null,
|
||||
'customer_cable_spec' => $fiber->customer_cable_spec ?? null,
|
||||
'customer_fiber_range' => $fiber->customer_fiber_range ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return mfBaseController::returnJson(mfResponse::Ok([
|
||||
'dispatcher' => $dispatcher,
|
||||
'connections' => $result
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class WarehouseArticleController extends TTCrud {
|
||||
protected string $headerTitle = 'Artikel';
|
||||
protected $createText = 'Artikel erstellen';
|
||||
protected $createText = false;
|
||||
protected string $singleText = 'Artikel';
|
||||
protected bool $reopenOnCreate = true;
|
||||
|
||||
@@ -12,7 +12,7 @@ class WarehouseArticleController extends TTCrud {
|
||||
['key' => 'articleNumber', 'text' => 'Nr.', 'required' => true],
|
||||
['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]],
|
||||
['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']],
|
||||
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'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' => false],
|
||||
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]],
|
||||
['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false],
|
||||
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
|
||||
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
|
||||
@@ -32,10 +32,13 @@ class WarehouseArticleController extends TTCrud {
|
||||
protected array $autocompleteColumns = ['articleNumber', 'title', 'description'];
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
|
||||
protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']];
|
||||
protected array $additionalActions = [
|
||||
['key' => 'printLabel','title' => 'Label drucken','class' => 'fas fa-print text-secondary'],
|
||||
['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']
|
||||
];
|
||||
// @formatter:on
|
||||
|
||||
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
|
||||
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_PAGE_TITLE' => true];
|
||||
|
||||
protected function prepareCrudConfig() {
|
||||
$categories = array_map(fn($category) => ['value' => $category->id, 'text' => $category->name], WarehouseCategory::getAll());
|
||||
@@ -50,15 +53,19 @@ class WarehouseArticleController extends TTCrud {
|
||||
$this->additionalJSVariables['WAREHOUSE_ADMIN'] = false;
|
||||
}
|
||||
|
||||
protected function beforeCreate() {
|
||||
protected function beforeCreate($postData): bool {
|
||||
if (!in_array($this->user->id, [2, 5, 6, 145, 14]))
|
||||
self::sendError("Sie haben keine Berechtigung, Artikel zu erstellen.");
|
||||
|
||||
$this->validateArticleNumber($postData);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function beforeUpdate($postData): bool {
|
||||
if (!in_array($this->user->id, [2, 5, 6, 145, 14]))
|
||||
self::sendError("Sie haben keine Berechtigung, Artikel zu bearbeiten.");
|
||||
|
||||
$this->validateArticleNumber($postData, $postData['id'] ?? null);
|
||||
(new WarehouseHistoryController)->create($postData, $this->mod);
|
||||
return true;
|
||||
}
|
||||
@@ -81,6 +88,38 @@ class WarehouseArticleController extends TTCrud {
|
||||
self::updateSellPrices($postData['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate article number for duplicates and correct category prefix
|
||||
*/
|
||||
private function validateArticleNumber(array $postData, ?int $excludeId = null): void {
|
||||
$articleNumber = $postData['articleNumber'] ?? '';
|
||||
$categoryId = $postData['category_id'] ?? null;
|
||||
|
||||
if (empty($articleNumber)) {
|
||||
self::sendError("Artikelnummer ist erforderlich.");
|
||||
}
|
||||
|
||||
// Check for duplicate article number
|
||||
$existingArticles = WarehouseArticleModel::getAll(['articleNumber' => $articleNumber]);
|
||||
foreach ($existingArticles as $existing) {
|
||||
if ($excludeId === null || $existing->id != $excludeId) {
|
||||
self::sendError("Artikelnummer '{$articleNumber}' existiert bereits (Artikel ID: {$existing->id}).");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate category prefix
|
||||
if ($categoryId) {
|
||||
$category = WarehouseCategory::get($categoryId);
|
||||
if ($category && $category->articleNumberPrefix) {
|
||||
$expectedPrefix = 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function updateSellPrices(int $id): void { // Added return type hint
|
||||
$a = WarehouseArticleModel::get($id);
|
||||
if (!$a instanceof WarehouseArticleModel) throw new Exception("Invalid article type");
|
||||
@@ -131,6 +170,41 @@ class WarehouseArticleController extends TTCrud {
|
||||
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
|
||||
}
|
||||
|
||||
protected function getNextArticleNumberAction() {
|
||||
$categoryId = intval($this->request->categoryId ?? 0);
|
||||
if (!$categoryId) self::sendError("Kategorie nicht angegeben");
|
||||
|
||||
$category = WarehouseCategory::get($categoryId);
|
||||
if (!$category) self::sendError("Kategorie nicht gefunden");
|
||||
if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix");
|
||||
|
||||
$prefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
// Get all existing article numbers with this prefix, sorted
|
||||
$result = $db->query("SELECT CAST(articleNumber AS UNSIGNED) as num FROM WarehouseArticle WHERE articleNumber LIKE '{$prefix}%' ORDER BY num ASC");
|
||||
$existingNumbers = [];
|
||||
while ($row = $db->fetch_array($result)) {
|
||||
$existingNumbers[] = intval($row['num']);
|
||||
}
|
||||
|
||||
// Start from prefix * 10000 + 1 (e.g., 1800 -> 18000001)
|
||||
$startNumber = intval($prefix) * 10000 + 1;
|
||||
$nextNumber = $startNumber;
|
||||
|
||||
// Find first gap
|
||||
foreach ($existingNumbers as $num) {
|
||||
if ($num == $nextNumber) {
|
||||
$nextNumber++;
|
||||
} else if ($num > $nextNumber) {
|
||||
// Found a gap
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'articleNumber' => str_pad($nextNumber, 8, '0', STR_PAD_LEFT)]);
|
||||
}
|
||||
|
||||
protected function autocompleteAction() {
|
||||
$textKey = property_exists($this->model, 'name') ? 'name' : 'title';
|
||||
if (strlen($this->request->searchedID) > 0) {
|
||||
@@ -163,4 +237,55 @@ class WarehouseArticleController extends TTCrud {
|
||||
return ['value' => $item->id, 'text' => $item->$textKey];
|
||||
}, $data));
|
||||
}
|
||||
|
||||
protected function printLabelAction() {
|
||||
$articleId = $this->request->id;
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::sendError("Artikel nicht gefunden", 404);
|
||||
}
|
||||
|
||||
$pdf_vars = [
|
||||
'articleId' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title
|
||||
];
|
||||
|
||||
$pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars);
|
||||
$wkhtmltopdfArgs = "--page-height 25mm --page-width 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);
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="label-' . $article->articleNumber . '.pdf"');
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ class WarehouseCategory extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public string $name;
|
||||
public string $description;
|
||||
public ?int $articleNumberPrefix;
|
||||
public ?string $articleNumberPrefix;
|
||||
public int $create;
|
||||
public int $create_by;
|
||||
public ?int $edit;
|
||||
|
||||
@@ -9,20 +9,86 @@ class WarehouseCategoryController extends TTCrud {
|
||||
protected array $columns = [
|
||||
['key' => 'name', 'text' => 'Name', 'required' => true,],
|
||||
['key' => 'description', 'text' => 'Beschreibung', 'required' => true],
|
||||
['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => true],
|
||||
['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']],
|
||||
['key' => 'create', 'text' => 'Erstellt am', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
|
||||
['key' => 'create_by', 'text' => 'Erstellt von', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]],
|
||||
];
|
||||
// @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();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function beforeUpdate($postData): bool {
|
||||
// Preserve existing prefix - don't allow changes
|
||||
$existing = WarehouseCategory::get($postData['id']);
|
||||
if ($existing) {
|
||||
$this->postData['articleNumberPrefix'] = $existing->articleNumberPrefix;
|
||||
}
|
||||
(new WarehouseHistoryController)->create($postData, $this->mod);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getNextFreePrefix(): string {
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
|
||||
$row = $db->fetch_array($result);
|
||||
|
||||
if ($row && $row['articleNumberPrefix']) {
|
||||
$lastPrefix = intval($row['articleNumberPrefix']);
|
||||
// Skip special ranges (9900+)
|
||||
if ($lastPrefix >= 9900) {
|
||||
// Find highest non-special prefix
|
||||
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL AND CAST(articleNumberPrefix AS UNSIGNED) < 9900 ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
|
||||
$row = $db->fetch_array($result);
|
||||
$lastPrefix = $row ? intval($row['articleNumberPrefix']) : 1800;
|
||||
}
|
||||
$nextPrefix = $lastPrefix + 100;
|
||||
// Skip 9900+ range
|
||||
if ($nextPrefix >= 9900) $nextPrefix = 9900;
|
||||
} else {
|
||||
$nextPrefix = 1900;
|
||||
}
|
||||
|
||||
return str_pad($nextPrefix, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
protected function getHistoryAction() {
|
||||
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class WarehouseLocationModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public string $title;
|
||||
public string $description;
|
||||
public ?string $description = null;
|
||||
public int $assignedTo;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<?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) {
|
||||
// Format movement type with badge
|
||||
$typeLabels = [
|
||||
'IN' => '<span class="badge bg-success">Einbuchung</span>',
|
||||
'OUT' => '<span class="badge bg-danger">Ausbuchung</span>',
|
||||
'ADJUSTMENT' => '<span class="badge bg-warning">Korrektur</span>',
|
||||
];
|
||||
$row['movementType'] = $typeLabels[$row['movementType']] ?? $row['movementType'];
|
||||
|
||||
// Format article
|
||||
if (!empty($row['articleId'])) {
|
||||
$article = ArticleModel::get($row['articleId']);
|
||||
if ($article) {
|
||||
$row['articleId'] = "<strong>{$article->articleNumber}</strong><br><small class='text-muted'>{$article->title}</small>";
|
||||
}
|
||||
}
|
||||
|
||||
// Format quantities
|
||||
$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, ',', '.');
|
||||
|
||||
// Format reason category
|
||||
$row['reasonCategory'] = WarehouseMovementModel::getReasonCategories()[$row['movementType']][$row['reasonCategory']] ?? $row['reasonCategory'];
|
||||
|
||||
// Format create date
|
||||
if (!empty($row['create'])) {
|
||||
$row['create'] = date('d.m.Y H:i', $row['create']);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?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 $userId;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
/**
|
||||
* Generate next movement number (WM-YYYY-X000001)
|
||||
*/
|
||||
public static function generateMovementNumber(): string {
|
||||
$year = date('Y');
|
||||
$prefix = "WM-{$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'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article object
|
||||
*/
|
||||
public function getArticle(): ?ArticleModel {
|
||||
return ArticleModel::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 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,
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeController extends TTCrud {
|
||||
protected string $headerTitle = 'Inventur';
|
||||
protected string $createText = 'Inventur erstellen';
|
||||
protected bool $reopenOnCreate = false;
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'stocktakeNumber', 'text' => 'Inventur-Nr.', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 10]],
|
||||
['key' => 'title', 'text' => 'Titel', 'required' => true,
|
||||
'modal' => ['type' => 'text'],
|
||||
'table' => ['priority' => 9]],
|
||||
['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true,
|
||||
'modal' => ['type' => 'select', 'items' => []],
|
||||
'table' => ['priority' => 8, 'filter' => 'select']],
|
||||
['key' => 'status', 'text' => 'Status', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 7, 'filter' => 'iconSelect', 'filterOptions' => [
|
||||
['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary'],
|
||||
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success'],
|
||||
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger'],
|
||||
]]],
|
||||
['key' => 'progress', 'text' => 'Fortschritt', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 6, 'sortable' => false, 'filter' => false]],
|
||||
['key' => 'startedAt', 'text' => 'Gestartet', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 5, 'filter' => false]],
|
||||
['key' => 'description', 'text' => 'Beschreibung', 'required' => false,
|
||||
'modal' => ['type' => 'textarea'],
|
||||
'table' => false],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
|
||||
];
|
||||
|
||||
protected array $additionalActions = [
|
||||
['key' => 'startStocktake', 'title' => 'Inventur starten', 'class' => 'fas fa-play text-success'],
|
||||
['key' => 'viewProgress', 'title' => 'Fortschritt anzeigen', 'class' => 'fas fa-chart-line text-primary'],
|
||||
['key' => 'completeStocktake', 'title' => 'Inventur abschließen', 'class' => 'fas fa-check text-success'],
|
||||
['key' => 'applyToStock', 'title' => 'Auf Lager anwenden', 'class' => 'fas fa-boxes text-warning'],
|
||||
['key' => 'exportReport', 'title' => 'Excel Export', 'class' => 'fas fa-download text-secondary'],
|
||||
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-secondary'],
|
||||
];
|
||||
|
||||
protected array $additionalJSVariables = [];
|
||||
|
||||
protected array $statusOptions = [
|
||||
['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary', 'color' => 'secondary'],
|
||||
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary', 'color' => 'primary'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success', 'color' => 'success'],
|
||||
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger', 'color' => 'danger'],
|
||||
];
|
||||
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
|
||||
protected array $infoMessages = [
|
||||
'create' => 'Inventur wurde erstellt',
|
||||
'update' => 'Inventur wurde aktualisiert',
|
||||
'delete' => 'Inventur wurde gelöscht',
|
||||
'noChanges' => 'Keine Änderungen',
|
||||
];
|
||||
|
||||
public function prepareCrudConfig() {
|
||||
// Populate locations dropdown
|
||||
$locations = array_map(function($location) {
|
||||
return ['value' => $location->id, 'text' => $location->title];
|
||||
}, WarehouseLocationModel::getAll());
|
||||
|
||||
foreach ($this->columns as &$col) {
|
||||
if ($col['key'] === 'warehouseLocationId') {
|
||||
$col['modal']['items'] = $locations;
|
||||
$col['table']['filterOptions'] = $locations;
|
||||
}
|
||||
}
|
||||
|
||||
$this->additionalJSVariables['STATUS_ITEMS'] = $this->statusOptions;
|
||||
}
|
||||
|
||||
protected function beforeCreate(): bool {
|
||||
// Set default values
|
||||
$this->postData['status'] = 'planned';
|
||||
$this->postData['totalItems'] = 0;
|
||||
$this->postData['totalScannedItems'] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function afterCreate($postData) {
|
||||
// Generate stocktake number
|
||||
$stocktake = WarehouseStocktakeModel::get($postData['id']);
|
||||
if ($stocktake) {
|
||||
$stocktakeNumber = WarehouseStocktakeModel::generateStocktakeNumber();
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("UPDATE WarehouseStocktake SET stocktakeNumber = '{$stocktakeNumber}' WHERE id = {$stocktake->id}");
|
||||
|
||||
// Log creation
|
||||
WarehouseStocktakeLogModel::log($stocktake->id, 'created', null, ['title' => $stocktake->title]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function beforeUpdate($postData): bool {
|
||||
(new WarehouseHistoryController)->create($postData, $this->mod);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function customRowsHandler($rows) {
|
||||
return array_map(fn($row) => $this->formatRow((array)$row), $rows);
|
||||
}
|
||||
|
||||
protected function formatRow($row) {
|
||||
// Keep raw status for frontend conditional logic (don't modify 'status' - table needs raw value for filter)
|
||||
$row['rawStatus'] = $row['status'];
|
||||
|
||||
// Don't modify warehouseLocationId - table uses items to display the text
|
||||
// Don't modify status - table uses filterOptions to display
|
||||
|
||||
// Format progress (no filter on this column)
|
||||
$row['progress'] = "<span class='badge bg-info'>{$row['totalScannedItems']} Artikel gescannt</span>";
|
||||
|
||||
// Format startedAt (no filter on this column)
|
||||
if ($row['startedAt']) {
|
||||
$row['startedAt'] = date('d.m.Y H:i', $row['startedAt']);
|
||||
} else {
|
||||
$row['startedAt'] = '-';
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a stocktake - changes status to in_progress
|
||||
*/
|
||||
protected function startStocktakeAction() {
|
||||
$id = intval($this->postData['id'] ?? 0);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'planned') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "Geplant" gestartet werden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("UPDATE WarehouseStocktake SET
|
||||
status = 'in_progress',
|
||||
startedAt = " . time() . ",
|
||||
startedBy = {$this->user->id}
|
||||
WHERE id = {$id}");
|
||||
|
||||
WarehouseStocktakeLogModel::log($id, 'started', null, ['startedBy' => $this->user->name]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Inventur wurde gestartet']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a stocktake - changes status to completed
|
||||
*/
|
||||
protected function completeStocktakeAction() {
|
||||
$id = intval($this->postData['id'] ?? 0);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'in_progress') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "In Bearbeitung" abgeschlossen werden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("UPDATE WarehouseStocktake SET
|
||||
status = 'completed',
|
||||
completedAt = " . time() . ",
|
||||
completedBy = {$this->user->id}
|
||||
WHERE id = {$id}");
|
||||
|
||||
WarehouseStocktakeLogModel::log($id, 'completed', null, ['completedBy' => $this->user->name]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Inventur wurde abgeschlossen']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress data for live updates
|
||||
*/
|
||||
protected function getProgressAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get items via direct SQL to avoid any ORM issues
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName,
|
||||
CASE WHEN si.overwrittenById IS NOT NULL THEN 1 ELSE 0 END as isOverwritten
|
||||
FROM WarehouseStocktakeItem si
|
||||
LEFT JOIN WarehouseArticle a ON si.articleId = a.id
|
||||
LEFT JOIN Worker w ON si.scannedBy = w.id
|
||||
WHERE si.stocktakeId = {$id}
|
||||
ORDER BY si.`create` DESC");
|
||||
|
||||
$formattedItems = [];
|
||||
$totalValue = 0;
|
||||
$totalQuantity = 0;
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0;
|
||||
$quantity = (float)$row['countedQuantity'];
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
$isOverwritten = (bool)$row['isOverwritten'];
|
||||
|
||||
// Only count non-overwritten items in totals
|
||||
if (!$isOverwritten) {
|
||||
$totalValue += $lineTotal;
|
||||
$totalQuantity += $quantity;
|
||||
}
|
||||
|
||||
$formattedItems[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'articleId' => (int)$row['articleId'],
|
||||
'articleNumber' => $row['articleNumber'] ?? '',
|
||||
'articleTitle' => $row['articleTitle'] ?? 'Unbekannt',
|
||||
'countedQuantity' => $quantity,
|
||||
'unitPrice' => $unitPrice,
|
||||
'lineTotal' => $lineTotal,
|
||||
'rack' => $row['rack'],
|
||||
'shelf' => $row['shelf'],
|
||||
'note' => $row['note'],
|
||||
'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null,
|
||||
'scannedBy' => $row['scannedByName'],
|
||||
'isOverwritten' => $isOverwritten,
|
||||
];
|
||||
}
|
||||
|
||||
$location = $stocktake->getLocation();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'stocktake' => [
|
||||
'id' => $stocktake->id,
|
||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
||||
'title' => $stocktake->title,
|
||||
'status' => $stocktake->status,
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
||||
],
|
||||
'items' => $formattedItems,
|
||||
'summary' => [
|
||||
'totalValue' => $totalValue,
|
||||
'totalQuantity' => $totalQuantity,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stocktake results to actual warehouse stock
|
||||
*/
|
||||
protected function applyToStockAction() {
|
||||
$id = intval($this->postData['id'] ?? 0);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'completed') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur muss abgeschlossen sein, um die Bestände anzupassen']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]);
|
||||
$appliedCount = 0;
|
||||
$createdCount = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
// Check if a WarehouseItem already exists for this article at this location
|
||||
$existingItems = WarehouseItemModel::getAll([
|
||||
'articleId' => $item->articleId,
|
||||
'warehouseLocationId' => $stocktake->warehouseLocationId
|
||||
]);
|
||||
|
||||
if (count($existingItems) > 0) {
|
||||
// Update existing item
|
||||
$existingItem = $existingItems[0];
|
||||
$oldQuantity = $existingItem->quantity;
|
||||
|
||||
$db->query("UPDATE WarehouseItem SET
|
||||
quantity = {$item->countedQuantity},
|
||||
rack = " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ",
|
||||
shelf = " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . "
|
||||
WHERE id = {$existingItem->id}");
|
||||
|
||||
// Log history
|
||||
(new WarehouseHistoryController)->create([
|
||||
'id' => $existingItem->id,
|
||||
'quantity' => $item->countedQuantity,
|
||||
'rack' => $item->rack,
|
||||
'shelf' => $item->shelf,
|
||||
], 'WarehouseItem');
|
||||
|
||||
$appliedCount++;
|
||||
} else {
|
||||
// Create new WarehouseItem
|
||||
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, rack, shelf, createBy, `create`)
|
||||
VALUES ({$item->articleId}, {$stocktake->warehouseLocationId}, {$item->countedQuantity},
|
||||
" . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ",
|
||||
" . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . ",
|
||||
{$this->user->id}, " . time() . ")");
|
||||
|
||||
$createdCount++;
|
||||
}
|
||||
}
|
||||
|
||||
WarehouseStocktakeLogModel::log($id, 'applied_to_stock', null, [
|
||||
'appliedCount' => $appliedCount,
|
||||
'createdCount' => $createdCount,
|
||||
'appliedBy' => $this->user->name
|
||||
]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => "Bestände angepasst: {$appliedCount} aktualisiert, {$createdCount} neu erstellt"
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export stocktake report to Excel
|
||||
*/
|
||||
protected function exportReportAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get items via direct SQL to include price and overwritten status
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, a.cheapestPurchasePrice, w.name as scannedByName
|
||||
FROM WarehouseStocktakeItem si
|
||||
LEFT JOIN WarehouseArticle a ON si.articleId = a.id
|
||||
LEFT JOIN Worker w ON si.scannedBy = w.id
|
||||
WHERE si.stocktakeId = {$id}
|
||||
ORDER BY si.`create` ASC");
|
||||
|
||||
$rows = [];
|
||||
$totalSum = 0;
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$unitPrice = $row['cheapestPurchasePrice'] ? (float)$row['cheapestPurchasePrice'] : 0;
|
||||
$quantity = (float)$row['countedQuantity'];
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
$isOverwritten = !empty($row['overwrittenById']);
|
||||
|
||||
// Skip overwritten items in calculation but show them
|
||||
if (!$isOverwritten) {
|
||||
$totalSum += $lineTotal;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'Artikel Titel' => $row['articleTitle'] ?? 'Unbekannt',
|
||||
'Artikel Nummer' => $row['articleNumber'] ?? '',
|
||||
'Einzelpreis' => number_format($unitPrice, 2, ',', '.') . ' €',
|
||||
'Anzahl' => $quantity,
|
||||
'Gesamtsumme' => number_format($lineTotal, 2, ',', '.') . ' €',
|
||||
'Gescannt am' => $row['scannedAt'] ? date('d.m.Y H:i', $row['scannedAt']) : '',
|
||||
'Gescannt von' => $row['scannedByName'] ?? '',
|
||||
'Status' => $isOverwritten ? 'Überschrieben' : '',
|
||||
];
|
||||
}
|
||||
|
||||
// Add summary row
|
||||
$rows[] = [
|
||||
'Artikel Titel' => '',
|
||||
'Artikel Nummer' => '',
|
||||
'Einzelpreis' => '',
|
||||
'Anzahl' => 'SUMME:',
|
||||
'Gesamtsumme' => number_format($totalSum, 2, ',', '.') . ' €',
|
||||
'Gescannt am' => '',
|
||||
'Gescannt von' => '',
|
||||
'Status' => '',
|
||||
];
|
||||
|
||||
$filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv";
|
||||
$csv = Helper::arrayToCsv($rows);
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
echo "\xEF\xBB\xBF"; // UTF-8 BOM
|
||||
echo $csv;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history for a stocktake
|
||||
*/
|
||||
protected function getHistoryAction() {
|
||||
$this->prepareCrudConfig();
|
||||
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a stocktake
|
||||
*/
|
||||
protected function getLogsAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$logs = WarehouseStocktakeLogModel::getLogsForStocktake($id);
|
||||
$formattedLogs = [];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$user = UserModel::get($log->userId);
|
||||
$formattedLogs[] = [
|
||||
'id' => $log->id,
|
||||
'action' => $log->action,
|
||||
'details' => $log->details ? json_decode($log->details, true) : null,
|
||||
'userName' => $user ? $user->name : 'Unbekannt',
|
||||
'create' => date('d.m.Y H:i:s', $log->create),
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'logs' => $formattedLogs]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?string $stocktakeNumber = null;
|
||||
public string $title;
|
||||
public ?string $description = null;
|
||||
public int $warehouseLocationId;
|
||||
public string $status = 'planned';
|
||||
public ?int $startedAt = null;
|
||||
public ?int $completedAt = null;
|
||||
public ?int $startedBy = null;
|
||||
public ?int $completedBy = null;
|
||||
public int $totalItems = 0;
|
||||
public int $totalScannedItems = 0;
|
||||
public ?string $notes = null;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
/**
|
||||
* Generate next stocktake number (ST-YYYY-NNNN)
|
||||
*/
|
||||
public static function generateStocktakeNumber(): string {
|
||||
$year = date('Y');
|
||||
$prefix = "IN{$year}-X";
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT stocktakeNumber FROM WarehouseStocktake
|
||||
WHERE stocktakeNumber LIKE '{$prefix}%'
|
||||
ORDER BY stocktakeNumber DESC LIMIT 1");
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
$lastNumber = intval(substr($row['stocktakeNumber'], -6));
|
||||
$nextNumber = $lastNumber + 1;
|
||||
} else {
|
||||
$nextNumber = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location object
|
||||
*/
|
||||
public function getLocation(): ?WarehouseLocationModel {
|
||||
return WarehouseLocationModel::get($this->warehouseLocationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who started the stocktake
|
||||
*/
|
||||
public function getStartedByUser(): ?UserModel {
|
||||
if (!$this->startedBy) return null;
|
||||
return UserModel::get($this->startedBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get items for this stocktake
|
||||
*/
|
||||
public function getItems(): array {
|
||||
return WarehouseStocktakeItemModel::getAll(['stocktakeId' => $this->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress counters
|
||||
*/
|
||||
public function updateProgress(): void {
|
||||
$items = $this->getItems();
|
||||
$this->totalScannedItems = count($items);
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("UPDATE WarehouseStocktake SET totalScannedItems = {$this->totalScannedItems} WHERE id = {$this->id}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeItemController extends TTCrud {
|
||||
protected string $headerTitle = 'Inventur-Artikel';
|
||||
protected string $createText = 'Artikel hinzufügen';
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'articleId', 'text' => 'Artikel', 'required' => true,
|
||||
'modal' => ['type' => 'autocomplete', 'apiUrl' => '/WarehouseArticle/autocomplete'],
|
||||
'table' => ['priority' => 10]],
|
||||
['key' => 'countedQuantity', 'text' => 'Menge', 'required' => true,
|
||||
'modal' => ['type' => 'number'],
|
||||
'table' => ['priority' => 9]],
|
||||
['key' => 'rack', 'text' => 'Regal', 'required' => false,
|
||||
'modal' => ['type' => 'text'],
|
||||
'table' => ['priority' => 8]],
|
||||
['key' => 'shelf', 'text' => 'Fach', 'required' => false,
|
||||
'modal' => ['type' => 'text'],
|
||||
'table' => ['priority' => 7]],
|
||||
['key' => 'note', 'text' => 'Notiz', 'required' => false,
|
||||
'modal' => ['type' => 'textarea'],
|
||||
'table' => ['priority' => 6]],
|
||||
['key' => 'scannedAt', 'text' => 'Gescannt am', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['priority' => 5]],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false,
|
||||
'modal' => false,
|
||||
'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
|
||||
protected function formatRow($row) {
|
||||
// Format article
|
||||
if ($row['articleId']) {
|
||||
$article = WarehouseArticleModel::get($row['articleId']);
|
||||
$row['articleId'] = $article ? "[{$article->articleNumber}] {$article->title}" : 'Unbekannt';
|
||||
}
|
||||
|
||||
// Format scannedAt
|
||||
if ($row['scannedAt']) {
|
||||
$row['scannedAt'] = date('d.m.Y H:i', $row['scannedAt']);
|
||||
} else {
|
||||
$row['scannedAt'] = '-';
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item via scan (used by PWA)
|
||||
*/
|
||||
protected function scanItemAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
$articleId = intval($this->request->articleId);
|
||||
$quantity = floatval($this->request->quantity);
|
||||
$rack = $this->request->rack ?? null;
|
||||
$shelf = $this->request->shelf ?? null;
|
||||
$note = $this->request->note ?? null;
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify stocktake exists and is in progress
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'in_progress') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify article exists
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this article was already scanned in this stocktake
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId
|
||||
]);
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
if ($existing) {
|
||||
// Update existing entry - add to quantity
|
||||
$newQuantity = $existing->countedQuantity + $quantity;
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET
|
||||
countedQuantity = {$newQuantity},
|
||||
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
|
||||
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
|
||||
scannedAt = " . time() . ",
|
||||
scannedBy = {$this->me->id}
|
||||
WHERE id = {$existing->id}");
|
||||
|
||||
$itemId = $existing->id;
|
||||
$message = "Artikel aktualisiert: {$article->title} (Neue Menge: {$newQuantity})";
|
||||
} else {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->me->id}, {$this->me->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
$message = "Artikel hinzugefügt: {$article->title} (Menge: {$quantity})";
|
||||
}
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
// Log the scan
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $existing ? ($existing->countedQuantity + $quantity) : $quantity,
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
],
|
||||
'totalScanned' => $stocktake->totalScannedItems + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article info by QR code or article number
|
||||
*/
|
||||
protected function getArticleByCodeAction() {
|
||||
$code = $this->request->code;
|
||||
|
||||
if (!$code) {
|
||||
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
|
||||
// Also accept WH: for backwards compatibility
|
||||
$articleId = null;
|
||||
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
||||
$articleId = intval($matches[1]);
|
||||
} else {
|
||||
// Try to find by article number
|
||||
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
|
||||
if ($article) {
|
||||
$articleId = $article->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'title' => $article->title,
|
||||
'description' => $article->description ?? '',
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeItemModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $stocktakeId;
|
||||
public int $articleId;
|
||||
public ?int $warehouseItemId;
|
||||
public float $countedQuantity;
|
||||
public ?string $rack;
|
||||
public ?string $shelf;
|
||||
public ?string $note;
|
||||
public ?int $scannedAt;
|
||||
public ?int $scannedBy;
|
||||
public ?int $overwrittenById;
|
||||
public int $createBy;
|
||||
public int $create;
|
||||
|
||||
/**
|
||||
* Get the article object
|
||||
*/
|
||||
public function getArticle(): ?WarehouseArticleModel {
|
||||
return WarehouseArticleModel::get($this->articleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stocktake object
|
||||
*/
|
||||
public function getStocktake(): ?WarehouseStocktakeModel {
|
||||
return WarehouseStocktakeModel::get($this->stocktakeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who scanned this item
|
||||
*/
|
||||
public function getScannedByUser(): ?User {
|
||||
if (!$this->scannedBy) return null;
|
||||
return UserModel::getOne($this->scannedBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakeLogModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $stocktakeId;
|
||||
public ?int $stocktakeItemId;
|
||||
public string $action;
|
||||
public ?string $details;
|
||||
public int $userId;
|
||||
public int $create;
|
||||
|
||||
/**
|
||||
* Create a log entry
|
||||
*/
|
||||
public static function log(int $stocktakeId, string $action, ?int $stocktakeItemId = null, ?array $details = null, ?int $userId = null): self {
|
||||
$me = mfValuecache::singleton()->get("me");
|
||||
$logUserId = $userId ?? ($me ? $me->id : 0);
|
||||
|
||||
$log = new self();
|
||||
$log->stocktakeId = $stocktakeId;
|
||||
$log->stocktakeItemId = $stocktakeItemId;
|
||||
$log->action = $action;
|
||||
$log->details = $details ? json_encode($details) : null;
|
||||
$log->userId = $logUserId;
|
||||
$log->create = time();
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$db->query("INSERT INTO WarehouseStocktakeLog (stocktakeId, stocktakeItemId, action, details, userId, `create`)
|
||||
VALUES ({$log->stocktakeId}, " . ($log->stocktakeItemId ? $log->stocktakeItemId : "NULL") . ",
|
||||
'{$db->escape($log->action)}', " . ($log->details ? "'{$db->escape($log->details)}'" : "NULL") . ",
|
||||
{$log->userId}, {$log->create})");
|
||||
|
||||
$log->id = $db->insert_id;
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a stocktake
|
||||
*/
|
||||
public static function getLogsForStocktake(int $stocktakeId): array {
|
||||
return self::getAll(['stocktakeId' => $stocktakeId], 0, 0, ['create' => 'DESC']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
class WarehouseStocktakePWAController extends mfBaseController {
|
||||
|
||||
protected $user;
|
||||
|
||||
protected function init() {
|
||||
$this->needlogin = true;
|
||||
|
||||
$me = mfValuecache::singleton()->get("me");
|
||||
if (!$me) {
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
mfValuecache::singleton()->set("me", $me);
|
||||
}
|
||||
$this->me = $me;
|
||||
$this->user = $me;
|
||||
$this->layout()->set("me", $me);
|
||||
|
||||
// Check permission
|
||||
if (!$me->can('WarehouseUser')) {
|
||||
$this->redirect("Dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main PWA View
|
||||
*/
|
||||
public function indexAction() {
|
||||
$this->layout()->setTemplate("VueViews/WarehouseStocktakePWA");
|
||||
$this->layout()->set("JSGlobals", [
|
||||
'BASE_PATH' => '/WarehouseStocktakePWA',
|
||||
'USER_ID' => $this->user->id,
|
||||
'USER_NAME' => $this->user->name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
protected function logoutAction() {
|
||||
mfLoginController::staticLogout();
|
||||
$this->redirect('/WarehouseStocktakePWA');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active stocktakes that user can participate in
|
||||
*/
|
||||
protected function getActiveStocktakesAction() {
|
||||
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
|
||||
|
||||
$result = [];
|
||||
foreach ($stocktakes as $stocktake) {
|
||||
$location = $stocktake->getLocation();
|
||||
$result[] = [
|
||||
'id' => $stocktake->id,
|
||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
||||
'title' => $stocktake->title,
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'stocktakes' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stocktake details
|
||||
*/
|
||||
protected function getStocktakeAction() {
|
||||
$id = intval($this->request->id);
|
||||
if (!$id) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($id);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$location = $stocktake->getLocation();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'stocktake' => [
|
||||
'id' => $stocktake->id,
|
||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
||||
'title' => $stocktake->title,
|
||||
'status' => $stocktake->status,
|
||||
'locationId' => $stocktake->warehouseLocationId,
|
||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get article by QR code or article number
|
||||
*/
|
||||
protected function getArticleAction() {
|
||||
$code = $this->request->code;
|
||||
|
||||
if (!$code) {
|
||||
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = null;
|
||||
|
||||
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
|
||||
// Also accept WH: for backwards compatibility
|
||||
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
||||
$articleId = intval($matches[1]);
|
||||
} else {
|
||||
// Try to find by article number
|
||||
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
|
||||
if ($article) {
|
||||
$articleId = $article->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get category name
|
||||
$category = WarehouseCategory::get($article->category_id);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'article' => [
|
||||
'id' => $article->id,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'title' => $article->title,
|
||||
'description' => $article->description ?? '',
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'categoryName' => $category ? $category->name : '',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search articles by text with optional category filter
|
||||
*/
|
||||
protected function searchArticlesAction() {
|
||||
$query = $this->request->query ?? '';
|
||||
$categoryId = intval($this->request->categoryId ?? 0);
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
|
||||
|
||||
if ($query && strlen($query) >= 2) {
|
||||
$escapedQuery = $db->escape($query);
|
||||
$conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
|
||||
}
|
||||
|
||||
if ($categoryId > 0) {
|
||||
$conditions[] = "category_id = {$categoryId}";
|
||||
}
|
||||
|
||||
if (count($conditions) === 1 && !$categoryId) {
|
||||
self::returnJson(['success' => true, 'articles' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $conditions);
|
||||
$result = $db->query("SELECT id, articleNumber, title, unit, category_id
|
||||
FROM WarehouseArticle
|
||||
WHERE {$whereClause}
|
||||
ORDER BY title ASC
|
||||
LIMIT 50");
|
||||
|
||||
$articles = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$articles[] = [
|
||||
'id' => intval($row['id']),
|
||||
'articleNumber' => $row['articleNumber'],
|
||||
'title' => $row['title'],
|
||||
'unit' => $row['unit'] ?? 'Stk.',
|
||||
'categoryId' => intval($row['category_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'articles' => $articles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories for browsing
|
||||
*/
|
||||
protected function getCategoriesAction() {
|
||||
$db = FronkDB::singleton();
|
||||
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
|
||||
|
||||
$categories = [];
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$categories[] = [
|
||||
'id' => intval($row['id']),
|
||||
'name' => $row['name'],
|
||||
];
|
||||
}
|
||||
self::returnJson(['success' => true, 'categories' => $categories]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if article is already scanned in stocktake
|
||||
*/
|
||||
protected function checkAlreadyScannedAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
$articleId = intval($this->request->articleId);
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$db = FronkDB::singleton();
|
||||
$scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
|
||||
$scannedByRow = $scannedByResult->fetch_assoc();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'alreadyScanned' => true,
|
||||
'existingItem' => [
|
||||
'id' => $existing->id,
|
||||
'countedQuantity' => $existing->countedQuantity,
|
||||
'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
|
||||
'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
self::returnJson(['success' => true, 'alreadyScanned' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a scanned item
|
||||
*/
|
||||
protected function submitScanAction() {
|
||||
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
$stocktakeId = intval($postData['stocktakeId'] ?? 0);
|
||||
$articleId = intval($postData['articleId'] ?? 0);
|
||||
$quantity = floatval($postData['quantity'] ?? 0);
|
||||
$rack = $postData['rack'] ?? null;
|
||||
$shelf = $postData['shelf'] ?? null;
|
||||
$note = $postData['note'] ?? null;
|
||||
$overwrite = boolval($postData['overwrite'] ?? false);
|
||||
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
|
||||
|
||||
if (!$stocktakeId || !$articleId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($quantity <= 0) {
|
||||
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify stocktake exists and is in progress
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($stocktake->status !== 'in_progress') {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify article exists
|
||||
$article = WarehouseArticleModel::get($articleId);
|
||||
if (!$article) {
|
||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
// If overwrite mode is enabled, mark existing item as overwritten
|
||||
if ($overwrite && $overwriteItemId) {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
|
||||
// Mark old item as overwritten by new item
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
|
||||
|
||||
$finalQuantity = $quantity;
|
||||
$isOverwrite = true;
|
||||
|
||||
// Log the overwrite
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'overwrittenItemId' => $overwriteItemId,
|
||||
]);
|
||||
|
||||
// Update stocktake progress (don't increase count since we're replacing)
|
||||
$stocktake->updateProgress();
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isOverwrite' => true,
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this article was already scanned in this stocktake (non-overwritten)
|
||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
||||
'stocktakeId' => $stocktakeId,
|
||||
'articleId' => $articleId,
|
||||
'overwrittenById' => null
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
// Update existing entry - add to quantity
|
||||
$newQuantity = $existing->countedQuantity + $quantity;
|
||||
$db->query("UPDATE WarehouseStocktakeItem SET
|
||||
countedQuantity = {$newQuantity},
|
||||
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
|
||||
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
|
||||
scannedAt = " . time() . ",
|
||||
scannedBy = {$this->user->id}
|
||||
WHERE id = {$existing->id}");
|
||||
|
||||
$itemId = $existing->id;
|
||||
$finalQuantity = $newQuantity;
|
||||
$isUpdate = true;
|
||||
} else {
|
||||
// Create new entry
|
||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||
|
||||
$itemId = $db->insert_id;
|
||||
$finalQuantity = $quantity;
|
||||
$isUpdate = false;
|
||||
}
|
||||
|
||||
// Update stocktake progress
|
||||
$stocktake->updateProgress();
|
||||
|
||||
// Log the scan
|
||||
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'quantity' => $quantity,
|
||||
'totalQuantity' => $finalQuantity,
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isUpdate' => $isUpdate,
|
||||
]);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'message' => $isUpdate
|
||||
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
|
||||
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
|
||||
'item' => [
|
||||
'id' => $itemId,
|
||||
'articleId' => $articleId,
|
||||
'articleNumber' => $article->articleNumber,
|
||||
'articleTitle' => $article->title,
|
||||
'countedQuantity' => $finalQuantity,
|
||||
'unit' => $article->unit ?? 'Stk.',
|
||||
'rack' => $rack,
|
||||
'shelf' => $shelf,
|
||||
'isUpdate' => $isUpdate,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent scans for current user in a stocktake
|
||||
*/
|
||||
protected function getMyScansAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
|
||||
if (!$stocktakeId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
|
||||
FROM WarehouseStocktakeItem si
|
||||
JOIN WarehouseArticle wa ON wa.id = si.articleId
|
||||
WHERE si.stocktakeId = {$stocktakeId}
|
||||
AND si.scannedBy = {$this->user->id}
|
||||
ORDER BY si.scannedAt DESC
|
||||
LIMIT 50");
|
||||
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = [
|
||||
'id' => intval($row['id']),
|
||||
'articleId' => intval($row['articleId']),
|
||||
'articleNumber' => $row['articleNumber'],
|
||||
'articleTitle' => $row['articleTitle'],
|
||||
'countedQuantity' => floatval($row['countedQuantity']),
|
||||
'unit' => $row['unit'] ?? 'Stk.',
|
||||
'rack' => $row['rack'],
|
||||
'shelf' => $row['shelf'],
|
||||
'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'items' => $items]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress stats
|
||||
*/
|
||||
protected function getProgressAction() {
|
||||
$stocktakeId = intval($this->request->stocktakeId);
|
||||
|
||||
if (!$stocktakeId) {
|
||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
||||
if (!$stocktake) {
|
||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
// Total scanned items
|
||||
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
|
||||
$totalRow = $totalResult->fetch_assoc();
|
||||
$totalScanned = intval($totalRow['count']);
|
||||
|
||||
// My scanned items
|
||||
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
|
||||
$myRow = $myResult->fetch_assoc();
|
||||
$myScanned = intval($myRow['count']);
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'progress' => [
|
||||
'totalScanned' => $totalScanned,
|
||||
'myScanned' => $myScanned,
|
||||
'status' => $stocktake->status,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,8 @@ class WorkorderBaseController extends TTCrud
|
||||
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
|
||||
if (empty($networks)) continue;
|
||||
|
||||
$tenantCampaigns = array_map(fn($n) => $n->id, PreordercampaignModel::getAll(['network_id' => array_map(fn($n) => $n->id, $networks)]));
|
||||
$networkIds = array_map(fn($n) => $n->id, $networks);
|
||||
$tenantCampaigns = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
|
||||
if (empty($tenantCampaigns)) continue;
|
||||
|
||||
$filters['preordercampaign_id'] = $tenantCampaigns;
|
||||
@@ -228,22 +229,25 @@ class WorkorderBaseController extends TTCrud
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenantCampaignIds = array_column(PreordercampaignModel::getAll(['network_id' => array_column($networks, 'id')]), 'id');
|
||||
$networkIds = array_map(fn($n) => $n->id, $networks);
|
||||
$tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
|
||||
if (empty($tenantCampaignIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activeFilters['preordercampaign_id'] = $tenantCampaignIds;
|
||||
|
||||
$activePreorderIds = array_column(PreorderModel::searchActive($activeFilters), 'id');
|
||||
$activePreorderIds = array_map(fn($p) => $p->id, PreorderModel::searchActive($activeFilters));
|
||||
$activePreorderIdsSet = array_flip($activePreorderIds);
|
||||
|
||||
$statusesToCheck = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved'];
|
||||
|
||||
$allTenantPreorders = PreorderModel::getAll(['preordercampaign_id' => $tenantCampaignIds]);
|
||||
// Get ALL preorders for tenant (including deleted/cancelled) to ensure their workorders get archived
|
||||
// Note: Not passing 'deleted' filter means all preorders are returned regardless of deleted status
|
||||
$allTenantPreorders = PreorderModel::search(['preordercampaign_id' => $tenantCampaignIds]);
|
||||
if(empty($allTenantPreorders)) continue;
|
||||
|
||||
$allTenantPreorderIds = array_column($allTenantPreorders, 'id');
|
||||
$allTenantPreorderIds = array_map(fn($p) => $p->id, $allTenantPreorders);
|
||||
|
||||
$workordersToCheck = WorkorderModel::getAll([
|
||||
'status' => $statusesToCheck,
|
||||
|
||||
Reference in New Issue
Block a user