- Handler funkce extrahovany z API souboru do api/admin/handlers/ - config.php rozdeleny na helpers.php (funkce) a constants.php (konstanty) - require_once odstranen z class souboru (AuditLog, JWTAuth, LeaveNotification) - vendor/autoload.php presunuto do config.php bootstrap - totp-handlers.php: pridany use deklarace pro TwoFactorAuth - phpstan.neon: bootstrapFiles, scanDirectories, dynamicConstantNames - Opraveny chybejici routing bloky v totp.php a session.php Vysledek: phpcs 0 errors 0 warnings, PHPStan 0 errors, ESLint 0 errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
507 lines
16 KiB
PHP
507 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** @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');
|
|
}
|