*/ function getAllowedMimes(): array { return ['application/pdf', 'image/jpeg', 'image/png']; } // --- Stats --- 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')))); $monthStart = sprintf('%04d-%02d-01', $year, $month); $monthEnd = date('Y-m-t', strtotime($monthStart)); // Celkem v měsíci (issue_date) $stmt = $pdo->prepare(' SELECT currency, SUM(amount) as total, SUM(vat_amount) as vat_total, COUNT(*) as cnt FROM received_invoices WHERE issue_date BETWEEN ? AND ? GROUP BY currency '); $stmt->execute([$monthStart, $monthEnd]); $monthRows = $stmt->fetchAll(); $totalAmounts = []; $vatAmounts = []; $czkItems = []; $vatCzkItems = []; $monthCount = 0; foreach ($monthRows as $r) { $totalAmounts[$r['currency']] = round((float) $r['total'], 2); $vatAmounts[$r['currency']] = round((float) $r['vat_total'], 2); $monthCount += (int) $r['cnt']; $czkItems[] = [ 'amount' => round((float) $r['total'], 2), 'currency' => $r['currency'], 'date' => $monthStart, ]; $vatCzkItems[] = [ 'amount' => round((float) $r['vat_total'], 2), 'currency' => $r['currency'], 'date' => $monthStart, ]; } $totalArr = []; foreach ($totalAmounts as $cur => $amt) { $totalArr[] = ['amount' => $amt, 'currency' => $cur]; } $vatArr = []; foreach ($vatAmounts as $cur => $amt) { $vatArr[] = ['amount' => $amt, 'currency' => $cur]; } // Neuhrazeno celkově $stmt = $pdo->prepare(' SELECT currency, SUM(amount) as total, COUNT(*) as cnt FROM received_invoices WHERE status = ? GROUP BY currency '); $stmt->execute(['unpaid']); $unpaidRows = $stmt->fetchAll(); $unpaidAmounts = []; $unpaidCzkItems = []; $unpaidCount = 0; foreach ($unpaidRows as $r) { $unpaidAmounts[] = ['amount' => round((float) $r['total'], 2), 'currency' => $r['currency']]; $unpaidCount += (int) $r['cnt']; $unpaidCzkItems[] = [ 'amount' => round((float) $r['total'], 2), 'currency' => $r['currency'], 'date' => date('Y-m-d'), ]; } $cnb = CnbRates::getInstance(); successResponse([ 'total_month' => $totalArr, 'total_month_czk' => $cnb->sumToCzk($czkItems), 'vat_month' => $vatArr, 'vat_month_czk' => $cnb->sumToCzk($vatCzkItems), 'unpaid' => $unpaidAmounts, 'unpaid_czk' => $cnb->sumToCzk($unpaidCzkItems), 'unpaid_count' => $unpaidCount, 'month_count' => $monthCount, 'month' => $month, 'year' => $year, ]); } // --- List --- function handleGetList(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')))); $search = trim($_GET['search'] ?? ''); $sort = $_GET['sort'] ?? 'created_at'; $order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; $sortMap = [ 'supplier_name' => 'supplier_name', 'invoice_number' => 'invoice_number', 'status' => 'status', 'issue_date' => 'issue_date', 'due_date' => 'due_date', 'amount' => 'amount', 'created_at' => 'created_at', ]; if (!isset($sortMap[$sort])) { errorResponse('Neplatný parametr řazení', 400); } $sortCol = $sortMap[$sort]; $where = 'WHERE month = ? AND year = ?'; $params = [$month, $year]; if ($search) { $search = mb_substr($search, 0, 100); $where .= ' AND (supplier_name LIKE ? OR invoice_number LIKE ?)'; $searchParam = "%{$search}%"; $params[] = $searchParam; $params[] = $searchParam; } $sql = " SELECT id, supplier_name, invoice_number, description, amount, currency, vat_rate, vat_amount, issue_date, due_date, paid_date, status, file_name, file_mime, file_size, notes, created_at, modified_at FROM received_invoices $where ORDER BY $sortCol $order "; $stmt = $pdo->prepare($sql); $stmt->execute($params); $invoices = $stmt->fetchAll(); successResponse(['invoices' => $invoices]); } // --- Detail --- function handleGetDetail(PDO $pdo, int $id): void { $stmt = $pdo->prepare(' SELECT id, supplier_name, invoice_number, description, amount, currency, vat_rate, vat_amount, issue_date, due_date, paid_date, status, file_name, file_mime, file_size, notes, uploaded_by, created_at, modified_at FROM received_invoices WHERE id = ? '); $stmt->execute([$id]); $invoice = $stmt->fetch(); if (!$invoice) { errorResponse('Přijatá faktura nebyla nalezena', 404); } successResponse($invoice); } // --- File streaming --- function handleGetFile(PDO $pdo, int $id): void { $stmt = $pdo->prepare('SELECT file_data, file_name, file_mime, file_size FROM received_invoices WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(); if (!$row || !$row['file_data']) { errorResponse('Soubor nebyl nalezen', 404); } $safeFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['file_name'])); header('Content-Type: ' . $row['file_mime']); header('Content-Disposition: inline; filename="' . $safeFilename . '"'); header('Content-Length: ' . $row['file_size']); header_remove('X-Content-Type-Options'); echo $row['file_data']; exit(); } // --- Bulk upload --- /** @param array $authData */ function handleBulkUpload(PDO $pdo, array $authData): void { $invoicesJson = $_POST['invoices'] ?? '[]'; $invoicesMeta = json_decode($invoicesJson, true); if (!is_array($invoicesMeta)) { errorResponse('Neplatná metadata'); } if (count($invoicesMeta) === 0) { errorResponse('Žádné faktury k nahrání'); } if (count($invoicesMeta) > 20) { errorResponse('Maximálně 20 faktur najednou'); } $files = $_FILES['files'] ?? []; $fileCount = is_array($files['tmp_name'] ?? null) ? count($files['tmp_name']) : 0; if ($fileCount !== count($invoicesMeta)) { errorResponse('Počet souborů neodpovídá počtu metadat'); } $allowedMimes = getAllowedMimes(); $validCurrencies = ['CZK', 'EUR', 'USD', 'GBP']; $validVatRates = [0, 10, 12, 15, 21]; $pdo->beginTransaction(); try { $created = []; $stmt = $pdo->prepare(' INSERT INTO received_invoices ( month, year, supplier_name, invoice_number, description, amount, currency, vat_rate, vat_amount, issue_date, due_date, status, file_data, file_name, file_mime, file_size, notes, uploaded_by ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); for ($i = 0; $i < $fileCount; $i++) { $meta = $invoicesMeta[$i]; $tmpName = $files['tmp_name'][$i]; $fileError = $files['error'][$i]; $fileSize = $files['size'][$i]; $fileName = $files['name'][$i]; if ($fileError !== UPLOAD_ERR_OK) { errorResponse("Chyba při nahrávání souboru #" . ($i + 1)); } if ($fileSize > 10 * 1024 * 1024) { errorResponse("Soubor #" . ($i + 1) . " je větší než 10 MB"); } $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($tmpName); if (!in_array($mime, $allowedMimes)) { errorResponse("Soubor #" . ($i + 1) . ": nepodporovaný formát (povoleno: PDF, JPEG, PNG)"); } $supplierName = trim($meta['supplier_name'] ?? ''); if ($supplierName === '') { errorResponse("Faktura #" . ($i + 1) . ": dodavatel je povinný"); } if (mb_strlen($supplierName) > 255) { errorResponse("Faktura #" . ($i + 1) . ": název dodavatele je příliš dlouhý"); } $amount = (float) ($meta['amount'] ?? 0); if ($amount <= 0) { errorResponse("Faktura #" . ($i + 1) . ": částka musí být větší než 0"); } $currency = trim($meta['currency'] ?? 'CZK'); if (!in_array($currency, $validCurrencies)) { errorResponse("Faktura #" . ($i + 1) . ": neplatná měna"); } $vatRate = (float) ($meta['vat_rate'] ?? 21); if (!in_array((int) $vatRate, $validVatRates)) { errorResponse("Faktura #" . ($i + 1) . ": neplatná sazba DPH"); } $vatAmount = round($amount * $vatRate / 100, 2); $invoiceNumber = trim($meta['invoice_number'] ?? ''); $description = trim($meta['description'] ?? ''); $issueDate = trim($meta['issue_date'] ?? ''); $dueDate = trim($meta['due_date'] ?? ''); $notes = trim($meta['notes'] ?? ''); // Validace dat if ($issueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $issueDate) || !strtotime($issueDate))) { errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data vystavení"); } if ($dueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dueDate) || !strtotime($dueDate))) { errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data splatnosti"); } // Délkové limity if (mb_strlen($invoiceNumber) > 100) { errorResponse("Faktura #" . ($i + 1) . ": číslo faktury je příliš dlouhé"); } if (mb_strlen($description) > 500) { errorResponse("Faktura #" . ($i + 1) . ": popis je příliš dlouhý"); } if (mb_strlen($notes) > 5000) { errorResponse("Faktura #" . ($i + 1) . ": poznámka je příliš dlouhá"); } // Určit month/year z issue_date nebo aktuální if ($issueDate) { $dt = new DateTime($issueDate); $month = (int) $dt->format('n'); $year = (int) $dt->format('Y'); } else { $month = (int) date('n'); $year = (int) date('Y'); } $fileData = file_get_contents($tmpName); $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName)); $stmt->execute([ $month, $year, $supplierName, $invoiceNumber ?: null, $description ?: null, $amount, $currency, $vatRate, $vatAmount, $issueDate ?: null, $dueDate ?: null, 'unpaid', $fileData, $safeName, $mime, $fileSize, $notes ?: null, $authData['user_id'], ]); $created[] = (int) $pdo->lastInsertId(); } $pdo->commit(); AuditLog::logCreate('received_invoices', $created[0], [ 'count' => count($created), 'ids' => $created, ], 'Nahráno ' . count($created) . ' přijatých faktur'); successResponse(['ids' => $created], 'Faktury byly nahrány'); } catch (PDOException $e) { $pdo->rollBack(); throw $e; } } // --- Update --- function handleUpdateReceivedInvoice(PDO $pdo, int $id): void { $stmt = $pdo->prepare( 'SELECT id, supplier_name, invoice_number, description, amount, currency, vat_rate, vat_amount, issue_date, due_date, paid_date, status, notes FROM received_invoices WHERE id = ?' ); $stmt->execute([$id]); $invoice = $stmt->fetch(); if (!$invoice) { errorResponse('Přijatá faktura nebyla nalezena', 404); } $input = getJsonInput(); $updates = []; $params = []; $stringFields = [ 'supplier_name' => 255, 'invoice_number' => 100, 'description' => 500, 'notes' => 5000, ]; foreach ($stringFields as $field => $maxLen) { if (array_key_exists($field, $input)) { $val = trim((string) $input[$field]); if ($field === 'supplier_name' && $val === '') { errorResponse('Dodavatel je povinný'); } if (mb_strlen($val) > $maxLen) { errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)"); } $updates[] = "$field = ?"; $params[] = $val ?: null; } } if (array_key_exists('amount', $input)) { $amount = (float) $input['amount']; if ($amount <= 0) { errorResponse('Částka musí být větší než 0'); } $updates[] = 'amount = ?'; $params[] = $amount; } if (array_key_exists('currency', $input)) { if (!in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) { errorResponse('Neplatná měna'); } $updates[] = 'currency = ?'; $params[] = $input['currency']; } if (array_key_exists('vat_rate', $input)) { $vatRate = (float) $input['vat_rate']; if (!in_array((int) $vatRate, [0, 10, 12, 15, 21])) { errorResponse('Neplatná sazba DPH'); } $updates[] = 'vat_rate = ?'; $params[] = $vatRate; $amount = (float) ($input['amount'] ?? $invoice['amount']); $updates[] = 'vat_amount = ?'; $params[] = round($amount * $vatRate / 100, 2); } elseif (array_key_exists('amount', $input)) { $vatRate = (float) ($input['vat_rate'] ?? $invoice['vat_rate']); $updates[] = 'vat_amount = ?'; $params[] = round((float) $input['amount'] * $vatRate / 100, 2); } foreach (['issue_date', 'due_date'] as $dateField) { if (array_key_exists($dateField, $input)) { $val = trim((string) $input[$dateField]); if ($val && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $val) || !strtotime($val))) { errorResponse("Neplatný formát data: $dateField"); } $updates[] = "$dateField = ?"; $params[] = $val ?: null; } } // Aktualizace month/year pokud se změní issue_date if (array_key_exists('issue_date', $input) && $input['issue_date']) { $dt = new DateTime($input['issue_date']); $updates[] = 'month = ?'; $params[] = (int) $dt->format('n'); $updates[] = 'year = ?'; $params[] = (int) $dt->format('Y'); } // Změna stavu - pouze unpaid -> paid (jednosmerny prechod) if (array_key_exists('status', $input)) { $newStatus = $input['status']; if (!in_array($newStatus, ['unpaid', 'paid'])) { errorResponse('Neplatný stav'); } if ($invoice['status'] === 'paid' && $newStatus !== 'paid') { errorResponse('Uhrazenou fakturu nelze vrátit do stavu neuhrazená'); } if ($newStatus !== $invoice['status']) { $updates[] = 'status = ?'; $params[] = $newStatus; if ($newStatus === 'paid') { $updates[] = 'paid_date = CURDATE()'; } } } if (empty($updates)) { errorResponse('Žádné změny k uložení'); } $updates[] = 'modified_at = NOW()'; $params[] = $id; $sql = 'UPDATE received_invoices SET ' . implode(', ', $updates) . ' WHERE id = ?'; $pdo->prepare($sql)->execute($params); AuditLog::logUpdate( 'received_invoices', $id, ['status' => $invoice['status']], ['status' => $input['status'] ?? $invoice['status']], "Aktualizována přijatá faktura #{$id}" ); successResponse(null, 'Faktura byla aktualizována'); } // --- Delete --- function handleDeleteReceivedInvoice(PDO $pdo, int $id): void { $stmt = $pdo->prepare('SELECT id, supplier_name, invoice_number FROM received_invoices WHERE id = ?'); $stmt->execute([$id]); $invoice = $stmt->fetch(); if (!$invoice) { errorResponse('Přijatá faktura nebyla nalezena', 404); } $pdo->prepare('DELETE FROM received_invoices WHERE id = ?')->execute([$id]); AuditLog::logDelete('received_invoices', $id, [ 'supplier_name' => $invoice['supplier_name'], 'invoice_number' => $invoice['invoice_number'], ], "Smazána přijatá faktura #{$id}"); successResponse(null, 'Faktura byla smazána'); }