diff --git a/Layout/default/WarehouseOffer/PDF_FOOTER.html b/Layout/default/WarehouseOffer/PDF_FOOTER.html new file mode 100644 index 000000000..0a984b7d6 --- /dev/null +++ b/Layout/default/WarehouseOffer/PDF_FOOTER.html @@ -0,0 +1,43 @@ + + + + Xinon Rechnung + + + + + + +
+
+ XINON GmbH | Fladnitz 150 | 8322 Studenzen
+ Tel.: +43 3115 40800 | E-Mail: office@xinon.at
+ UID: ATU68711968 | FN: 416556h | LG: Feldbach
+ IBAN: {{ bank_iban }} | BIC: {{ bank_bic }}
+
+ +
Seite von
+ +
+ + diff --git a/Layout/default/WarehouseOffer/PDF_HEADER.html b/Layout/default/WarehouseOffer/PDF_HEADER.html new file mode 100644 index 000000000..87352ed31 --- /dev/null +++ b/Layout/default/WarehouseOffer/PDF_HEADER.html @@ -0,0 +1,87 @@ + + + + XINON Shipping Note Header + + + + + +
+ +
+ Xinon Logo +
+ + + + + + +
+

{{ addressLine_header }}

+
{{ addressLine_1 }}
+
{{ addressLine_2 }}
+
{{ addressLine_3 }}
+
{{ addressLine_4 }}
+
{{ addressLine_5 }}
+
+
{{ externalReference }}
+
+

{{ billingAddressLine_header }}

+
{{ billingAddressLine_1 }}
+
{{ billingAddressLine_2 }}
+
{{ billingAddressLine_3 }}
+
{{ billingAddressLine_4 }}
+
{{ billingAddressLine_5 }}
+
{{ billingAddressLine_6 }}
+
+ + +
+ + + diff --git a/Layout/default/WarehouseOffer/PDF_MAIN.php b/Layout/default/WarehouseOffer/PDF_MAIN.php new file mode 100644 index 000000000..da8f13027 --- /dev/null +++ b/Layout/default/WarehouseOffer/PDF_MAIN.php @@ -0,0 +1,330 @@ + [position1, position2,...]] + * @var float $subTotal Calculated subtotal of all positions + * @var string $offerNumber Offer number + * @var int $offerDate Timestamp of offer creation + * @var int|null $validUntilDate Timestamp of offer validity end, or null + * @var bool $includeTax Whether to calculate and show VAT + * @var float $vatRate The VAT rate (e.g., 0.20 for 20%) + * @var string $offerText Additional text from the offer record + * + * // Bank details are also available but typically used in footer + * @var string $bank_iban + * @var string $bank_bic + * @var string $bank_bank + * @var string $bank_owner + */ + +// Set filename for download (optional, can also be set in controller) +// $this->setReturnValue(['filename' => ($offerNumber ?? $offer->id) . "_Angebot.pdf"]); + +// --- Text Elements (Simple German Example - Extend for EN like in Order) --- +$texts = [ + 'DE' => [ + 'title' => 'Angebot', + 'offerNumberLabel' => 'Angebotsnr.:', + 'offerDateLabel' => 'Datum:', + 'validUntilLabel' => 'Gültig bis:', + 'pageLabel' => 'Seite', // For page numbering in content if needed + 'table' => [ + 'pos' => 'Pos', + 'article' => 'Artikel / Beschreibung', + // 'description' => 'Beschreibung', // Combined with Article + 'amount' => 'Menge', + 'unit' => 'Einheit', + 'unitPrice' => 'Einzelpreis', + 'totalPrice' => 'Gesamtpreis' + ], + 'summary' => [ + 'subTotal' => 'Zwischensumme', + 'vatFormatted' => 'zzgl. {VAT_RATE}% MwSt.', // Placeholder for rate + 'total' => 'Gesamtbetrag', + 'currency' => '€' // Currency symbol + ], + 'notes' => 'Anmerkungen:', + 'defaultOfferText' => 'Vielen Dank für Ihre Anfrage. Es gelten unsere Allgemeinen Geschäftsbedingungen.', + 'taxInfoNet' => '(Alle Preise exkl. MwSt.)', // Info if tax is added at the end + 'taxInfoGross' => '(Alle Preise inkl. MwSt.)', // Info if prices already include tax (less common in B2B offers) + ] + // Add 'EN' => [...] section if needed +]; +// Simple language selection (default to DE) - enhance if customer language is known +$lang = 'DE'; +$text = $texts[$lang]; +$currencySymbol = $text['summary']['currency']; + +// --- Calculations --- +$vatAmount = 0; +$grandTotal = $subTotal; +if ($includeTax) { + $vatAmount = $subTotal * $vatRate; + $grandTotal = $subTotal + $vatAmount; +} + +// Format dates +$formattedOfferDate = date("d.m.Y", $offerDate); +$formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("d.m.Y", strtotime("+14 days", $offerDate)); + +?> + + + + <?= $text['title'] ?> <?= $offerNumber ?> + + + + + +

