refactor: odstraneni PSR-1 SideEffects warningu

- 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>
This commit is contained in:
2026-03-12 14:29:21 +01:00
parent f733dee856
commit 5ef6fc8064
45 changed files with 7105 additions and 6924 deletions

View File

@@ -18,6 +18,7 @@ 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 __DIR__ . '/handlers/offers-handlers.php';
setCorsHeaders();
setSecurityHeaders();
@@ -92,588 +93,3 @@ try {
errorResponse('Chyba databáze', 500);
}
}
function handleGetList(PDO $pdo): void
{
$search = trim($_GET['search'] ?? '');
$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 = [
'Date' => 'q.created_at',
'CreatedAt' => 'q.created_at',
'created_at' => 'q.created_at',
'QuotationNumber' => 'q.quotation_number',
'quotation_number' => 'q.quotation_number',
'ProjectCode' => 'q.project_code',
'project_code' => 'q.project_code',
'ValidUntil' => 'q.valid_until',
'valid_until' => 'q.valid_until',
'Currency' => 'q.currency',
'currency' => 'q.currency',
];
if (!isset($sortMap[$sort])) {
errorResponse('Neplatný parametr řazení', 400);
}
$sortCol = $sortMap[$sort];
$where = 'WHERE 1=1';
$params = [];
if ($search) {
$search = mb_substr($search, 0, 100);
$where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
$searchParam = "%{$search}%";
$params = [$searchParam, $searchParam, $searchParam];
}
// Celkovy pocet pro pagination
$countSql = "
SELECT COUNT(*)
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
$where
";
$stmt = $pdo->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$offset = ($page - 1) * $perPage;
$sql = "
SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until,
q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate,
q.customer_id, q.order_id, q.status,
c.name as customer_name,
(SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0)
FROM quotation_items qi WHERE qi.quotation_id = q.id) as total
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
$where
ORDER BY $sortCol $order
LIMIT $perPage OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$quotations = $stmt->fetchAll();
successResponse([
'quotations' => $quotations,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
]);
}
function handleGetDetail(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare('
SELECT q.*, c.name as customer_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = ?
');
$stmt->execute([$id]);
$quotation = $stmt->fetch();
if (!$quotation) {
errorResponse('Nabídka nebyla nalezena', 404);
}
// Get items
$stmt = $pdo->prepare('
SELECT * FROM quotation_items
WHERE quotation_id = ?
ORDER BY position
');
$stmt->execute([$id]);
$quotation['items'] = $stmt->fetchAll();
// Get scope sections
$stmt = $pdo->prepare('
SELECT * FROM scope_sections
WHERE quotation_id = ?
ORDER BY position
');
$stmt->execute([$id]);
$quotation['sections'] = $stmt->fetchAll();
// Get customer
if ($quotation['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([$quotation['customer_id']]);
$quotation['customer'] = $stmt->fetch();
}
// Get linked order info
if ($quotation['order_id']) {
$stmt = $pdo->prepare('SELECT id, order_number, status FROM orders WHERE id = ?');
$stmt->execute([$quotation['order_id']]);
$quotation['order'] = $stmt->fetch() ?: null;
} else {
$quotation['order'] = null;
}
successResponse($quotation);
}
function handleGetNextNumber(PDO $pdo): void
{
$settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch();
if (!$settings) {
errorResponse('Nastavení firmy nenalezeno');
}
$year = date('Y');
$prefix = $settings['quotation_prefix'] ?: 'N';
$number = getMaxQuotationNumber($pdo, $year, $prefix) + 1;
$formatted = sprintf('%s/%s/%03d', $year, $prefix, $number);
successResponse([
'number' => $formatted,
'raw_number' => $number,
'prefix' => $prefix,
'year' => $year,
]);
}
function getMaxQuotationNumber(PDO $pdo, string $year, string $prefix): int
{
$likePattern = "{$year}/{$prefix}/%";
$stmt = $pdo->prepare("
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0)
FROM quotations
WHERE quotation_number LIKE ?
");
$stmt->execute([$likePattern]);
return (int) $stmt->fetchColumn();
}
function generateNextNumber(PDO $pdo): string
{
$settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch();
$year = date('Y');
$prefix = $settings['quotation_prefix'] ?: 'N';
$number = getMaxQuotationNumber($pdo, $year, $prefix) + 1;
return sprintf('%s/%s/%03d', $year, $prefix, $number);
}
/** @param array<string, mixed> $q */
function validateQuotationInput(array $q): void
{
if (empty($q['customer_id'])) {
errorResponse('Vyberte zákazníka');
}
if (empty($q['created_at'])) {
errorResponse('Zadejte datum vytvoření');
}
if (empty($q['valid_until'])) {
errorResponse('Zadejte datum platnosti');
}
if (!empty($q['created_at']) && !empty($q['valid_until']) && $q['valid_until'] < $q['created_at']) {
errorResponse('Datum platnosti nesmí být před datem vytvoření');
}
if (empty($q['currency'])) {
errorResponse('Vyberte měnu');
}
// Validace formatu dat
foreach (['created_at', 'valid_until'] as $dateField) {
if (!empty($q[$dateField]) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $q[$dateField])) {
errorResponse("Neplatný formát data: $dateField");
}
}
// Validace meny a jazyka
if (!in_array($q['currency'] ?? '', ['EUR', 'USD', 'CZK', 'GBP'])) {
errorResponse('Neplatná měna');
}
if (!empty($q['language']) && !in_array($q['language'], ['EN', 'CZ'])) {
errorResponse('Neplatný jazyk');
}
// Validace DPH
if (isset($q['vat_rate'])) {
$rate = floatval($q['vat_rate']);
if ($rate < 0 || $rate > 100) {
errorResponse('Sazba DPH musí být mezi 0 a 100');
}
}
// Delkove limity
if (!empty($q['project_code']) && mb_strlen($q['project_code']) > 100) {
errorResponse('Kód projektu je příliš dlouhý (max 100 znaků)');
}
}
function handleCreateOffer(PDO $pdo): void
{
$input = getJsonInput();
$quotation = $input['quotation'] ?? $input;
$items = $input['items'] ?? [];
$sections = $input['sections'] ?? [];
validateQuotationInput($quotation);
// Serialize number generation across concurrent requests
$locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn();
if (!$locked) {
errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503);
}
$pdo->beginTransaction();
try {
$quotationNumber = generateNextNumber($pdo);
$stmt = $pdo->prepare('
INSERT INTO quotations (
quotation_number, project_code, customer_id, created_at, valid_until,
currency, language, vat_rate, apply_vat, exchange_rate,
scope_title, scope_description, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
');
$stmt->execute([
$quotationNumber,
$quotation['project_code'] ?? '',
$quotation['customer_id'] ? (int)$quotation['customer_id'] : null,
$quotation['created_at'] ?? date('Y-m-d H:i:s'),
$quotation['valid_until'] ?? date('Y-m-d H:i:s', strtotime('+30 days')),
$quotation['currency'] ?? 'EUR',
$quotation['language'] ?? 'EN',
$quotation['vat_rate'] ?? 21,
isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : 0,
$quotation['exchange_rate'] ?? null,
$quotation['scope_title'] ?? '',
$quotation['scope_description'] ?? '',
]);
$quotationId = (int)$pdo->lastInsertId();
saveItems($pdo, $quotationId, $items);
saveSections($pdo, $quotationId, $sections);
$pdo->commit();
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
AuditLog::logCreate('offers_quotation', $quotationId, [
'quotation_number' => $quotationNumber,
'project_code' => $quotation['project_code'] ?? '',
], "Vytvořena nabídka '$quotationNumber'");
successResponse([
'id' => $quotationId,
'number' => $quotationNumber,
], 'Nabídka byla vytvořena');
} catch (PDOException $e) {
$pdo->rollBack();
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
throw $e;
}
}
function handleUpdateOffer(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
$stmt->execute([$id]);
$existing = $stmt->fetch();
if (!$existing) {
errorResponse('Nabídka nebyla nalezena', 404);
}
if ($existing['status'] === 'invalidated') {
errorResponse('Zneplatněnou nabídku nelze upravovat', 403);
}
$input = getJsonInput();
$quotation = $input['quotation'] ?? $input;
$items = $input['items'] ?? [];
$sections = $input['sections'] ?? [];
validateQuotationInput($quotation);
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('
UPDATE quotations SET
project_code = ?,
customer_id = ?,
created_at = ?,
valid_until = ?,
currency = ?,
language = ?,
vat_rate = ?,
apply_vat = ?,
exchange_rate = ?,
scope_title = ?,
scope_description = ?,
modified_at = NOW()
WHERE id = ?
');
$stmt->execute([
$quotation['project_code'] ?? $existing['project_code'],
isset($quotation['customer_id'])
? ($quotation['customer_id'] ? (int)$quotation['customer_id'] : null)
: $existing['customer_id'],
$quotation['created_at'] ?? $existing['created_at'],
$quotation['valid_until'] ?? $existing['valid_until'],
$quotation['currency'] ?? $existing['currency'],
$quotation['language'] ?? $existing['language'],
$quotation['vat_rate'] ?? $existing['vat_rate'],
isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : $existing['apply_vat'],
array_key_exists('exchange_rate', $quotation) ? $quotation['exchange_rate'] : $existing['exchange_rate'],
$quotation['scope_title'] ?? $existing['scope_title'],
$quotation['scope_description'] ?? $existing['scope_description'],
$id,
]);
// Replace items
$stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?');
$stmt->execute([$id]);
saveItems($pdo, $id, $items);
// Replace sections
$stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?');
$stmt->execute([$id]);
saveSections($pdo, $id, $sections);
$pdo->commit();
AuditLog::logUpdate(
'offers_quotation',
$id,
['quotation_number' => $existing['quotation_number']],
['project_code' => $quotation['project_code'] ?? $existing['project_code']],
"Upravena nabídka '{$existing['quotation_number']}'"
);
successResponse(null, 'Nabídka byla aktualizována');
} catch (PDOException $e) {
$pdo->rollBack();
throw $e;
}
}
function handleDuplicate(PDO $pdo, int $sourceId): void
{
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
$stmt->execute([$sourceId]);
$source = $stmt->fetch();
if (!$source) {
errorResponse('Zdrojová nabídka nebyla nalezena', 404);
}
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
$stmt->execute([$sourceId]);
$sourceItems = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
$stmt->execute([$sourceId]);
$sourceSections = $stmt->fetchAll();
$locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn();
if (!$locked) {
errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503);
}
$pdo->beginTransaction();
try {
$newNumber = generateNextNumber($pdo);
$stmt = $pdo->prepare('
INSERT INTO quotations (
quotation_number, project_code, customer_id, created_at, valid_until,
currency, language, vat_rate, apply_vat, exchange_rate,
scope_title, scope_description, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
');
$stmt->execute([
$newNumber,
$source['project_code'],
$source['customer_id'],
date('Y-m-d H:i:s'),
date('Y-m-d H:i:s', strtotime('+30 days')),
$source['currency'],
$source['language'],
$source['vat_rate'],
$source['apply_vat'],
$source['exchange_rate'],
$source['scope_title'],
$source['scope_description'],
]);
$newId = (int)$pdo->lastInsertId();
$items = array_map(function ($item) {
return [
'description' => $item['description'],
'item_description' => $item['item_description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'is_included_in_total' => $item['is_included_in_total'],
'position' => $item['position'],
];
}, $sourceItems);
saveItems($pdo, $newId, $items);
$sections = array_map(function ($section) {
return [
'title' => $section['title'],
'title_cz' => $section['title_cz'],
'content' => $section['content'],
'position' => $section['position'],
];
}, $sourceSections);
saveSections($pdo, $newId, $sections);
$pdo->commit();
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
AuditLog::logCreate('offers_quotation', $newId, [
'quotation_number' => $newNumber,
'duplicated_from' => $source['quotation_number'],
], "Duplikována nabídka '{$source['quotation_number']}' jako '$newNumber'");
successResponse([
'id' => $newId,
'number' => $newNumber,
], 'Nabídka byla duplikována');
} catch (PDOException $e) {
$pdo->rollBack();
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
throw $e;
}
}
function handleInvalidateOffer(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare('SELECT quotation_number, status, order_id FROM quotations WHERE id = ?');
$stmt->execute([$id]);
$quotation = $stmt->fetch();
if (!$quotation) {
errorResponse('Nabídka nebyla nalezena', 404);
}
if ($quotation['status'] === 'invalidated') {
errorResponse('Nabídka je již zneplatněna', 400);
}
if ($quotation['order_id']) {
errorResponse('Nabídku s objednávkou nelze zneplatnit', 400);
}
$stmt = $pdo->prepare('UPDATE quotations SET status = ?, modified_at = NOW() WHERE id = ?');
$stmt->execute(['invalidated', $id]);
AuditLog::logUpdate(
'offers_quotation',
$id,
['status' => 'active'],
['status' => 'invalidated'],
"Zneplatněna nabídka '{$quotation['quotation_number']}'"
);
successResponse(null, 'Nabídka byla zneplatněna');
}
function handleDeleteQuotation(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare('SELECT quotation_number FROM quotations WHERE id = ?');
$stmt->execute([$id]);
$quotation = $stmt->fetch();
if (!$quotation) {
errorResponse('Nabídka nebyla nalezena', 404);
}
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?');
$stmt->execute([$id]);
$stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?');
$stmt->execute([$id]);
$stmt = $pdo->prepare('DELETE FROM quotations WHERE id = ?');
$stmt->execute([$id]);
$pdo->commit();
AuditLog::logDelete('offers_quotation', $id, [
'quotation_number' => $quotation['quotation_number'],
], "Smazána nabídka '{$quotation['quotation_number']}'");
successResponse(null, 'Nabídka byla smazána');
} catch (PDOException $e) {
$pdo->rollBack();
throw $e;
}
}
// --- Helpers ---
/** @param list<array<string, mixed>> $items */
function saveItems(PDO $pdo, int $quotationId, array $items): void
{
if (empty($items)) {
return;
}
$stmt = $pdo->prepare('
INSERT INTO quotation_items (
quotation_id, description, item_description, quantity, unit,
unit_price, is_included_in_total, position, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
');
foreach ($items as $i => $item) {
$stmt->execute([
$quotationId,
$item['description'] ?? '',
$item['item_description'] ?? '',
$item['quantity'] ?? 1,
$item['unit'] ?? '',
$item['unit_price'] ?? 0,
isset($item['is_included_in_total']) ? ($item['is_included_in_total'] ? 1 : 0) : 1,
$item['position'] ?? ($i + 1),
]);
}
}
/** @param list<array<string, mixed>> $sections */
function saveSections(PDO $pdo, int $quotationId, array $sections): void
{
if (empty($sections)) {
return;
}
$stmt = $pdo->prepare('
INSERT INTO scope_sections (
quotation_id, title, title_cz, content, position, modified_at
) VALUES (?, ?, ?, ?, ?, NOW())
');
foreach ($sections as $i => $section) {
$stmt->execute([
$quotationId,
$section['title'] ?? '',
$section['title_cz'] ?? '',
$section['content'] ?? '',
$section['position'] ?? ($i + 1),
]);
}
}