getMessage()); if (DEBUG_MODE) { errorResponse('Chyba databáze: ' . $e->getMessage(), 500); } else { errorResponse('Chyba databáze', 500); } } function handleGetList(PDO $pdo): void { $search = trim($_GET['search'] ?? ''); $sort = $_GET['sort'] ?? 'created_at'; $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; $page = max(1, (int) ($_GET['page'] ?? 1)); $perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500))); $sortMap = [ 'Date' => 'q.created_at', 'CreatedAt' => 'q.created_at', 'created_at' => 'q.created_at', 'QuotationNumber' => 'q.quotation_number', 'quotation_number' => 'q.quotation_number', 'ProjectCode' => 'q.project_code', 'project_code' => 'q.project_code', 'ValidUntil' => 'q.valid_until', 'valid_until' => 'q.valid_until', 'Currency' => 'q.currency', 'currency' => 'q.currency', ]; if (!isset($sortMap[$sort])) { errorResponse('Neplatný parametr řazení', 400); } $sortCol = $sortMap[$sort]; $where = 'WHERE 1=1'; $params = []; if ($search) { $search = mb_substr($search, 0, 100); $where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; $searchParam = "%{$search}%"; $params = [$searchParam, $searchParam, $searchParam]; } // Celkovy pocet pro pagination $countSql = " SELECT COUNT(*) FROM quotations q LEFT JOIN customers c ON q.customer_id = c.id $where "; $stmt = $pdo->prepare($countSql); $stmt->execute($params); $total = (int) $stmt->fetchColumn(); $offset = ($page - 1) * $perPage; $sql = " SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until, q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate, q.customer_id, q.order_id, q.status, c.name as customer_name, (SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0) FROM quotation_items qi WHERE qi.quotation_id = q.id) as total FROM quotations q LEFT JOIN customers c ON q.customer_id = c.id $where ORDER BY $sortCol $order LIMIT $perPage OFFSET $offset "; $stmt = $pdo->prepare($sql); $stmt->execute($params); $quotations = $stmt->fetchAll(); successResponse([ 'quotations' => $quotations, 'total' => $total, 'page' => $page, 'per_page' => $perPage, ]); } function handleGetDetail(PDO $pdo, int $id): void { $stmt = $pdo->prepare(' SELECT q.*, c.name as customer_name FROM quotations q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = ? '); $stmt->execute([$id]); $quotation = $stmt->fetch(); if (!$quotation) { errorResponse('Nabídka nebyla nalezena', 404); } // Get items $stmt = $pdo->prepare(' SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position '); $stmt->execute([$id]); $quotation['items'] = $stmt->fetchAll(); // Get scope sections $stmt = $pdo->prepare(' SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position '); $stmt->execute([$id]); $quotation['sections'] = $stmt->fetchAll(); // Get customer if ($quotation['customer_id']) { $stmt = $pdo->prepare( 'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields FROM customers WHERE id = ?' ); $stmt->execute([$quotation['customer_id']]); $quotation['customer'] = $stmt->fetch(); } // Get linked order info if ($quotation['order_id']) { $stmt = $pdo->prepare('SELECT id, order_number, status FROM orders WHERE id = ?'); $stmt->execute([$quotation['order_id']]); $quotation['order'] = $stmt->fetch() ?: null; } else { $quotation['order'] = null; } successResponse($quotation); } function handleGetNextNumber(PDO $pdo): void { $settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch(); if (!$settings) { errorResponse('Nastavení firmy nenalezeno'); } $year = date('Y'); $prefix = $settings['quotation_prefix'] ?: 'N'; $number = getMaxQuotationNumber($pdo, $year, $prefix) + 1; $formatted = sprintf('%s/%s/%03d', $year, $prefix, $number); successResponse([ 'number' => $formatted, 'raw_number' => $number, 'prefix' => $prefix, 'year' => $year, ]); } function getMaxQuotationNumber(PDO $pdo, string $year, string $prefix): int { $likePattern = "{$year}/{$prefix}/%"; $stmt = $pdo->prepare(" SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) FROM quotations WHERE quotation_number LIKE ? "); $stmt->execute([$likePattern]); return (int) $stmt->fetchColumn(); } function generateNextNumber(PDO $pdo): string { $settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch(); $year = date('Y'); $prefix = $settings['quotation_prefix'] ?: 'N'; $number = getMaxQuotationNumber($pdo, $year, $prefix) + 1; return sprintf('%s/%s/%03d', $year, $prefix, $number); } /** @param array $q */ function validateQuotationInput(array $q): void { if (empty($q['customer_id'])) { errorResponse('Vyberte zákazníka'); } if (empty($q['created_at'])) { errorResponse('Zadejte datum vytvoření'); } if (empty($q['valid_until'])) { errorResponse('Zadejte datum platnosti'); } if (!empty($q['created_at']) && !empty($q['valid_until']) && $q['valid_until'] < $q['created_at']) { errorResponse('Datum platnosti nesmí být před datem vytvoření'); } if (empty($q['currency'])) { errorResponse('Vyberte měnu'); } // Validace formatu dat foreach (['created_at', 'valid_until'] as $dateField) { if (!empty($q[$dateField]) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $q[$dateField])) { errorResponse("Neplatný formát data: $dateField"); } } // Validace meny a jazyka if (!in_array($q['currency'] ?? '', ['EUR', 'USD', 'CZK', 'GBP'])) { errorResponse('Neplatná měna'); } if (!empty($q['language']) && !in_array($q['language'], ['EN', 'CZ'])) { errorResponse('Neplatný jazyk'); } // Validace DPH if (isset($q['vat_rate'])) { $rate = floatval($q['vat_rate']); if ($rate < 0 || $rate > 100) { errorResponse('Sazba DPH musí být mezi 0 a 100'); } } // Delkove limity if (!empty($q['project_code']) && mb_strlen($q['project_code']) > 100) { errorResponse('Kód projektu je příliš dlouhý (max 100 znaků)'); } } function handleCreateOffer(PDO $pdo): void { $input = getJsonInput(); $quotation = $input['quotation'] ?? $input; $items = $input['items'] ?? []; $sections = $input['sections'] ?? []; validateQuotationInput($quotation); // Serialize number generation across concurrent requests $locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn(); if (!$locked) { errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503); } $pdo->beginTransaction(); try { $quotationNumber = generateNextNumber($pdo); $stmt = $pdo->prepare(' INSERT INTO quotations ( quotation_number, project_code, customer_id, created_at, valid_until, currency, language, vat_rate, apply_vat, exchange_rate, scope_title, scope_description, modified_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) '); $stmt->execute([ $quotationNumber, $quotation['project_code'] ?? '', $quotation['customer_id'] ? (int)$quotation['customer_id'] : null, $quotation['created_at'] ?? date('Y-m-d H:i:s'), $quotation['valid_until'] ?? date('Y-m-d H:i:s', strtotime('+30 days')), $quotation['currency'] ?? 'EUR', $quotation['language'] ?? 'EN', $quotation['vat_rate'] ?? 21, isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : 0, $quotation['exchange_rate'] ?? null, $quotation['scope_title'] ?? '', $quotation['scope_description'] ?? '', ]); $quotationId = (int)$pdo->lastInsertId(); saveItems($pdo, $quotationId, $items); saveSections($pdo, $quotationId, $sections); $pdo->commit(); $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); AuditLog::logCreate('offers_quotation', $quotationId, [ 'quotation_number' => $quotationNumber, 'project_code' => $quotation['project_code'] ?? '', ], "Vytvořena nabídka '$quotationNumber'"); successResponse([ 'id' => $quotationId, 'number' => $quotationNumber, ], 'Nabídka byla vytvořena'); } catch (PDOException $e) { $pdo->rollBack(); $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); throw $e; } } function handleUpdateOffer(PDO $pdo, int $id): void { $stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); $stmt->execute([$id]); $existing = $stmt->fetch(); if (!$existing) { errorResponse('Nabídka nebyla nalezena', 404); } if ($existing['status'] === 'invalidated') { errorResponse('Zneplatněnou nabídku nelze upravovat', 403); } $input = getJsonInput(); $quotation = $input['quotation'] ?? $input; $items = $input['items'] ?? []; $sections = $input['sections'] ?? []; validateQuotationInput($quotation); $pdo->beginTransaction(); try { $stmt = $pdo->prepare(' UPDATE quotations SET project_code = ?, customer_id = ?, created_at = ?, valid_until = ?, currency = ?, language = ?, vat_rate = ?, apply_vat = ?, exchange_rate = ?, scope_title = ?, scope_description = ?, modified_at = NOW() WHERE id = ? '); $stmt->execute([ $quotation['project_code'] ?? $existing['project_code'], isset($quotation['customer_id']) ? ($quotation['customer_id'] ? (int)$quotation['customer_id'] : null) : $existing['customer_id'], $quotation['created_at'] ?? $existing['created_at'], $quotation['valid_until'] ?? $existing['valid_until'], $quotation['currency'] ?? $existing['currency'], $quotation['language'] ?? $existing['language'], $quotation['vat_rate'] ?? $existing['vat_rate'], isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : $existing['apply_vat'], array_key_exists('exchange_rate', $quotation) ? $quotation['exchange_rate'] : $existing['exchange_rate'], $quotation['scope_title'] ?? $existing['scope_title'], $quotation['scope_description'] ?? $existing['scope_description'], $id, ]); // Replace items $stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?'); $stmt->execute([$id]); saveItems($pdo, $id, $items); // Replace sections $stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?'); $stmt->execute([$id]); saveSections($pdo, $id, $sections); $pdo->commit(); AuditLog::logUpdate( 'offers_quotation', $id, ['quotation_number' => $existing['quotation_number']], ['project_code' => $quotation['project_code'] ?? $existing['project_code']], "Upravena nabídka '{$existing['quotation_number']}'" ); successResponse(null, 'Nabídka byla aktualizována'); } catch (PDOException $e) { $pdo->rollBack(); throw $e; } } function handleDuplicate(PDO $pdo, int $sourceId): void { $stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); $stmt->execute([$sourceId]); $source = $stmt->fetch(); if (!$source) { errorResponse('Zdrojová nabídka nebyla nalezena', 404); } $stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); $stmt->execute([$sourceId]); $sourceItems = $stmt->fetchAll(); $stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); $stmt->execute([$sourceId]); $sourceSections = $stmt->fetchAll(); $locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn(); if (!$locked) { errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503); } $pdo->beginTransaction(); try { $newNumber = generateNextNumber($pdo); $stmt = $pdo->prepare(' INSERT INTO quotations ( quotation_number, project_code, customer_id, created_at, valid_until, currency, language, vat_rate, apply_vat, exchange_rate, scope_title, scope_description, modified_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) '); $stmt->execute([ $newNumber, $source['project_code'], $source['customer_id'], date('Y-m-d H:i:s'), date('Y-m-d H:i:s', strtotime('+30 days')), $source['currency'], $source['language'], $source['vat_rate'], $source['apply_vat'], $source['exchange_rate'], $source['scope_title'], $source['scope_description'], ]); $newId = (int)$pdo->lastInsertId(); $items = array_map(function ($item) { return [ 'description' => $item['description'], 'item_description' => $item['item_description'], 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], 'is_included_in_total' => $item['is_included_in_total'], 'position' => $item['position'], ]; }, $sourceItems); saveItems($pdo, $newId, $items); $sections = array_map(function ($section) { return [ 'title' => $section['title'], 'title_cz' => $section['title_cz'], 'content' => $section['content'], 'position' => $section['position'], ]; }, $sourceSections); saveSections($pdo, $newId, $sections); $pdo->commit(); $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); AuditLog::logCreate('offers_quotation', $newId, [ 'quotation_number' => $newNumber, 'duplicated_from' => $source['quotation_number'], ], "Duplikována nabídka '{$source['quotation_number']}' jako '$newNumber'"); successResponse([ 'id' => $newId, 'number' => $newNumber, ], 'Nabídka byla duplikována'); } catch (PDOException $e) { $pdo->rollBack(); $pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')"); throw $e; } } function handleInvalidateOffer(PDO $pdo, int $id): void { $stmt = $pdo->prepare('SELECT quotation_number, status, order_id FROM quotations WHERE id = ?'); $stmt->execute([$id]); $quotation = $stmt->fetch(); if (!$quotation) { errorResponse('Nabídka nebyla nalezena', 404); } if ($quotation['status'] === 'invalidated') { errorResponse('Nabídka je již zneplatněna', 400); } if ($quotation['order_id']) { errorResponse('Nabídku s objednávkou nelze zneplatnit', 400); } $stmt = $pdo->prepare('UPDATE quotations SET status = ?, modified_at = NOW() WHERE id = ?'); $stmt->execute(['invalidated', $id]); AuditLog::logUpdate( 'offers_quotation', $id, ['status' => 'active'], ['status' => 'invalidated'], "Zneplatněna nabídka '{$quotation['quotation_number']}'" ); successResponse(null, 'Nabídka byla zneplatněna'); } function handleDeleteQuotation(PDO $pdo, int $id): void { $stmt = $pdo->prepare('SELECT quotation_number FROM quotations WHERE id = ?'); $stmt->execute([$id]); $quotation = $stmt->fetch(); if (!$quotation) { errorResponse('Nabídka nebyla nalezena', 404); } $pdo->beginTransaction(); try { $stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?'); $stmt->execute([$id]); $stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?'); $stmt->execute([$id]); $stmt = $pdo->prepare('DELETE FROM quotations WHERE id = ?'); $stmt->execute([$id]); $pdo->commit(); AuditLog::logDelete('offers_quotation', $id, [ 'quotation_number' => $quotation['quotation_number'], ], "Smazána nabídka '{$quotation['quotation_number']}'"); successResponse(null, 'Nabídka byla smazána'); } catch (PDOException $e) { $pdo->rollBack(); throw $e; } } // --- Helpers --- /** @param list> $items */ function saveItems(PDO $pdo, int $quotationId, array $items): void { if (empty($items)) { return; } $stmt = $pdo->prepare(' INSERT INTO quotation_items ( quotation_id, description, item_description, quantity, unit, unit_price, is_included_in_total, position, modified_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) '); foreach ($items as $i => $item) { $stmt->execute([ $quotationId, $item['description'] ?? '', $item['item_description'] ?? '', $item['quantity'] ?? 1, $item['unit'] ?? '', $item['unit_price'] ?? 0, isset($item['is_included_in_total']) ? ($item['is_included_in_total'] ? 1 : 0) : 1, $item['position'] ?? ($i + 1), ]); } } /** @param list> $sections */ function saveSections(PDO $pdo, int $quotationId, array $sections): void { if (empty($sections)) { return; } $stmt = $pdo->prepare(' INSERT INTO scope_sections ( quotation_id, title, title_cz, content, position, modified_at ) VALUES (?, ?, ?, ?, ?, NOW()) '); foreach ($sections as $i => $section) { $stmt->execute([ $quotationId, $section['title'] ?? '', $section['title_cz'] ?? '', $section['content'] ?? '', $section['position'] ?? ($i + 1), ]); } }