+ +
+ + + + + + + + + + + +
+
+ + + + + + + + + + + + + + $positions): + // Optional: Display group name if it exists + if (!empty($groupName)): ?> + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
Keine Positionen im Angebot enthalten.
+ + + + + + + + + + + + + + + + + + + + + + + +
:
:
:
:
+ +' . $text['taxInfoNet'] . ''; +} +// Add taxInfoGross if your prices *include* tax, which is less common for B2B offers. +?> + + +
+ +

+
+
+ +

+

Zahlungsbedingungen: 14 Tage netto.

+

Lieferzeit: nach Vereinbarung.

+
+ + + \ No newline at end of file diff --git a/application/WarehouseOffer/WarehouseOfferController.php b/application/WarehouseOffer/WarehouseOfferController.php index 497d28c96..b581a6f20 100644 --- a/application/WarehouseOffer/WarehouseOfferController.php +++ b/application/WarehouseOffer/WarehouseOfferController.php @@ -80,57 +80,243 @@ class WarehouseOfferController extends TTCrud { } public function createPDFAction($returnFilename = false, $idOverride = null) { - //display errors + // Display errors (Keep for debugging, consider removing for production) ini_set('display_errors', 1); ini_set('display_startup_errors', 1); error_reporting(E_ALL); $id = $idOverride ?? $this->request->id; - if (strlen($id) < 1) self::sendError('ID fehlt'); - - $offer = WarehouseOfferModel::get($id); - if (!$offer->id) self::sendError('Angebot nicht gefunden'); - - $positions = json_decode($offer->positions, true); - $entries = []; - - foreach ($positions as $position) { - if (!isset($position['article'])) continue; - $article = WarehouseArticleModel::get($position['article']); - $position['articleText'] = WarehouseArticleModel::get($position['article'])->title; - $position['articleDescription'] = $article->description === $article->title ? "" : $article->description; - $position['articleUnit'] = $position['unit'] ?? $article->unit ?? 'Stk.'; - - if (isset($position['_group'])) { - $entries[$position['_group']][] = $position; - } else { - $entries[''][] = $position; - } + if (empty($id)) { + self::sendError('ID fehlt'); // Use empty() for better checks } + $offer = WarehouseOfferModel::get($id); + if (!$offer || !$offer->id) { // Check if offer object is valid + self::sendError('Angebot nicht gefunden'); + } + + // --- Customer Data (Assuming fields exist on $offer) --- + // You might need to fetch a CustomerModel if data isn't directly on the offer + // $customer = CustomerModel::get($offer->customerId); + // $customerName = $customer->name; // Example + $customerName = $offer->customerName ?? 'N/A'; + $customerStreet = $offer->customerStreet ?? ''; + $customerZip = $offer->customerZip ?? ''; + $customerCity = $offer->customerCity ?? ''; + $customerCountryId = $offer->customerCountryId ?? null; // Assuming country ID exists + $customerCountry = $customerCountryId ? (new Country($customerCountryId))->name : ''; + + // Construct address lines (adjust based on your actual data fields) + $addressLines = []; + $addressLines['header'] = "Empfänger"; // Or "Kunde" + $addressLines['1'] = $customerName; + $addressLines['2'] = $customerStreet; + $addressLines['3'] = $customerZip . ' ' . $customerCity; + $addressLines['4'] = $customerCountry; + $addressLines['5'] = ''; // Add more lines if needed (e.g., contact person) + + // --- Billing Address (Assuming fields exist on $offer, check if different) --- + // Example: Check if specific billing fields exist and are filled + $useBillingAddress = !empty($offer->billingName) || !empty($offer->billingStreet); + if ($useBillingAddress) { + $billingAddressLines = []; + $billingAddressLines['header'] = "Rechnungsadresse"; + $billingAddressLines['1'] = $offer->billingName ?? $customerName; + $billingAddressLines['2'] = $offer->billingStreet ?? ''; + $billingAddressLines['3'] = ($offer->billingZip ?? '') . ' ' . ($offer->billingCity ?? ''); + $billingAddressLines['4'] = $offer->billingCountryId ? (new Country($offer->billingCountryId))->name : ''; + $billingAddressLines['5'] = ''; + $billingAddressLines['6'] = ''; // Add more lines if needed + } else { + // If no specific billing address, maybe hide the section or repeat customer address + $billingAddressLines = ['header' => '', '1' => '', '2' => '', '3' => '', '4' => '', '5' => '', '6' => '']; // Effectively hides it + // Or repeat customer address: + // $billingAddressLines = $addressLines; + // $billingAddressLines['header'] = "Rechnungsadresse"; + } + + + // --- Process Positions --- + $positionsRaw = json_decode($offer->positions, true); + $entries = []; + $subTotal = 0; // Calculate subtotal here + + if (is_array($positionsRaw)) { + foreach ($positionsRaw as $position) { + if (!isset($position['article']) || empty($position['article'])) continue; + + $article = WarehouseArticleModel::get($position['article']); + if (!$article) continue; // Skip if article not found + + $position['articleText'] = $article->title ?? 'N/A'; + // Avoid showing description if same as title + $position['articleDescription'] = ($article->description !== $article->title) ? ($article->description ?? '') : ''; + $position['articleUnit'] = $position['unit'] ?? $article->unit ?? 'Stk.'; // Default to 'Stk.' + $position['price'] = (float)($position['unitPrice'] ?? 0); // Ensure price is float + $position['amount'] = (float)($position['amount'] ?? 0); // Ensure amount is float + $position['totalPrice'] = $position['unitPrice'] * $position['amount']; + + $subTotal += $position['totalPrice']; // Add to subtotal + + // Grouping logic + $groupKey = $position['_group'] ?? ''; // Use empty string as default group + if (!isset($entries[$groupKey])) { + $entries[$groupKey] = []; // Initialize group if not exists + } + $entries[$groupKey][] = $position; + } + } else { + // Handle case where positions JSON is invalid or empty + trigger_error("Invalid or empty positions JSON for offer ID: " . $id, E_USER_WARNING); + } + + + // --- Prepare PDF Variables --- $pdf_vars = [ "offer" => $offer, - "entries" => $entries, + "entries" => $entries, // Grouped entries + "subTotal" => $subTotal, + // Add other offer details needed in PDF_MAIN + "offerNumber" => $offer->offerNumber ?? $offer->id, // Use offerNumber if available + "offerDate" => $offer->createDate ?? time(), // Assuming createDate is a timestamp + "validUntilDate" => $offer->validUntilDate ?? null, // Assuming validUntilDate is a timestamp + "includeTax" => $offer->includeTax ?? true, // Default to including tax (e.g., 20% VAT) + "vatRate" => 0.20, // Example VAT rate (20%) - make this configurable if needed + "offerText" => $offer->offerText ?? '', // Optional text block from the offer "bank_iban" => TT_INVOICE_BANK_IBAN, "bank_bic" => TT_INVOICE_BANK_BIC, "bank_bank" => TT_INVOICE_BANK_BANK, "bank_owner" => TT_INVOICE_BANK_OWNER + // Add any other variables needed in PDF_MAIN.php ]; - $headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseOffer/PDF_HEADER.html"); - $headerHtml = str_replace("{{ addressLine_header }}", -// {{ addressLine_1 }} {{ addressLine_2 }} {{ addressLine_3 }} {{ addressLine_4 }} {{ externalReference }} - $headerHtml = str_replace("{{ addressLine_1 }}", $offer->customerName, $headerHtml)); - $headerHtml = str_replace("{{ addressLine_2 }}", $offer->customerStreet, $headerHtml); - $headerHtml = str_replace("{{ addressLine_3 }}", $offer->customerZip . ' ' . $offer->customerCity, $headerHtml); - $headerHtml = str_replace("{{ addressLine_5 }}", $offer->customerVAT, $headerHtml); - $headerHtml = str_replace("{{ externalReference }}", $offer->customerReference, $headerHtml); + // --- Prepare Replacements for Header/Footer --- + $replacements = [ + 'basedir' => BASEDIR, // Crucial for images/assets in header/footer + 'externalReference' => !empty($offer->extReference) ? + "Ihre Referenz: " . htmlspecialchars($offer->extReference) : "", // Added htmlspecialchars + + // Customer Address + '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' => htmlspecialchars($addressLines['5']), + + // Billing Address + '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 (assuming PDF_FOOTER.HTML needs these) + 'bank_iban' => defined('TT_INVOICE_BANK_IBAN_FORMATTED') ? TT_INVOICE_BANK_IBAN_FORMATTED : TT_INVOICE_BANK_IBAN, + 'bank_bic' => TT_INVOICE_BANK_BIC, + 'bank_bank' => TT_INVOICE_BANK_BANK, + 'bank_owner' => TT_INVOICE_BANK_OWNER, + // Add other company info if needed in footer (e.g., VAT ID, address) + 'company_vat_id' => 'ATU68711968', // Example + 'company_address' => 'Fladnitz im Raabtal 150, 8322 Studenzen', // Example + 'company_phone' => '+43 1 2345678', // Example + 'company_email' => 'office@xinon.at' // Example + ]; + + // --- Generate Header/Footer Temp Files --- + // Ensure the temp directory exists and is writable + $tempDir = BASEDIR . "/var/temp"; + if (!is_dir($tempDir)) { + mkdir($tempDir, 0775, true); + } + + $headerFile = $tempDir . "/offer_header-" . date("U") . "-" . rand(1000, 9999) . ".html"; + $footerFile = $tempDir . "/offer_footer-" . date("U") . "-" . rand(1000, 9999) . ".html"; + + // Assume generateTemplate exists and works like in the Order example + // You need 'WarehouseOffer/PDF_HEADER' and 'WarehouseOffer/PDF_FOOTER' templates + // Using the provided 'PDF_HEADER.HTML' content directly as the template source path + // **Important:** Adjust 'WarehouseOffer/PDF_HEADER' and 'WarehouseOffer/PDF_FOOTER' + // to match the actual paths or keys your `generateTemplate` function expects. + // If generateTemplate just takes file paths, you might need to save the HTML content + // from the prompt into actual files first (e.g., `views/WarehouseOffer/PDF_HEADER.HTML`). + + // Check if template generation succeeds + if (file_put_contents($headerFile, $this->generateTemplate('WarehouseOffer/PDF_HEADER', $replacements)) === false) { + self::sendError('Fehler beim Erstellen der Header-Datei.'); + } + if (file_put_contents($footerFile, $this->generateTemplate('WarehouseOffer/PDF_FOOTER', $replacements)) === false) { + // Attempt cleanup before erroring + if (file_exists($headerFile)) unlink($headerFile); + self::sendError('Fehler beim Erstellen der Footer-Datei.'); + } + // --- Generate PDF --- + try { + // Use the correct path for your PDF_MAIN template + $pdf = new PdfForm("WarehouseOffer/PDF_MAIN", $pdf_vars); + // Construct options for wkhtmltopdf + // Adjust margins as needed (T=Top, R=Right, B=Bottom, L=Left) + $options = "--header-html {$headerFile} --footer-html {$footerFile}"; - exit; + $filename = $pdf->render($options); // Pass options to render + + } catch (\Exception $e) { + // Log the error for debugging + error_log("PDF Generation Error: " . $e->getMessage()); + // Attempt cleanup before erroring + if (file_exists($headerFile)) unlink($headerFile); + if (file_exists($footerFile)) unlink($footerFile); + self::sendError('Fehler beim Erstellen des PDFs: ' . $e->getMessage()); + exit; // Ensure script stops + } + + // --- Clean up temporary files --- +// if (file_exists($headerFile)) unlink($headerFile); +// if (file_exists($footerFile)) unlink($footerFile); + + // --- Output or Return Filename --- + if ($returnFilename === true) { + return $filename; + } + + if (!file_exists($filename)) { + self::sendError('Generierte PDF-Datei nicht gefunden.'); + } + + // Send PDF to browser + header('Content-Type: application/pdf'); + // Use offer number in filename if available + $outputFilename = ($offer->offerNumber ?? $offer->id) . ".pdf"; + header('Content-Disposition: inline; filename="' . basename($outputFilename) . '"'); + header('Content-Length: ' . filesize($filename)); // Good practice + readfile($filename); + // Optionally delete the generated PDF after sending if it's temporary + // unlink($filename); + exit; // Crucial to prevent further output + } + + protected function generateTemplate(string $templateName, array $replacements): string + { + // Example Implementation (Very Basic - Adapt!) + // Assumes templates are in a specific directory and use {{ key }} placeholders + $templatePath = BASEDIR . "/Layout/default/" . $templateName . ".html"; // Adjust path as needed + if (!file_exists($templatePath)) { + self::sendError('Template nicht gefunden: ' . $templatePath); + } else { + $content = file_get_contents($templatePath); + } + + foreach ($replacements as $key => $value) { + $content = str_replace('{{ ' . $key . ' }}', $value ?? '', $content); + } + return $content; } + } diff --git a/public/js/pages/WarehouseOffer/WarehouseOffer.js b/public/js/pages/WarehouseOffer/WarehouseOffer.js index c2dca7eec..b73997446 100644 --- a/public/js/pages/WarehouseOffer/WarehouseOffer.js +++ b/public/js/pages/WarehouseOffer/WarehouseOffer.js @@ -233,7 +233,10 @@ Vue.component('warehouse-offer', { - + `,