prepare('SELECT * FROM quotations WHERE id = ?'); $stmt->execute([$id]); $quotation = $stmt->fetch(); if (!$quotation) { header('Content-Type: application/json; charset=utf-8'); errorResponse('Nabídka nebyla nalezena', 404); } $customer = null; if ($quotation['customer_id']) { $stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); $stmt->execute([$quotation['customer_id']]); $customer = $stmt->fetch(); } $stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); $stmt->execute([$id]); $items = $stmt->fetchAll(); $stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); $stmt->execute([$id]); $sections = $stmt->fetchAll(); $stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1'); $settings = $stmt->fetch(); $logoBase64 = ''; $logoMime = 'image/png'; if (!empty($settings['logo_data'])) { $logoBase64 = base64_encode($settings['logo_data']); $finfo = new finfo(FILEINFO_MIME_TYPE); $detected = $finfo->buffer($settings['logo_data']); $allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; if ($detected && in_array($detected, $allowedMimes)) { $logoMime = $detected; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- $isCzech = ($quotation['language'] ?? 'EN') === 'CZ'; $currency = $quotation['currency'] ?? 'EUR'; $translations = [ 'title' => ['EN' => 'PRICE QUOTATION', 'CZ' => 'CENOVÁ NABÍDKA'], 'scope_title' => ['EN' => 'SCOPE OF THE PROJECT', 'CZ' => 'ROZSAH PROJEKTU'], 'valid_until' => ['EN' => 'Valid until', 'CZ' => 'Platnost do'], 'customer' => ['EN' => 'Customer', 'CZ' => 'Zákazník'], 'supplier' => ['EN' => 'Supplier', 'CZ' => 'Dodavatel'], 'no' => ['EN' => 'N.', 'CZ' => 'Č.'], 'description' => ['EN' => 'Description', 'CZ' => 'Popis'], 'qty' => ['EN' => 'Qty', 'CZ' => 'Mn.'], 'unit_price' => ['EN' => 'Unit Price', 'CZ' => 'Jedn. cena'], 'included' => ['EN' => 'Included', 'CZ' => 'Zahrnuto'], 'total' => ['EN' => 'Total', 'CZ' => 'Celkem'], 'subtotal' => ['EN' => 'Subtotal', 'CZ' => 'Mezisoučet'], 'vat' => ['EN' => 'VAT', 'CZ' => 'DPH'], 'total_to_pay' => ['EN' => 'Total to pay', 'CZ' => 'Celkem k úhradě'], 'exchange_rate' => ['EN' => 'Exchange rate', 'CZ' => 'Směnný kurz'], 'ico' => ['EN' => 'ID', 'CZ' => 'IČO'], 'dic' => ['EN' => 'VAT ID', 'CZ' => 'DIČ'], 'yes' => ['EN' => 'Yes', 'CZ' => 'Ano'], 'no_val' => ['EN' => 'No', 'CZ' => 'Ne'], 'page' => ['EN' => 'Page', 'CZ' => 'Strana'], 'of' => ['EN' => 'of', 'CZ' => 'z'], ]; $lang = $isCzech ? 'CZ' : 'EN'; $t = function ($key) use ($translations, $lang) { return $translations[$key][$lang] ?? $key; }; $formatNum = function ($number, $decimals, $decSep = ',', $thousandSep = "\xC2\xA0") { $fixed = number_format(abs($number), $decimals, '.', ''); $parts = explode('.', $fixed); $intPart = preg_replace('/\B(?=(\d{3})+(?!\d))/', $thousandSep, $parts[0]); $result = isset($parts[1]) ? $intPart . $decSep . $parts[1] : $intPart; return $number < 0 ? '-' . $result : $result; }; $formatCurrency = function ($amount) use ($currency, $formatNum) { $n = floatval($amount); switch ($currency) { case 'EUR': return $formatNum($n, 2, ',', "\xC2\xA0") . ' €'; case 'USD': return '$' . $formatNum($n, 2, '.', ','); case 'CZK': return $formatNum($n, 2, ',', "\xC2\xA0") . ' Kč'; case 'GBP': return '£' . $formatNum($n, 2, '.', ','); default: return $formatNum($n, 2, ',', "\xC2\xA0") . ' ' . $currency; } }; $formatDate = function ($dateStr) { if (!$dateStr) { return ''; } $d = strtotime($dateStr); if ($d === false) { return $dateStr; } return date('d.m.Y', $d); }; $esc = function ($str) { return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8'); }; $buildAddressLines = function ($entity, $isSupplier) use ($t) { if (!$entity) { return ['name' => '', 'lines' => []]; } $nameKey = $isSupplier ? 'company_name' : 'name'; $name = $entity[$nameKey] ?? ''; $cfData = []; $fieldOrder = null; $raw = $entity['custom_fields'] ?? null; if ($raw) { $parsed = is_string($raw) ? json_decode($raw, true) : $raw; if (isset($parsed['fields'])) { $cfData = $parsed['fields'] ?? []; $fieldOrder = $parsed['field_order'] ?? $parsed['fieldOrder'] ?? null; } elseif (is_array($parsed) && isset($parsed[0])) { $cfData = $parsed; } } // Zpetna kompatibilita - stare DB zaznamy maji PascalCase klice if (is_array($fieldOrder)) { $legacyMap = [ 'Name' => 'name', 'CompanyName' => 'company_name', 'Street' => 'street', 'CityPostal' => 'city_postal', 'Country' => 'country', 'CompanyId' => 'company_id', 'VatId' => 'vat_id', ]; $fieldOrder = array_map(fn ($k) => $legacyMap[$k] ?? $k, $fieldOrder); } $fieldMap = []; if ($name) { $fieldMap[$nameKey] = $name; } if (!empty($entity['street'])) { $fieldMap['street'] = $entity['street']; } $cityParts = array_filter([$entity['city'] ?? '', $entity['postal_code'] ?? '']); $cityPostal = trim(implode(' ', $cityParts)); if ($cityPostal) { $fieldMap['city_postal'] = $cityPostal; } if (!empty($entity['country'])) { $fieldMap['country'] = $entity['country']; } if (!empty($entity['company_id'])) { $fieldMap['company_id'] = $t('ico') . ': ' . $entity['company_id']; } if (!empty($entity['vat_id'])) { $fieldMap['vat_id'] = $t('dic') . ': ' . $entity['vat_id']; } foreach ($cfData as $i => $cf) { $cfName = trim($cf['name'] ?? ''); $cfValue = trim($cf['value'] ?? ''); $showLabel = $cf['showLabel'] ?? true; if ($cfValue) { $fieldMap["custom_{$i}"] = ($showLabel && $cfName) ? "{$cfName}: {$cfValue}" : $cfValue; } } $lines = []; if (is_array($fieldOrder) && count($fieldOrder)) { foreach ($fieldOrder as $key) { if ($key === $nameKey) { continue; } if (isset($fieldMap[$key])) { $lines[] = $fieldMap[$key]; } } foreach ($fieldMap as $key => $line) { if ($key === $nameKey) { continue; } if (!in_array($key, $fieldOrder)) { $lines[] = $line; } } } else { foreach ($fieldMap as $key => $line) { if ($key === $nameKey) { continue; } $lines[] = $line; } } return ['name' => $name, 'lines' => $lines]; }; $logoImg = ''; if ($logoBase64) { $logoImg = ''; } $subtotal = 0; foreach ($items as $item) { $included = $item['is_included_in_total'] ?? 1; if ($included) { $subtotal += (floatval($item['quantity']) ?: 0) * (floatval($item['unit_price']) ?: 0); } } $applyVat = !empty($quotation['apply_vat']); $vatRate = floatval($quotation['vat_rate'] ?? 21) ?: 21; $vatAmount = $applyVat ? $subtotal * ($vatRate / 100) : 0; $totalToPay = $subtotal + $vatAmount; $exchangeRate = floatval($quotation['exchange_rate'] ?? 0); $vatMode = $applyVat ? 'standard' : 'exempt'; $hasScopeContent = false; foreach ($sections as $s) { if (trim($s['content'] ?? '') || trim($s['title'] ?? '')) { $hasScopeContent = true; break; } } $sectionTitle = function ($section) use ($isCzech) { if ($isCzech && !empty(trim($section['title_cz'] ?? ''))) { return $section['title_cz']; } return $section['title'] ?? ''; }; /** * Merge adjacent tags with identical attributes produced by Quill. * Quill sometimes splits a single word across multiple elements * when cursor operations occur mid-word, creating spurious line-break * opportunities in the PDF renderer. */ $cleanQuillHtml = function (string $html): string { // Sanitizace - povolit jen bezpecne HTML tagy z Quill editoru $allowedTags = '