*/ function getValidTransitions(string $status): array { return match ($status) { 'issued' => ['paid'], 'overdue' => ['paid'], default => [] }; } // --- Invoice number generation --- function generateInvoiceNumber(PDO $pdo): string { $yy = date('y'); $settings = $pdo->query('SELECT invoice_type_code FROM company_settings LIMIT 1')->fetch(); $typeCode = ($settings && !empty($settings['invoice_type_code'])) ? $settings['invoice_type_code'] : '81'; $prefix = $yy . $typeCode; $prefixLen = strlen($prefix); $likePattern = $prefix . '%'; $stmt = $pdo->prepare(' SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ? + 1) AS UNSIGNED)), 0) FROM invoices WHERE invoice_number LIKE ? '); $stmt->execute([$prefixLen, $likePattern]); $max = (int) $stmt->fetchColumn(); return sprintf('%s%04d', $prefix, $max + 1); } // --- Stats --- /** * Spocita celkovou castku faktur seskupenou podle meny + CZK prepocet dle kurzu k datu faktury. * * @param array $params * @return array{amounts: array, count: int, total_czk: float} */ function sumInvoicesByCurrency(PDO $pdo, string $where, array $params): array { // Per-faktura pro presny prepocet kurzem k datu $perInvoiceSql = " SELECT i.id, i.currency, i.issue_date, COALESCE(SUM(ii.quantity * ii.unit_price), 0) + COALESCE(SUM(CASE WHEN i.apply_vat THEN ii.quantity * ii.unit_price * ii.vat_rate / 100 ELSE 0 END), 0) AS total FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id $where GROUP BY i.id, i.currency, i.issue_date "; $stmt = $pdo->prepare($perInvoiceSql); $stmt->execute($params); $rows = $stmt->fetchAll(); // Seskupit podle meny pro zobrazeni $byCurrency = []; $czkItems = []; foreach ($rows as $r) { $cur = $r['currency']; $amt = round((float) $r['total'], 2); $byCurrency[$cur] = ($byCurrency[$cur] ?? 0) + $amt; $czkItems[] = [ 'amount' => $amt, 'currency' => $cur, 'date' => $r['issue_date'], ]; } $amounts = []; foreach ($byCurrency as $cur => $total) { $amounts[] = ['amount' => round($total, 2), 'currency' => $cur]; } $cnb = CnbRates::getInstance(); $totalCzk = $cnb->sumToCzk($czkItems); $countSql = "SELECT COUNT(*) FROM invoices i $where"; $countStmt = $pdo->prepare($countSql); $countStmt->execute($params); return [ 'amounts' => $amounts, 'count' => (int) $countStmt->fetchColumn(), 'total_czk' => $totalCzk, ]; } function handleGetStats(PDO $pdo): void { $month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n')))); $year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y')))); // Lazy overdue detekce $pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()"); $monthStart = sprintf('%04d-%02d-01', $year, $month); $monthEnd = date('Y-m-t', strtotime($monthStart)); // a) Uhrazeno v danem mesici (dle data vystaveni, ne uhrazeni) $paidWhere = "WHERE i.status = 'paid' AND i.issue_date BETWEEN ? AND ?"; $paid = sumInvoicesByCurrency($pdo, $paidWhere, [$monthStart, $monthEnd]); // b) Ceka uhrada (aktualni stav) $awaiting = sumInvoicesByCurrency($pdo, "WHERE i.status = 'issued'", []); // c) Po splatnosti (aktualni stav) $overdue = sumInvoicesByCurrency($pdo, "WHERE i.status = 'overdue'", []); // d) DPH v danem mesici - per faktura pro kurz k datu $vatSql = " SELECT i.id, i.currency, i.issue_date, COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0) AS vat_total FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id WHERE i.apply_vat = 1 AND i.issue_date BETWEEN ? AND ? GROUP BY i.id, i.currency, i.issue_date "; $vatStmt = $pdo->prepare($vatSql); $vatStmt->execute([$monthStart, $monthEnd]); $vatRows = $vatStmt->fetchAll(); $vatByCurrency = []; $vatCzkItems = []; foreach ($vatRows as $r) { $cur = $r['currency']; $amt = round((float) $r['vat_total'], 2); $vatByCurrency[$cur] = ($vatByCurrency[$cur] ?? 0) + $amt; $vatCzkItems[] = [ 'amount' => $amt, 'currency' => $cur, 'date' => $r['issue_date'], ]; } $vatAmounts = []; foreach ($vatByCurrency as $cur => $total) { $vatAmounts[] = ['amount' => round($total, 2), 'currency' => $cur]; } $cnb = CnbRates::getInstance(); successResponse([ 'paid_month' => $paid['amounts'], 'paid_month_czk' => $paid['total_czk'], 'paid_month_count' => $paid['count'], 'awaiting' => $awaiting['amounts'], 'awaiting_czk' => $awaiting['total_czk'], 'awaiting_count' => $awaiting['count'], 'overdue' => $overdue['amounts'], 'overdue_czk' => $overdue['total_czk'], 'overdue_count' => $overdue['count'], 'vat_month' => $vatAmounts, 'vat_month_czk' => $cnb->sumToCzk($vatCzkItems), 'month' => $month, 'year' => $year, ]); } // --- Handlers --- function handleGetList(PDO $pdo): void { $statusFilter = trim($_GET['status'] ?? ''); $sortMap = [ 'InvoiceNumber' => 'i.invoice_number', 'invoice_number' => 'i.invoice_number', 'CreatedAt' => 'i.created_at', 'created_at' => 'i.created_at', 'Status' => 'i.status', 'status' => 'i.status', 'DueDate' => 'i.due_date', 'due_date' => 'i.due_date', 'IssueDate' => 'i.issue_date', 'issue_date' => 'i.issue_date', ]; $p = PaginationHelper::parseParams($sortMap); // Lazy overdue detekce $pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()"); $where = 'WHERE 1=1'; $params = []; if ($p['search']) { $where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)'; $searchParam = "%{$p['search']}%"; $params = array_merge($params, [$searchParam, $searchParam, $searchParam]); } if ($statusFilter) { $statuses = array_filter(explode(',', $statusFilter)); if ($statuses) { $placeholders = implode(',', array_fill(0, count($statuses), '?')); $where .= " AND i.status IN ($placeholders)"; $params = array_merge($params, $statuses); } } $from = "FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id LEFT JOIN orders o ON i.order_id = o.id"; $result = PaginationHelper::paginate( $pdo, "SELECT COUNT(*) FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id {$where}", "SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency, i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat, c.name as customer_name, (SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0) FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal, o.order_number {$from} {$where} ORDER BY {$p['sort']} {$p['order']}", $params, $p ); $invoices = $result['items']; // Dopocitat celkovou castku s DPH foreach ($invoices as &$inv) { $subtotal = (float) $inv['subtotal']; if ($inv['apply_vat']) { $vatStmt = $pdo->prepare(' SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0) FROM invoice_items WHERE invoice_id = ? '); $vatStmt->execute([$inv['id']]); $vatAmount = (float) $vatStmt->fetchColumn(); $inv['total'] = $subtotal + $vatAmount; } else { $inv['total'] = $subtotal; } } unset($inv); successResponse([ 'invoices' => $invoices, 'pagination' => $result['pagination'], ]); } function handleGetDetail(PDO $pdo, int $id): void { // Lazy overdue $pdo->prepare( "UPDATE invoices SET status = 'overdue' WHERE id = ? AND status = 'issued' AND due_date < CURDATE()" )->execute([$id]); $stmt = $pdo->prepare(' SELECT i.*, c.name as customer_name, o.order_number FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id LEFT JOIN orders o ON i.order_id = o.id WHERE i.id = ? '); $stmt->execute([$id]); $invoice = $stmt->fetch(); if (!$invoice) { errorResponse('Faktura nebyla nalezena', 404); } // Polozky $stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position'); $stmt->execute([$id]); $invoice['items'] = $stmt->fetchAll(); // Zakaznik if ($invoice['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([$invoice['customer_id']]); $invoice['customer'] = $stmt->fetch(); } $invoice['valid_transitions'] = getValidTransitions($invoice['status']); successResponse($invoice); } function handleGetNextNumber(PDO $pdo): void { $number = generateInvoiceNumber($pdo); successResponse(['number' => $number]); } function handleGetOrderData(PDO $pdo, int $id): void { $stmt = $pdo->prepare(' SELECT o.id, o.order_number, o.customer_id, o.status, o.currency, o.language, o.vat_rate, o.apply_vat, o.exchange_rate, o.created_at, o.modified_at, c.name as customer_name FROM orders o LEFT JOIN customers c ON o.customer_id = c.id WHERE o.id = ? '); $stmt->execute([$id]); $order = $stmt->fetch(); if (!$order) { errorResponse('Objednávka nebyla nalezena', 404); } // Polozky objednavky $stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position'); $stmt->execute([$id]); $order['items'] = $stmt->fetchAll(); successResponse($order); } /** @param array $authData */ function handleCreateInvoice(PDO $pdo, array $authData): void { $input = getJsonInput(); $customerId = isset($input['customer_id']) ? (int) $input['customer_id'] : null; $orderId = !empty($input['order_id']) ? (int) $input['order_id'] : null; $issueDate = trim($input['issue_date'] ?? ''); $dueDate = trim($input['due_date'] ?? ''); $taxDate = trim($input['tax_date'] ?? ''); $currency = trim($input['currency'] ?? 'CZK'); $applyVat = isset($input['apply_vat']) ? (int) $input['apply_vat'] : 1; $paymentMethod = trim($input['payment_method'] ?? 'Příkazem'); $constantSymbol = trim($input['constant_symbol'] ?? '0308'); $issuedBy = trim($input['issued_by'] ?? ''); $notes = trim($input['notes'] ?? ''); $items = $input['items'] ?? []; // Bankovni udaje $bankName = trim($input['bank_name'] ?? ''); $bankSwift = trim($input['bank_swift'] ?? ''); $bankIban = trim($input['bank_iban'] ?? ''); $bankAccount = trim($input['bank_account'] ?? ''); if (!$customerId) { errorResponse('Zákazník je povinný'); } if (!$issueDate || !$dueDate || !$taxDate) { errorResponse('Všechna data (vystavení, splatnost, DÚZP) jsou povinná'); } // Validace formatu dat foreach (['issue_date' => $issueDate, 'due_date' => $dueDate, 'tax_date' => $taxDate] as $label => $date) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { errorResponse("Neplatný formát data: $label"); } } // Validace meny $validCurrencies = ['CZK', 'EUR', 'USD', 'GBP']; if (!in_array($currency, $validCurrencies)) { errorResponse('Neplatná měna'); } // Delkove limity if (mb_strlen($paymentMethod) > 50) { errorResponse('Forma úhrady je příliš dlouhá (max 50 znaků)'); } if (mb_strlen($issuedBy) > 255) { errorResponse('Vystavil je příliš dlouhé (max 255 znaků)'); } if (mb_strlen($notes) > 5000) { errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); } if (mb_strlen($bankName) > 255) { errorResponse('Název banky je příliš dlouhý'); } if (mb_strlen($bankIban) > 50) { errorResponse('IBAN je příliš dlouhý'); } if (mb_strlen($bankSwift) > 20) { errorResponse('BIC/SWIFT je příliš dlouhý'); } if (mb_strlen($bankAccount) > 50) { errorResponse('Číslo účtu je příliš dlouhé'); } if (!$bankAccount && !$bankIban) { errorResponse('Bankovní účet je povinný'); } if (empty($items)) { errorResponse('Faktura musí mít alespoň jednu položku'); } // Validace polozek foreach ($items as $i => $item) { $qty = $item['quantity'] ?? 1; $price = $item['unit_price'] ?? 0; $vatRate = $item['vat_rate'] ?? 21; if (!is_numeric($qty) || $qty < 0) { errorResponse('Položka #' . ($i + 1) . ': neplatné množství'); } if (!is_numeric($price)) { errorResponse('Položka #' . ($i + 1) . ': neplatná cena'); } if (!is_numeric($vatRate) || $vatRate < 0 || $vatRate > 100) { errorResponse('Položka #' . ($i + 1) . ': neplatná sazba DPH'); } if (mb_strlen($item['description'] ?? '') > 500) { errorResponse('Položka #' . ($i + 1) . ': popis je příliš dlouhý (max 500 znaků)'); } } // Overit zakaznika $stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?'); $stmt->execute([$customerId]); if (!$stmt->fetch()) { errorResponse('Zákazník nebyl nalezen', 404); } // Lock pro cislovani $locked = $pdo->query("SELECT GET_LOCK('boha_invoice_number', 5)")->fetchColumn(); if (!$locked) { errorResponse('Nepodařilo se získat zámek pro číslo faktury, zkuste to znovu', 503); } $pdo->beginTransaction(); try { $invoiceNumber = !empty($input['invoice_number']) ? trim($input['invoice_number']) : generateInvoiceNumber($pdo); $stmt = $pdo->prepare(" INSERT INTO invoices ( invoice_number, order_id, customer_id, status, currency, vat_rate, apply_vat, payment_method, constant_symbol, bank_name, bank_swift, bank_iban, bank_account, issue_date, due_date, tax_date, issued_by, notes, created_at, modified_at ) VALUES (?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) "); $stmt->execute([ $invoiceNumber, $orderId, $customerId, $currency, $input['vat_rate'] ?? 21, $applyVat, $paymentMethod, $constantSymbol, $bankName, $bankSwift, $bankIban, $bankAccount, $issueDate, $dueDate, $taxDate, $issuedBy, $notes, ]); $invoiceId = (int) $pdo->lastInsertId(); // Vlozit polozky $itemStmt = $pdo->prepare(' INSERT INTO invoice_items ( invoice_id, description, quantity, unit, unit_price, vat_rate, position ) VALUES (?, ?, ?, ?, ?, ?, ?) '); foreach ($items as $i => $item) { $itemStmt->execute([ $invoiceId, trim($item['description'] ?? ''), $item['quantity'] ?? 1, trim($item['unit'] ?? ''), $item['unit_price'] ?? 0, $item['vat_rate'] ?? 21, $item['position'] ?? $i, ]); } $pdo->commit(); $pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')"); AuditLog::logCreate('invoices_invoice', $invoiceId, [ 'invoice_number' => $invoiceNumber, 'customer_id' => $customerId, 'order_id' => $orderId, ], "Vytvořena faktura '$invoiceNumber'"); successResponse([ 'invoice_id' => $invoiceId, 'invoice_number' => $invoiceNumber, ], 'Faktura byla vystavena'); } catch (PDOException $e) { $pdo->rollBack(); $pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')"); throw $e; } } function handleUpdateInvoice(PDO $pdo, int $id): void { $stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); $stmt->execute([$id]); $invoice = $stmt->fetch(); if (!$invoice) { errorResponse('Faktura nebyla nalezena', 404); } $input = getJsonInput(); $newStatus = $input['status'] ?? null; $isDraft = $invoice['status'] === 'issued'; // Zmena stavu if ($newStatus && $newStatus !== $invoice['status']) { $valid = getValidTransitions($invoice['status']); if (!in_array($newStatus, $valid)) { errorResponse("Neplatný přechod stavu z '{$invoice['status']}' na '$newStatus'"); } } $pdo->beginTransaction(); try { $updates = []; $params = []; if ($newStatus !== null && $newStatus !== $invoice['status']) { $updates[] = 'status = ?'; $params[] = $newStatus; if ($newStatus === 'paid') { $updates[] = 'paid_date = CURDATE()'; } } // V issued stavu lze editovat vsechna pole if ($isDraft) { // Validace dat foreach (['issue_date', 'due_date', 'tax_date'] as $dateField) { if ( isset($input[$dateField]) && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $input[$dateField]) || !strtotime($input[$dateField])) ) { errorResponse("Neplatný formát data: $dateField"); } } // Validace meny if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) { errorResponse('Neplatná měna'); } // Validace DPH if ( isset($input['vat_rate']) && (!is_numeric($input['vat_rate']) || $input['vat_rate'] < 0 || $input['vat_rate'] > 100) ) { errorResponse('Neplatná sazba DPH'); } // Validace zakaznika if (isset($input['customer_id'])) { $custStmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?'); $custStmt->execute([(int)$input['customer_id']]); if (!$custStmt->fetch()) { errorResponse('Zákazník nebyl nalezen', 404); } } $stringFields = [ 'issue_date' => 20, 'due_date' => 20, 'tax_date' => 20, 'payment_method' => 50, 'constant_symbol' => 10, 'bank_name' => 255, 'bank_swift' => 20, 'bank_iban' => 50, 'bank_account' => 50, 'issued_by' => 255, ]; foreach ($stringFields as $field => $maxLen) { if (array_key_exists($field, $input)) { $val = trim((string)$input[$field]); if (mb_strlen($val) > $maxLen) { errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)"); } $updates[] = "$field = ?"; $params[] = $val; } } $numericFields = ['currency', 'vat_rate', 'apply_vat', 'customer_id']; foreach ($numericFields as $field) { if (array_key_exists($field, $input)) { $updates[] = "$field = ?"; $params[] = $input[$field]; } } // Aktualizace polozek if (isset($input['items']) && is_array($input['items'])) { $pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]); $itemStmt = $pdo->prepare(' INSERT INTO invoice_items ( invoice_id, description, quantity, unit, unit_price, vat_rate, position ) VALUES (?, ?, ?, ?, ?, ?, ?) '); foreach ($input['items'] as $i => $item) { $itemStmt->execute([ $id, trim($item['description'] ?? ''), $item['quantity'] ?? 1, trim($item['unit'] ?? ''), $item['unit_price'] ?? 0, $item['vat_rate'] ?? 21, $item['position'] ?? $i, ]); } } } // Poznamky lze editovat jen v issued/overdue stavu if ($isDraft || $invoice['status'] === 'overdue') { if (array_key_exists('notes', $input)) { $updates[] = 'notes = ?'; $params[] = $input['notes']; } if (array_key_exists('internal_notes', $input)) { $updates[] = 'internal_notes = ?'; $params[] = $input['internal_notes']; } } if (!empty($updates)) { $updates[] = 'modified_at = NOW()'; $params[] = $id; $sql = 'UPDATE invoices SET ' . implode(', ', $updates) . ' WHERE id = ?'; $stmt = $pdo->prepare($sql); $stmt->execute($params); } $pdo->commit(); AuditLog::logUpdate( 'invoices_invoice', $id, ['status' => $invoice['status']], ['status' => $newStatus ?? $invoice['status']], "Aktualizována faktura '{$invoice['invoice_number']}'" ); successResponse(null, 'Faktura byla aktualizována'); } catch (PDOException $e) { $pdo->rollBack(); throw $e; } } function handleDeleteInvoice(PDO $pdo, int $id): void { $stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); $stmt->execute([$id]); $invoice = $stmt->fetch(); if (!$invoice) { errorResponse('Faktura nebyla nalezena', 404); } $pdo->beginTransaction(); try { $pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]); $pdo->prepare('DELETE FROM invoices WHERE id = ?')->execute([$id]); $pdo->commit(); AuditLog::logDelete('invoices_invoice', $id, [ 'invoice_number' => $invoice['invoice_number'], 'customer_id' => $invoice['customer_id'], ], "Smazána faktura '{$invoice['invoice_number']}'"); successResponse(null, 'Faktura byla smazána'); } catch (PDOException $e) { $pdo->rollBack(); throw $e; } }