Files
app/api/admin/received-invoices.php
2026-03-12 12:43:56 +01:00

598 lines
19 KiB
PHP

<?php
/**
* Received Invoices API - přijaté faktury (upload, CRUD, stats)
*
* GET ?action=list&month=X&year=Y - Seznam přijatých faktur
* GET ?action=stats&month=X&year=Y - KPI statistiky
* GET ?action=detail&id=X - Detail záznamu (bez BLOB)
* GET ?action=file&id=X - Stažení/zobrazení souboru
* POST (FormData) - Bulk upload: files[] + invoices JSON
* PUT ?id=X - Update metadat / změna stavu
* DELETE ?id=X - Smazání záznamu
*/
declare(strict_types=1);
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/CnbRates.php';
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
try {
$pdo = db();
switch ($method) {
case 'GET':
requirePermission($authData, 'invoices.view');
switch ($action) {
case 'stats':
handleGetStats($pdo);
break;
case 'detail':
if (!$id) {
errorResponse('ID je povinné');
}
handleGetDetail($pdo, $id);
break;
case 'file':
if (!$id) {
errorResponse('ID je povinné');
}
handleGetFile($pdo, $id);
break;
default:
handleGetList($pdo);
}
break;
case 'POST':
requirePermission($authData, 'invoices.create');
handleBulkUpload($pdo, $authData);
break;
case 'PUT':
requirePermission($authData, 'invoices.edit');
if (!$id) {
errorResponse('ID je povinné');
}
handleUpdateReceivedInvoice($pdo, $id);
break;
case 'DELETE':
requirePermission($authData, 'invoices.delete');
if (!$id) {
errorResponse('ID je povinné');
}
handleDeleteReceivedInvoice($pdo, $id);
break;
default:
errorResponse('Metoda není povolena', 405);
}
} catch (PDOException $e) {
error_log('Received Invoices API error: ' . $e->getMessage());
if (DEBUG_MODE) {
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
} else {
errorResponse('Chyba databáze', 500);
}
}
// --- Allowed MIME types ---
/** @return list<string> */
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<string, mixed> $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 * 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');
}