Files
thetool/application/WarehouseOffer/WarehouseOfferController.php
2026-01-13 07:11:56 +01:00

556 lines
25 KiB
PHP

<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
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;
$this->postData['validity'] = 31;
$this->postData['alternativePositions'] = json_encode([]);
return true;
}
protected function afterCreate($offer): void
{
$id = $offer['id'];
$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::searchOfferJournal(['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()
{
//display errors for debugging
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
$_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';
$bodyText = $_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);
// --- 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>Angebot</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);">';
// 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;">';
$html .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
$html .= nl2br(htmlspecialchars($bodyText));
$html .= '</div>';
$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>';
$mail = new PHPMailer(true);
try {
// Server settings
$mail->isSMTP();
$mail->Host = TT_PIPEWORK_SMTP_HOST;
$mail->SMTPAuth = true;
$mail->Username = TT_PIPEWORK_SMTP_USER;
$mail->Password = TT_PIPEWORK_SMTP_PASS;
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->Encoding = 'base64';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
// Logos
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
$mail->setFrom('thetool@xinon.at', 'XINON TheTool');
// set replyto to backoffice@xinon.at
$mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName);
$mail->Subject = ($subject);
$mail->isHTML(true);
$mail->Body = $html;
$mail->AltBody = strip_tags($bodyText);
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
$mail->send();
// Update offer status and last sent date
$WarehouseOffer = (array) WarehouseOfferModel::get($id);
$WarehouseOffer['status'] = 'sent';
$WarehouseOffer['lastSentDate'] = time();
WarehouseOfferModel::update($WarehouseOffer);
// 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.']);
} catch (Exception $e) {
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;
$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
}
}
if (!$offerData) {
$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;
}
// --- 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['discount'] = isset($p['discount']) ? (float)$p['discount'] : 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,
"versionDate" => $versionDate ?? $offerData->create, // Date for validity calculation
"offerEditorName" => $editor ? $editor->name : 'Unbekannt',
"includeTax" => true,
"vatRate" => 0.20,
"offerText" => $offerData->notes ?? '',
"validity" => $offerData->validity ?? 31,
"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());
}
}