485 lines
21 KiB
PHP
485 lines
21 KiB
PHP
<?php
|
|
|
|
class WarehouseOfferController extends TTCrud
|
|
{
|
|
protected string $headerTitle = 'Angebote';
|
|
protected string $singleText = 'Angebot';
|
|
protected bool $createText = false;
|
|
|
|
protected array $columns = [
|
|
['key' => 'id', 'text' => 'ID', 'modal' => false, 'table' => false],
|
|
['key' => 'version', 'text' => 'Version', 'modal' => false, 'table' => false],
|
|
['key' => 'offerNumber', 'text' => 'Angebotsnummer', 'required' => true, 'modal' => false],
|
|
['key' => 'customerNumber', 'text' => 'Kundennummer', 'required' => true, 'modal' => false],
|
|
['key' => 'customerName', 'text' => 'Kundenname', 'required' => true, 'modal' => false],
|
|
['key' => 'contactPerson', 'text' => 'Kontaktperson', 'required' => false, 'modal' => false],
|
|
['key' => 'customerCity', 'text' => 'Stadt', 'required' => true, 'modal' => false],
|
|
['key' => 'totalAmount', 'text' => 'Gesamtbetrag', 'required' => true, 'modal' => false, 'table' => ['formatter' => 'formatPrice']],
|
|
['key' => 'status', 'text' => 'Status', 'required' => true, 'modal' => ['type' => 'select', 'items' => [
|
|
['value' => 'new', 'text' => 'Neu'],
|
|
['value' => 'sent', 'text' => 'Ausgeschickt'],
|
|
['value' => 'accepted', 'text' => 'Angenommen'],
|
|
['value' => 'rejected', 'text' => 'Abgelehnt'],
|
|
['value' => 'cancelled', 'text' => 'Storniert'],
|
|
]], 'table' => ['filter' => 'select']],
|
|
['key' => 'editor', 'text' => 'Sachbearbeiter', 'required' => true, 'modal' => ['type' => 'select'], 'table' => ['filter' => 'select']],
|
|
['key' => 'create', 'text' => 'Erstellt', 'required' => true, 'modal' => false, 'table' => ['formatter' => 'formatDate']],
|
|
['key' => 'lastSentDate', 'text' => 'Zuletzt gesendet', 'required' => false, 'modal' => false, 'table' => ['formatter' => 'formatDate']],
|
|
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
|
|
];
|
|
|
|
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => false];
|
|
protected array $permissionCheck = ['WarehouseAdmin', 'WarehouseUser'];
|
|
protected array $additionalActions = [
|
|
['key' => 'openpdf', 'title' => 'PDF öffnen', 'class' => 'fas fa-file-pdf', 'color' => 'primary'],
|
|
['key' => 'sendmail', 'title' => 'Angebot senden', 'class' => 'fas fa-paper-plane', 'color' => 'success'],
|
|
];
|
|
|
|
protected function prepareCrudConfig(): void
|
|
{
|
|
$editorColumnIndex = array_search('editor', array_column($this->columns, 'key'));
|
|
$this->columns[$editorColumnIndex]['modal']['items'] = array_map(function ($user) {
|
|
return ['value' => intval($user->id), 'text' => $user->name];
|
|
}, UserModel::search(['employee' => true]));
|
|
|
|
if ($this->user->can('WarehouseAdmin')) {
|
|
$this->additionalJSVariables['WAREHOUSE_ADMIN'] = true;
|
|
}
|
|
}
|
|
|
|
protected function beforeCreate(): bool
|
|
{
|
|
$currentCount = WarehouseOfferModel::count(['create' => ['from' => strtotime(date('Y-01-01'))]]);
|
|
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
|
|
$this->postData['status'] = 'new';
|
|
$this->postData['version'] = 1;
|
|
return true;
|
|
}
|
|
|
|
protected function afterCreate($id): void
|
|
{
|
|
$offer = WarehouseOfferModel::get($id);
|
|
$this->createHistoryEntry($id, 1, $offer);
|
|
}
|
|
|
|
|
|
protected function beforeUpdate($postData): bool
|
|
{
|
|
$oldOffer = WarehouseOfferModel::get($postData['id']);
|
|
$newVersion = $oldOffer->version + 1;
|
|
|
|
// Create history entry before updating the main record
|
|
$historyId = $this->createHistoryEntry($postData['id'], $newVersion, $postData);
|
|
|
|
$this->postData['version'] = $newVersion;
|
|
$this->postData['history_id'] = $historyId; // Link to the latest history entry
|
|
|
|
return true;
|
|
}
|
|
|
|
private function createHistoryEntry($offerId, $version, $data)
|
|
{
|
|
$historyData = is_object($data) ? json_encode($data) : json_encode((object)$data);
|
|
return WarehouseHistoryModel::create([
|
|
'table' => $this->mod,
|
|
'row_id' => $offerId,
|
|
'key' => 'version',
|
|
'old_value' => $version - 1,
|
|
'new_value' => $version,
|
|
'note' => 'Version ' . $version . ' erstellt.',
|
|
'data' => $historyData, // Store full data snapshot
|
|
'user_id' => $this->user->id,
|
|
'create' => time()
|
|
]);
|
|
}
|
|
|
|
public function getVersionsAction()
|
|
{
|
|
$id = $this->request->id;
|
|
if (!$id) self::sendError("ID fehlt");
|
|
|
|
$history = WarehouseHistoryModel::getByRowId($id, $this->mod);
|
|
$versions = array_map(function ($entry) {
|
|
return [
|
|
'id' => $entry->id,
|
|
'version' => $entry->new_value,
|
|
'user' => $entry->user_name,
|
|
'date' => $entry->create,
|
|
'data' => json_decode($entry->data)
|
|
];
|
|
}, $history);
|
|
|
|
self::returnJson($versions);
|
|
}
|
|
|
|
// Journal Actions
|
|
public function getJournalAction()
|
|
{
|
|
$id = $this->request->id;
|
|
if (!$id) self::sendError("ID fehlt");
|
|
$journalEntries = WarehouseOfferJournalModel::search(['offerId' => $id], ['create' => 'DESC']);
|
|
self::returnJson($journalEntries);
|
|
}
|
|
|
|
public function addJournalEntryAction()
|
|
{
|
|
$_POST = json_decode(file_get_contents('php://input'), true);
|
|
$id = $_POST['id'] ?? null;
|
|
$message = $_POST['message'] ?? '';
|
|
$fileIds = isset($_POST['fileIds']) ? json_encode($_POST['fileIds']) : null;
|
|
|
|
if (!$id) self::sendError("ID fehlt");
|
|
|
|
WarehouseOfferJournalModel::create([
|
|
'offerId' => $id,
|
|
'message' => $message,
|
|
'fileIds' => $fileIds,
|
|
'createBy' => $this->user->id,
|
|
'create' => time()
|
|
]);
|
|
|
|
self::returnJson(['success' => true, 'message' => 'Journaleintrag hinzugefügt.']);
|
|
}
|
|
|
|
|
|
// Closing Text Actions
|
|
public function getClosingTextsAction()
|
|
{
|
|
self::returnJson(WarehouseOfferClosingTextModel::getAll());
|
|
}
|
|
|
|
public function createClosingTextAction()
|
|
{
|
|
if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung");
|
|
$_POST = json_decode(file_get_contents('php://input'), true);
|
|
$id = WarehouseOfferClosingTextModel::create([
|
|
'name' => $_POST['name'],
|
|
'text' => $_POST['text'],
|
|
'createBy' => $this->user->id,
|
|
'create' => time()
|
|
]);
|
|
self::returnJson(['success' => true, 'id' => $id]);
|
|
}
|
|
|
|
public function updateClosingTextAction()
|
|
{
|
|
if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung");
|
|
$_POST = json_decode(file_get_contents('php://input'), true);
|
|
WarehouseOfferClosingTextModel::update($_POST['id'], ['name' => $_POST['name'], 'text' => $_POST['text']]);
|
|
self::returnJson(['success' => true]);
|
|
}
|
|
|
|
public function deleteClosingTextAction()
|
|
{
|
|
if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung");
|
|
WarehouseOfferClosingTextModel::delete($this->request->id);
|
|
self::returnJson(['success' => true]);
|
|
}
|
|
|
|
|
|
// E-Mail Actions
|
|
public function sendOfferEmailAction()
|
|
{
|
|
$_POST = json_decode(file_get_contents('php://input'), true);
|
|
$id = $_POST['id'] ?? null;
|
|
$recipientEmail = $_POST['email'] ?? null;
|
|
$subject = $_POST['subject'] ?? 'Ihr Angebot von XINON GmbH';
|
|
$body = $_POST['body'] ?? 'Anbei finden Sie Ihr angefordertes Angebot.';
|
|
|
|
if (!$id || !$recipientEmail) {
|
|
self::sendError("ID oder E-Mail-Adresse fehlt.");
|
|
}
|
|
|
|
$offer = WarehouseOfferModel::get($id);
|
|
if (!$offer) {
|
|
self::sendError("Angebot nicht gefunden.");
|
|
}
|
|
|
|
// Approval Logic
|
|
if ($offer->totalAmount > 5000 && !$this->user->can('WarehouseAdmin')) {
|
|
// Send approval request to admin
|
|
$this->sendApprovalRequestEmail($offer);
|
|
self::returnJson(['success' => true, 'message' => 'Angebotssumme über 5000€. Freigabeanfrage wurde an einen Administrator gesendet.']);
|
|
return;
|
|
}
|
|
|
|
// Generate PDF
|
|
$pdfContent = $this->createPDFAction(true, $id, true);
|
|
|
|
// Send Email
|
|
$mail = new Mail();
|
|
$mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName);
|
|
$mail->setSubject($subject);
|
|
$mail->setBody($body);
|
|
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
|
|
|
|
if ($mail->send()) {
|
|
// Update offer status and last sent date
|
|
WarehouseOfferModel::update($id, ['status' => 'sent', 'lastSentDate' => time()]);
|
|
|
|
// Add Journal Entry
|
|
WarehouseOfferJournalModel::create([
|
|
'offerId' => $id,
|
|
'message' => "Angebot per E-Mail an $recipientEmail gesendet.",
|
|
'createBy' => $this->user->id,
|
|
'create' => time()
|
|
]);
|
|
|
|
self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet.']);
|
|
} else {
|
|
self::sendError('E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo);
|
|
}
|
|
}
|
|
|
|
private function sendApprovalRequestEmail($offer)
|
|
{
|
|
$admins = UserModel::search(['permission' => 'WarehouseAdmin']);
|
|
$mail = new Mail();
|
|
foreach ($admins as $admin) {
|
|
$mail->addAddress($admin->email, $admin->name);
|
|
}
|
|
$mail->setSubject("Angebotsfreigabe erforderlich: " . $offer->offerNumber);
|
|
$body = "Hallo,<br><br>das Angebot <strong>{$offer->offerNumber}</strong> für Kunde <strong>{$offer->customerName}</strong> übersteigt die Summe von 5.000,00 € und erfordert Ihre Freigabe.<br><br>";
|
|
$body .= "<strong>Gesamtsumme:</strong> " . number_format($offer->totalAmount, 2, ',', '.') . " €<br>";
|
|
$body .= "<strong>Erstellt von:</strong> " . (UserModel::getOne($offer->editor))->name . "<br><br>";
|
|
$body .= "Bitte prüfen und genehmigen Sie das Angebot im System.<br><br>Danke,<br>Ihr TheTool System";
|
|
$mail->setBody($body);
|
|
$mail->send();
|
|
|
|
// Add Journal Entry for approval request
|
|
WarehouseOfferJournalModel::create([
|
|
'offerId' => $offer->id,
|
|
'message' => "Freigabeanfrage für Angebot an Administratoren gesendet.",
|
|
'createBy' => $this->user->id,
|
|
'create' => time()
|
|
]);
|
|
}
|
|
|
|
public function sendReminderEmailsAction()
|
|
{
|
|
// This action should be triggered by a cronjob daily
|
|
$twoWeeksAgo = strtotime('-14 days');
|
|
$offersToRemind = WarehouseOfferModel::search([
|
|
'status' => 'sent',
|
|
'lastSentDate' => ['to' => $twoWeeksAgo]
|
|
]);
|
|
|
|
foreach ($offersToRemind as $offer) {
|
|
$recipientEmail = $offer->contactPersonEmail;
|
|
if (!$recipientEmail) continue;
|
|
|
|
$subject = "Erinnerung: Ihr Angebot " . $offer->offerNumber;
|
|
$body = "Sehr geehrte/r " . ($offer->contactPerson ?? $offer->customerName) . ",<br><br>";
|
|
$body .= "hiermit möchten wir Sie an unser Angebot mit der Nummer <strong>{$offer->offerNumber}</strong> vom " . date('d.m.Y', $offer->create) . " erinnern.<br><br>";
|
|
$body .= "Sollten Sie Fragen haben oder eine Anpassung wünschen, stehen wir Ihnen gerne zur Verfügung.<br><br>Mit freundlichen Grüßen,<br>Ihr XINON Team";
|
|
|
|
$pdfContent = $this->createPDFAction(true, $offer->id, true);
|
|
|
|
$mail = new Mail();
|
|
$mail->addAddress($recipientEmail);
|
|
$mail->setSubject($subject);
|
|
$mail->setBody($body);
|
|
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
|
|
|
|
if ($mail->send()) {
|
|
// Update last sent date to avoid sending reminders every day
|
|
WarehouseOfferModel::update($offer->id, ['lastSentDate' => time()]);
|
|
WarehouseOfferJournalModel::create([
|
|
'offerId' => $offer->id,
|
|
'message' => "Automatische Erinnerungs-E-Mail gesendet an $recipientEmail.",
|
|
'createBy' => 0, // System user
|
|
'create' => time()
|
|
]);
|
|
}
|
|
}
|
|
echo "Reminder job finished.";
|
|
}
|
|
|
|
|
|
// PDF Generation
|
|
public function createPDFAction($returnContent = false, $idOverride = null, $fromEmail = false)
|
|
{
|
|
$id = $idOverride ?? $this->request->id;
|
|
if (empty($id)) self::sendError('ID fehlt');
|
|
|
|
$version = $this->request->version ?? null;
|
|
$offerData = null;
|
|
|
|
if ($version) {
|
|
$historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version);
|
|
if ($historyEntry && !empty($historyEntry->data)) {
|
|
$offerData = json_decode($historyEntry->data);
|
|
}
|
|
}
|
|
|
|
if (!$offerData) {
|
|
$offer = WarehouseOfferModel::get($id);
|
|
if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden');
|
|
$offerData = $offer;
|
|
}
|
|
|
|
|
|
// --- Customer & Billing Address ---
|
|
// ... (address logic remains similar to original)
|
|
|
|
// --- Process Positions ---
|
|
$positionsRaw = is_string($offerData->positions) ? json_decode($offerData->positions, true) : (array)$offerData->positions;
|
|
$entries = [];
|
|
$subTotal = 0;
|
|
$alternativeTotal = 0;
|
|
|
|
if (is_array($positionsRaw)) {
|
|
foreach ($positionsRaw as $p) {
|
|
$p = (array)$p; // Ensure $p is an array
|
|
if (!isset($p['article']) || empty($p['article']) || (isset($p['amount']) && $p['amount'] == 0)) continue;
|
|
|
|
$article = WarehouseArticleModel::get($p['article']);
|
|
if (!$article) continue;
|
|
|
|
$p['articleNumber'] = $article->articleNumber ?? null;
|
|
$p['articleText'] = $article->title;
|
|
$p['articleDescription'] = ($article->description !== $article->title) ? ($article->description ?? '') : '';
|
|
$p['articleUnit'] = $p['unit'] ?? $article->unit ?? 'Stk.';
|
|
$p['price'] = (float)($p['unitPrice'] ?? 0);
|
|
$p['amount'] = (float)($p['amount'] ?? 0);
|
|
$discount = isset($p['discount']) ? (float)$p['discount'] : 0;
|
|
$p['totalPrice'] = ($p['price'] * $p['amount']) * (1 - $discount / 100);
|
|
$p['comment'] = $p['comment'] ?? ''; // Add comment field
|
|
|
|
$isAlternative = isset($p['isAlternative']) && $p['isAlternative'];
|
|
|
|
if (!$isAlternative) {
|
|
$subTotal += $p['totalPrice'];
|
|
} else {
|
|
$alternativeTotal += $p['totalPrice'];
|
|
}
|
|
|
|
$groupKey = $isAlternative ? 'Alternativpositionen' : ($p['_group'] ?? '');
|
|
if (!isset($entries[$groupKey])) $entries[$groupKey] = [];
|
|
$entries[$groupKey][] = $p;
|
|
}
|
|
}
|
|
|
|
$editor = UserModel::getOne($offerData->editor);
|
|
|
|
// --- Prepare PDF Variables ---
|
|
$pdf_vars = [
|
|
"offer" => $offerData,
|
|
"entries" => $entries,
|
|
"subTotal" => $subTotal,
|
|
"alternativeTotal" => $alternativeTotal,
|
|
"offerNumber" => $offerData->offerNumber,
|
|
"offerDate" => $offerData->create,
|
|
"offerEditorName" => $editor ? $editor->name : 'Unbekannt',
|
|
"includeTax" => true,
|
|
"vatRate" => 0.20,
|
|
"offerText" => $offerData->notes ?? '',
|
|
"closingText" => $offerData->closingText ?? '',
|
|
"bank_iban" => TT_INVOICE_BANK_IBAN,
|
|
"bank_bic" => TT_INVOICE_BANK_BIC,
|
|
];
|
|
|
|
// --- Customer & Billing Address ---
|
|
// Since the offer model doesn't have a separate billing address,
|
|
// we'll populate the main address and provide empty values for the billing address section.
|
|
$addressLines = [
|
|
'header' => "<strong>Empfänger</strong>",
|
|
'1' => $offerData->customerName ?? '',
|
|
'2' => $offerData->customerStreet ?? '',
|
|
'3' => ($offerData->customerZip ?? '') . ' ' . ($offerData->customerCity ?? ''),
|
|
'4' => '', // Placeholder for country if needed in the future
|
|
'5' => !empty($offerData->contactPerson) ? 'z.H. ' . htmlspecialchars($offerData->contactPerson) : ''
|
|
];
|
|
|
|
// Provide empty values for the billing address section in the template to avoid errors
|
|
$billingAddressLines = [
|
|
'header' => '', '1' => '', '2' => '', '3' => '', '4' => '', '5' => '', '6' => ''
|
|
];
|
|
|
|
|
|
// --- Prepare Replacements for Header/Footer ---
|
|
$replacements = [
|
|
'basedir' => BASEDIR,
|
|
'externalReference' => !empty($offerData->reference) ?
|
|
"<strong>Ihre Referenz:</strong> " . htmlspecialchars($offerData->reference) : "",
|
|
|
|
// Customer Address (Empfänger)
|
|
'addressLine_header' => $addressLines['header'],
|
|
'addressLine_1' => htmlspecialchars($addressLines['1']),
|
|
'addressLine_2' => htmlspecialchars($addressLines['2']),
|
|
'addressLine_3' => htmlspecialchars($addressLines['3']),
|
|
'addressLine_4' => htmlspecialchars($addressLines['4']),
|
|
'addressLine_5' => $addressLines['5'], // Already escaped
|
|
|
|
// Billing Address (Rechnungsadresse) - left blank as per model
|
|
'billingAddressLine_header' => $billingAddressLines['header'],
|
|
'billingAddressLine_1' => htmlspecialchars($billingAddressLines['1']),
|
|
'billingAddressLine_2' => htmlspecialchars($billingAddressLines['2']),
|
|
'billingAddressLine_3' => htmlspecialchars($billingAddressLines['3']),
|
|
'billingAddressLine_4' => htmlspecialchars($billingAddressLines['4']),
|
|
'billingAddressLine_5' => htmlspecialchars($billingAddressLines['5']),
|
|
'billingAddressLine_6' => htmlspecialchars($billingAddressLines['6']),
|
|
|
|
// Footer variables with fallbacks
|
|
'bank_iban' => defined('TT_INVOICE_BANK_IBAN_FORMATTED') ? TT_INVOICE_BANK_IBAN_FORMATTED : (defined('TT_INVOICE_BANK_IBAN') ? TT_INVOICE_BANK_IBAN : ''),
|
|
'bank_bic' => defined('TT_INVOICE_BANK_BIC') ? TT_INVOICE_BANK_BIC : '',
|
|
'bank_bank' => defined('TT_INVOICE_BANK_BANK') ? TT_INVOICE_BANK_BANK : '',
|
|
'bank_owner' => defined('TT_INVOICE_BANK_OWNER') ? TT_INVOICE_BANK_OWNER : '',
|
|
];
|
|
|
|
// --- Generate PDF ---
|
|
$tempDir = BASEDIR . "/var/temp";
|
|
if (!is_dir($tempDir)) mkdir($tempDir, 0775, true);
|
|
|
|
$headerFile = tempnam($tempDir, 'offer_header') . '.html';
|
|
$footerFile = tempnam($tempDir, 'offer_footer') . '.html';
|
|
|
|
file_put_contents($headerFile, $this->generateTemplate('WarehouseOffer/PDF_HEADER', $replacements));
|
|
file_put_contents($footerFile, $this->generateTemplate('WarehouseOffer/PDF_FOOTER', $replacements));
|
|
|
|
$pdf = new PdfForm("WarehouseOffer/PDF_MAIN", $pdf_vars);
|
|
$options = "--header-html {$headerFile} --footer-html {$footerFile}";// --margin-top 45 --margin-bottom 25";
|
|
$filename = $pdf->render($options);
|
|
|
|
// unlink($headerFile);
|
|
// unlink($footerFile);
|
|
|
|
if ($returnContent === true) {
|
|
$content = file_get_contents($filename);
|
|
// unlink($filename);
|
|
return $content;
|
|
}
|
|
|
|
if (!file_exists($filename)) self::sendError('Generierte PDF-Datei nicht gefunden.');
|
|
|
|
header('Content-Type: application/pdf');
|
|
$outputFilename = ($offerData->offerNumber ?? $offerData->id) . "_v" . ($version ?? $offerData->version) . ".pdf";
|
|
header('Content-Disposition: inline; filename="' . basename($outputFilename) . '"');
|
|
header('Content-Length: ' . filesize($filename));
|
|
readfile($filename);
|
|
exit;
|
|
}
|
|
|
|
protected function generateTemplate(string $templateName, array $replacements): string
|
|
{
|
|
$templatePath = BASEDIR . "/Layout/default/" . $templateName . ".html";
|
|
if (!file_exists($templatePath)) {
|
|
// Fallback to a default or error
|
|
$templatePath = BASEDIR . "/modules/" . $templateName . ".html";
|
|
if (!file_exists($templatePath)) {
|
|
self::sendError('Template nicht gefunden: ' . $templateName);
|
|
}
|
|
}
|
|
$content = file_get_contents($templatePath);
|
|
foreach ($replacements as $key => $value) {
|
|
$content = str_replace('{{ ' . $key . ' }}', $value ?? '', $content);
|
|
}
|
|
return $content;
|
|
}
|
|
|
|
protected function getTemplatesAction() {
|
|
self::returnJson(WarehouseOfferTemplateModel::getAll());
|
|
}
|
|
|
|
}
|