Initial commit
This commit is contained in:
803
api/admin/invoices.php
Normal file
803
api/admin/invoices.php
Normal file
@@ -0,0 +1,803 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Invoices CRUD API
|
||||
*
|
||||
* GET /api/admin/invoices.php - Seznam faktur
|
||||
* GET /api/admin/invoices.php?action=detail&id=X - Detail faktury
|
||||
* GET /api/admin/invoices.php?action=next_number - Dalsi cislo faktury
|
||||
* GET /api/admin/invoices.php?action=order_data&id=X - Data objednavky pro pre-fill
|
||||
* GET /api/admin/invoices.php?action=stats - KPI statistiky (month, year)
|
||||
* POST /api/admin/invoices.php - Vytvoreni faktury
|
||||
* PUT /api/admin/invoices.php?id=X - Uprava faktury / zmena stavu
|
||||
* DELETE /api/admin/invoices.php?id=X - Smazani faktury
|
||||
*/
|
||||
|
||||
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 'detail':
|
||||
if (!$id) {
|
||||
errorResponse('ID faktury je povinné');
|
||||
}
|
||||
handleGetDetail($pdo, $id);
|
||||
break;
|
||||
case 'next_number':
|
||||
requirePermission($authData, 'invoices.create');
|
||||
handleGetNextNumber($pdo);
|
||||
break;
|
||||
case 'order_data':
|
||||
requirePermission($authData, 'invoices.create');
|
||||
if (!$id) {
|
||||
errorResponse('ID objednávky je povinné');
|
||||
}
|
||||
handleGetOrderData($pdo, $id);
|
||||
break;
|
||||
case 'stats':
|
||||
requirePermission($authData, 'invoices.view');
|
||||
handleGetStats($pdo);
|
||||
break;
|
||||
default:
|
||||
handleGetList($pdo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'invoices.create');
|
||||
handleCreateInvoice($pdo, $authData);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'invoices.edit');
|
||||
if (!$id) {
|
||||
errorResponse('ID faktury je povinné');
|
||||
}
|
||||
handleUpdateInvoice($pdo, $id);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'invoices.delete');
|
||||
if (!$id) {
|
||||
errorResponse('ID faktury je povinné');
|
||||
}
|
||||
handleDeleteInvoice($pdo, $id);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Invoices API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Status transitions ---
|
||||
|
||||
/** @return list<string> */
|
||||
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<int, string|int|float> $params
|
||||
* @return array{amounts: array<int, array{amount: float, currency: string}>, 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
|
||||
{
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$statusFilter = trim($_GET['status'] ?? '');
|
||||
$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 = [
|
||||
'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',
|
||||
];
|
||||
if (!isset($sortMap[$sort])) {
|
||||
errorResponse('Neplatný parametr řazení', 400);
|
||||
}
|
||||
$sortCol = $sortMap[$sort];
|
||||
|
||||
// Lazy overdue detekce
|
||||
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
|
||||
|
||||
$where = 'WHERE 1=1';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
$where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)';
|
||||
$searchParam = "%{$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);
|
||||
}
|
||||
}
|
||||
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
$where
|
||||
";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
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 invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
LEFT JOIN orders o ON i.order_id = o.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$invoices = $stmt->fetchAll();
|
||||
|
||||
// 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,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user