CREATE TABLE `finance` ( `id` int NOT NULL AUTO_INCREMENT, `type` enum('income','expense') NOT NULL, `amount` decimal(12,2) NOT NULL, `category_id` int NOT NULL, `account_id` int NOT NULL, `date` date NOT NULL, `note` varchar(255) DEFAULT '', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_date` (`date`), KEY `idx_type` (`type`), KEY `idx_category` (`category_id`), KEY `idx_account` (`account_id`), CONSTRAINT `fk_finance_account` FOREIGN KEY (`account_id`) REFERENCES `finance_accounts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=4595 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci CREATE TABLE `finance_accounts` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, `color` varchar(7) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '#6ec6ff', `sort_order` int NOT NULL DEFAULT '0', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_name` (`name`) ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci CREATE TABLE `finance_adjustments` ( `id` int NOT NULL AUTO_INCREMENT, `account_id` int DEFAULT NULL, `amount` decimal(12,2) NOT NULL, `date` date NOT NULL, `note` varchar(255) DEFAULT '', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_acc_adj` (`account_id`), CONSTRAINT `fk_adj_account` FOREIGN KEY (`account_id`) REFERENCES `finance_accounts` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci CREATE TABLE `finance_categories` ( `id` int NOT NULL AUTO_INCREMENT, `type` enum('income','expense') NOT NULL, `name` varchar(50) NOT NULL, `color` varchar(7) NOT NULL DEFAULT '#78ff87', `sort_order` int NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uniq_type_name` (`type`,`name`) ) ENGINE=InnoDB AUTO_INCREMENT=59 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci CREATE TABLE `finance_goals` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, `target_amount` decimal(12,2) NOT NULL, `current_amount` decimal(12,2) NOT NULL DEFAULT '0.00', `color` varchar(7) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '#78ff87', `deadline` date DEFAULT NULL, `note` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', `sort_order` int NOT NULL DEFAULT '0', `is_archived` tinyint(1) NOT NULL DEFAULT '0', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; try { $pdo = new PDO($dsn, $user, $pass, $options); } catch (\PDOException $e) { throw new \PDOException($e->getMessage(), (int)$e->getCode()); } $error = ''; $success = ''; /** * Парсер SQL-значений (для обратной совместимости, если используется старый импорт) */ if (!function_exists('parseSqlValues')) { function parseSqlValues($raw) { $vals = []; $len = strlen($raw); $i = 0; while ($i < $len) { while ($i < $len && ($raw[$i] === ' ' || $raw[$i] === ',' || $raw[$i] === "\t")) $i++; if ($i >= $len) break; if ($raw[$i] === "'") { $i++; $val = ''; while ($i < $len) { $ch = $raw[$i]; if ($ch === '\\' && $i + 1 < $len) { $next = $raw[$i + 1]; if ($next === "'" || $next === '\\') { $val .= $next; $i += 2; continue; } $val .= $ch; $i++; continue; } if ($ch === "'") { if ($i + 1 < $len && $raw[$i + 1] === "'") { $val .= "'"; $i += 2; continue; } $i++; break; } $val .= $ch; $i++; } $vals[] = $val; } elseif ($raw[$i] === 'N' && $i + 3 < $len && strtoupper(substr($raw, $i, 4)) === 'NULL') { $vals[] = null; $i += 4; } else { $start = $i; while ($i < $len && $raw[$i] !== ',' && $raw[$i] !== ')') $i++; $token = trim(substr($raw, $start, $i - $start)); if (strtoupper($token) === 'NULL') $vals[] = null; else $vals[] = $token; } } return $vals; } } // ============================================================ // Создание таблицы целей при необходимости // ============================================================ try { $pdo->exec("CREATE TABLE IF NOT EXISTS finance_goals ( id int NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, target_amount decimal(12,2) NOT NULL, current_amount decimal(12,2) NOT NULL DEFAULT 0, color varchar(7) NOT NULL DEFAULT '#78ff87', deadline date DEFAULT NULL, note varchar(255) DEFAULT '', sort_order int NOT NULL DEFAULT 0, is_archived tinyint(1) NOT NULL DEFAULT 0, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); // Автомиграция: добавить недостающие колонки, если таблица была создана раньше $cols = []; $stmt = $pdo->query("SHOW COLUMNS FROM finance_goals"); while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) $cols[$r['Field']] = true; if (!isset($cols['note'])) { $pdo->exec("ALTER TABLE finance_goals ADD COLUMN note varchar(255) DEFAULT '' AFTER deadline"); } if (!isset($cols['sort_order'])) { $pdo->exec("ALTER TABLE finance_goals ADD COLUMN sort_order int NOT NULL DEFAULT 0 AFTER note"); } if (!isset($cols['is_archived'])) { $pdo->exec("ALTER TABLE finance_goals ADD COLUMN is_archived tinyint(1) NOT NULL DEFAULT 0 AFTER sort_order"); } } catch (PDOException $e) {} // ============================================================ // POST: обработка действий // ============================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? ''; // ------------------------------------------------------------ // ЭКСПОРТ В CSV (Excel-совместимый, разделитель ;) // ------------------------------------------------------------ if ($action === 'export_csv') { $what = $_POST['what'] ?? 'all'; $filename = 'finance_export_' . date('Y-m-d_H-i-s') . '.csv'; header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Pragma: no-cache'); header('Expires: 0'); $out = fopen('php://output', 'w'); fwrite($out, "\xEF\xBB\xBF"); $dec = ','; // Хелпер: fputcsv с явным escape (требование PHP 8.4+) $csv = function($handle, $fields) { fputcsv($handle, $fields, ';', '"', ''); }; if ($what === 'categories' || $what === 'all') { $csv($out, ['=== CATEGORIES ===']); $csv($out, ['id', 'type', 'name', 'color', 'sort_order']); $stmt = $pdo->query("SELECT id, type, name, color, sort_order FROM finance_categories ORDER BY type, sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $csv($out, [$row['id'], $row['type'], $row['name'], $row['color'], $row['sort_order']]); } $csv($out, []); } if ($what === 'finance' || $what === 'all') { $csv($out, ['=== FINANCE ===']); $csv($out, ['id', 'type', 'amount', 'category_id', 'category_name', 'account_id', 'account_name', 'date', 'note']); $stmt = $pdo->query(" SELECT f.id, f.type, f.amount, f.category_id, c.name AS category_name, f.account_id, a.name AS account_name, f.date, f.note FROM finance f LEFT JOIN finance_categories c ON c.id = f.category_id LEFT JOIN finance_accounts a ON a.id = f.account_id ORDER BY f.date DESC, f.id DESC "); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $csv($out, [ $row['id'], $row['type'], number_format((float)$row['amount'], 2, $dec, ''), $row['category_id'], $row['category_name'], $row['account_id'], $row['account_name'], $row['date'], $row['note'] ]); } $csv($out, []); } if ($what === 'adjustments' || $what === 'all') { $csv($out, ['=== ADJUSTMENTS ===']); $csv($out, ['id', 'account_id', 'amount', 'date', 'note']); $stmt = $pdo->query("SELECT id, account_id, amount, date, note FROM finance_adjustments ORDER BY date DESC, id DESC"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $csv($out, [$row['id'], $row['account_id'], number_format((float)$row['amount'], 2, $dec, ''), $row['date'], $row['note']]); } $csv($out, []); } if ($what === 'goals' || $what === 'all') { $csv($out, ['=== GOALS ===']); $csv($out, ['id', 'name', 'target_amount', 'current_amount', 'color', 'deadline', 'note', 'sort_order', 'is_archived']); try { $stmt = $pdo->query("SELECT id, name, target_amount, current_amount, color, deadline, note, sort_order, is_archived FROM finance_goals ORDER BY sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $csv($out, [$row['id'], $row['name'], number_format((float)$row['target_amount'], 2, $dec, ''), number_format((float)$row['current_amount'], 2, $dec, ''), $row['color'], $row['deadline'], $row['note'], $row['sort_order'], $row['is_archived']]); } } catch (PDOException $e) {} } fclose($out); exit; } // ------------------------------------------------------------ // ЭКСПОРТ В SQL // ------------------------------------------------------------ if ($action === 'export_sql') { $filename = 'finance_export_' . date('Y-m-d_H-i-s') . '.sql'; header('Content-Type: application/sql; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Pragma: no-cache'); header('Expires: 0'); echo "-- Finance export\n"; echo "-- Generated: " . date('Y-m-d H:i:s') . "\n\n"; echo "SET NAMES utf8mb4;\n"; echo "SET FOREIGN_KEY_CHECKS = 0;\n\n"; echo "-- CATEGORIES\n"; $stmt = $pdo->query("SELECT id, type, name, color, sort_order FROM finance_categories ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { echo "INSERT INTO finance_categories (id, type, name, color, sort_order) VALUES (" . (int)$row['id'] . ", " . $pdo->quote($row['type']) . ", " . $pdo->quote($row['name']) . ", " . $pdo->quote($row['color']) . ", " . (int)$row['sort_order'] . ");\n"; } echo "\n"; echo "-- ACCOUNTS\n"; $stmt = $pdo->query("SELECT id, name, color, sort_order FROM finance_accounts ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { echo "INSERT INTO finance_accounts (id, name, color, sort_order) VALUES (" . (int)$row['id'] . ", " . $pdo->quote($row['name']) . ", " . $pdo->quote($row['color']) . ", " . (int)$row['sort_order'] . ");\n"; } echo "\n"; echo "-- FINANCE\n"; $stmt = $pdo->query("SELECT id, type, amount, category_id, account_id, date, note FROM finance ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { echo "INSERT INTO finance (id, type, amount, category_id, account_id, date, note) VALUES (" . (int)$row['id'] . ", " . $pdo->quote($row['type']) . ", " . number_format((float)$row['amount'], 2, '.', '') . ", " . (int)$row['category_id'] . ", " . (int)$row['account_id'] . ", " . $pdo->quote($row['date']) . ", " . $pdo->quote($row['note']) . ");\n"; } echo "\n"; echo "-- ADJUSTMENTS\n"; $stmt = $pdo->query("SELECT id, account_id, amount, date, note FROM finance_adjustments ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $accId = $row['account_id'] ? (int)$row['account_id'] : 'NULL'; echo "INSERT INTO finance_adjustments (id, account_id, amount, date, note) VALUES (" . (int)$row['id'] . ", " . $accId . ", " . number_format((float)$row['amount'], 2, '.', '') . ", " . $pdo->quote($row['date']) . ", " . $pdo->quote($row['note']) . ");\n"; } echo "\n"; echo "-- GOALS\n"; try { $stmt = $pdo->query("SELECT id, name, target_amount, current_amount, color, deadline, note, sort_order, is_archived FROM finance_goals ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $deadline = $row['deadline'] ? $pdo->quote($row['deadline']) : 'NULL'; echo "INSERT INTO finance_goals (id, name, target_amount, current_amount, color, deadline, note, sort_order, is_archived) VALUES (" . (int)$row['id'] . ", " . $pdo->quote($row['name']) . ", " . number_format((float)$row['target_amount'], 2, '.', '') . ", " . number_format((float)$row['current_amount'], 2, '.', '') . ", " . $pdo->quote($row['color']) . ", " . $deadline . ", " . $pdo->quote($row['note']) . ", " . (int)$row['sort_order'] . ", " . (int)$row['is_archived'] . ");\n"; } } catch (PDOException $e) {} echo "\nSET FOREIGN_KEY_CHECKS = 1;\n"; exit; } // ------------------------------------------------------------ // ИМПОРТ ИЗ SQL // ------------------------------------------------------------ if ($action === 'import_sql') { if (empty($_FILES['sql_file']) || $_FILES['sql_file']['error'] !== UPLOAD_ERR_OK) { $error = 'Файл не загружен.'; } elseif ($_FILES['sql_file']['size'] > 20 * 1024 * 1024) { $error = 'Файл слишком большой (макс. 20 МБ).'; } else { $sql = file_get_contents($_FILES['sql_file']['tmp_name']); if ($sql === false) { $error = 'Не удалось прочитать файл.'; } else { $imported = 0; $skipped = 0; $skippedDetails = []; try { $pdo->beginTransaction(); $lines = preg_split('/\r\n|\r|\n/', $sql); $lineNum = 0; foreach ($lines as $line) { $lineNum++; $line = trim($line); if ($line === '' || $line[0] === '-' || $line[0] === '#') continue; if (!preg_match('/^INSERT\s+INTO\s+`?(finance|finance_categories|finance_adjustments|finance_accounts|finance_goals)`?\s/i', $line, $m)) continue; $table = strtolower($m[1]); if (!in_array($table, ['finance', 'finance_categories', 'finance_adjustments', 'finance_accounts', 'finance_goals'], true)) { $skipped++; continue; } try { $pdo->exec($line); $imported++; } catch (PDOException $e) { $skipped++; if ($e->getCode() !== '23000' && count($skippedDetails) < 30) $skippedDetails[] = "line $lineNum: " . $e->getMessage(); } } $pdo->commit(); $msg = 'Импорт SQL завершён. Добавлено записей: ' . $imported . ', пропущено: ' . $skipped . '.'; if (!empty($skippedDetails)) $msg .= ' Причины: ' . implode('; ', array_slice($skippedDetails, 0, 5)); $success = $msg; } catch (Exception $e) { $pdo->rollBack(); $error = 'Ошибка импорта SQL: ' . $e->getMessage(); } } } } // ------------------------------------------------------------ // УДАЛИТЬ ВСЕ ДАННЫЕ // ------------------------------------------------------------ if ($action === 'delete_all_data') { $confirmWord = trim($_POST['confirm_word'] ?? ''); $keepCats = !empty($_POST['keep_categories']); $keepAdj = !empty($_POST['keep_adjustments']); $keepAccs = !empty($_POST['keep_accounts']); $keepGoals = !empty($_POST['keep_goals']); if (mb_strtoupper($confirmWord, 'UTF-8') !== 'УДАЛИТЬ') { $error = 'Введите слово УДАЛИТЬ для подтверждения.'; } else { try { $pdo->beginTransaction(); $pdo->exec("DELETE FROM finance"); if (!$keepCats) $pdo->exec("DELETE FROM finance_categories"); if (!$keepAdj) $pdo->exec("DELETE FROM finance_adjustments"); if (!$keepAccs) { $pdo->exec("DELETE FROM finance_adjustments"); $pdo->exec("DELETE FROM finance_accounts"); } if (!$keepGoals) { try { $pdo->exec("DELETE FROM finance_goals"); } catch (PDOException $e) {} } $pdo->commit(); $deleted = ['операции']; if (!$keepCats) $deleted[] = 'категории'; if (!$keepAdj) $deleted[] = 'корректировки'; if (!$keepAccs) $deleted[] = 'счета'; if (!$keepGoals) $deleted[] = 'цели'; $success = 'Удалено: ' . implode(', ', $deleted) . '.'; } catch (Exception $e) { $pdo->rollBack(); $error = 'Ошибка удаления: ' . $e->getMessage(); } } } // --- ОПЕРАЦИИ --- if ($action === 'add_finance') { $type = ($_POST['type'] ?? '') === 'income' ? 'income' : 'expense'; $amount = isset($_POST['amount']) ? (float)str_replace(',', '.', $_POST['amount']) : 0; $catId = isset($_POST['category_id']) ? (int)$_POST['category_id'] : 0; $accId = isset($_POST['account_id']) ? (int)$_POST['account_id'] : 0; $date = isset($_POST['date']) ? trim($_POST['date']) : date('Y-m-d'); $note = isset($_POST['note']) ? trim($_POST['note']) : ''; if ($amount <= 0) $error = 'Сумма должна быть больше нуля.'; elseif ($catId <= 0) $error = 'Выберите категорию.'; elseif ($accId <= 0) $error = 'Выберите счёт.'; elseif (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) $error = 'Некорректная дата.'; else { try { $stmt = $pdo->prepare("INSERT INTO finance (type, amount, category_id, account_id, date, note) VALUES (?, ?, ?, ?, ?, ?)"); $stmt->execute([$type, $amount, $catId, $accId, $date, $note]); $success = ($type === 'income' ? 'Доход' : 'Расход') . ' добавлен.'; } catch (PDOException $e) { $error = 'Ошибка сохранения: ' . $e->getMessage(); } } } if ($action === 'update_finance') { $id = isset($_POST['id']) ? (int)$_POST['id'] : 0; $amount = isset($_POST['amount']) ? (float)str_replace(',', '.', $_POST['amount']) : 0; $catId = isset($_POST['category_id']) ? (int)$_POST['category_id'] : 0; $accId = isset($_POST['account_id']) ? (int)$_POST['account_id'] : 0; $date = isset($_POST['date']) ? trim($_POST['date']) : ''; $note = isset($_POST['note']) ? trim($_POST['note']) : ''; if ($id <= 0) $error = 'Неверный ID.'; elseif ($amount <= 0) $error = 'Сумма должна быть больше нуля.'; elseif ($catId <= 0) $error = 'Выберите категорию.'; elseif ($accId <= 0) $error = 'Выберите счёт.'; elseif (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) $error = 'Некорректная дата.'; else { try { $stmt = $pdo->prepare("UPDATE finance SET amount = ?, category_id = ?, account_id = ?, date = ?, note = ? WHERE id = ?"); $stmt->execute([$amount, $catId, $accId, $date, $note, $id]); $success = 'Запись обновлена.'; } catch (PDOException $e) { $error = 'Ошибка обновления: ' . $e->getMessage(); } } } if ($action === 'delete_finance') { $id = isset($_POST['id']) ? (int)$_POST['id'] : 0; if ($id > 0) { try { $pdo->prepare("DELETE FROM finance WHERE id = ?")->execute([$id]); $success = 'Запись удалена.'; } catch (PDOException $e) { $error = 'Ошибка удаления: ' . $e->getMessage(); } } } // --- КАТЕГОРИИ --- if ($action === 'add_category') { $type = ($_POST['cat_type'] ?? '') === 'income' ? 'income' : 'expense'; $name = trim($_POST['cat_name'] ?? ''); $color = trim($_POST['cat_color'] ?? '#78ff87'); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#78ff87'; if ($name === '') $error = 'Название категории не может быть пустым.'; else { try { $max = $pdo->prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM finance_categories WHERE type = ?"); $max->execute([$type]); $order = (int)$max->fetchColumn(); $stmt = $pdo->prepare("INSERT INTO finance_categories (type, name, color, sort_order) VALUES (?, ?, ?, ?)"); $stmt->execute([$type, $name, $color, $order]); $success = 'Категория добавлена.'; } catch (PDOException $e) { $error = ($e->getCode() === '23000') ? 'Такая категория уже есть.' : 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'delete_category') { $id = isset($_POST['cat_id']) ? (int)$_POST['cat_id'] : 0; if ($id > 0) { try { $check = $pdo->prepare("SELECT COUNT(*) FROM finance WHERE category_id = ?"); $check->execute([$id]); if ((int)$check->fetchColumn() > 0) $error = 'Нельзя удалить: есть операции.'; else { $pdo->prepare("DELETE FROM finance_categories WHERE id = ?")->execute([$id]); $success = 'Категория удалена.'; } } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'update_category_color') { $id = isset($_POST['cat_id']) ? (int)$_POST['cat_id'] : 0; $color = trim($_POST['cat_color'] ?? ''); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#78ff87'; if ($id > 0) { try { $pdo->prepare("UPDATE finance_categories SET color = ? WHERE id = ?")->execute([$color, $id]); $success = 'Цвет обновлён.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } // --- СЧЕТА --- if ($action === 'add_account') { $name = trim($_POST['acc_name'] ?? ''); $color = trim($_POST['acc_color'] ?? '#6ec6ff'); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#6ec6ff'; if ($name === '') $error = 'Название счёта не может быть пустым.'; else { try { $max = $pdo->query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM finance_accounts"); $order = (int)$max->fetchColumn(); $stmt = $pdo->prepare("INSERT INTO finance_accounts (name, color, sort_order) VALUES (?, ?, ?)"); $stmt->execute([$name, $color, $order]); $success = 'Счёт добавлен.'; } catch (PDOException $e) { $error = ($e->getCode() === '23000') ? 'Такой счёт уже есть.' : 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'delete_account') { $id = isset($_POST['acc_id']) ? (int)$_POST['acc_id'] : 0; if ($id > 0) { try { $check = $pdo->prepare("SELECT COUNT(*) FROM finance WHERE account_id = ?"); $check->execute([$id]); if ((int)$check->fetchColumn() > 0) $error = 'Нельзя удалить счёт: есть операции.'; else { $pdo->prepare("DELETE FROM finance_accounts WHERE id = ?")->execute([$id]); $success = 'Счёт удалён.'; } } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } // --- РЕДАКТИРОВАНИЕ СЧЁТА (Имя, Цвет, Баланс) --- if ($action === 'edit_account') { $accId = isset($_POST['acc_id']) ? (int)$_POST['acc_id'] : 0; $name = trim($_POST['acc_name'] ?? ''); $color = trim($_POST['acc_color'] ?? '#6ec6ff'); $target = isset($_POST['target_balance']) ? (float)str_replace(',', '.', $_POST['target_balance']) : null; if ($accId <= 0 || $name === '') { $error = 'Неверные данные счёта.'; } else { if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#6ec6ff'; try { $pdo->prepare("UPDATE finance_accounts SET name = ?, color = ? WHERE id = ?")->execute([$name, $color, $accId]); if ($target !== null) { $stmt = $pdo->prepare("SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END), 0) AS bal FROM finance WHERE account_id = ?"); $stmt->execute([$accId]); $currentBal = (float)$stmt->fetchColumn(); $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount), 0) FROM finance_adjustments WHERE account_id = ?"); $stmt->execute([$accId]); $currentBal += (float)$stmt->fetchColumn(); $diff = $target - $currentBal; if (abs($diff) >= 0.01) { $note = 'Ручная корректировка счёта: ' . number_format($currentBal, 2, '.', ' ') . ' → ' . number_format($target, 2, '.', ' '); $stmt = $pdo->prepare("INSERT INTO finance_adjustments (account_id, amount, date, note) VALUES (?, ?, ?, ?)"); $stmt->execute([$accId, $diff, date('Y-m-d'), $note]); $success = 'Счёт обновлён, баланс скорректирован.'; } else { $success = 'Данные счёта обновлены.'; } } else { $success = 'Данные счёта обновлены.'; } } catch (PDOException $e) { $error = ($e->getCode() === '23000') ? 'Счёт с таким именем уже есть.' : 'Ошибка: ' . $e->getMessage(); } } } // --- КОРРЕКТИРОВКИ --- if ($action === 'add_adjustment') { $amount = isset($_POST['adj_amount']) ? (float)str_replace(',', '.', $_POST['adj_amount']) : 0; $accId = isset($_POST['adj_account_id']) ? (int)$_POST['adj_account_id'] : 0; $date = isset($_POST['adj_date']) ? trim($_POST['adj_date']) : date('Y-m-d'); $note = isset($_POST['adj_note']) ? trim($_POST['adj_note']) : ''; if ($amount == 0) $error = 'Сумма корректировки не может быть нулевой.'; elseif ($accId <= 0) $error = 'Выберите счёт для корректировки.'; elseif (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) $error = 'Некорректная дата корректировки.'; else { try { $stmt = $pdo->prepare("INSERT INTO finance_adjustments (account_id, amount, date, note) VALUES (?, ?, ?, ?)"); $stmt->execute([$accId, $amount, $date, $note]); $success = 'Корректировка добавлена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'delete_adjustment') { $id = isset($_POST['adj_id']) ? (int)$_POST['adj_id'] : 0; if ($id > 0) { try { $pdo->prepare("DELETE FROM finance_adjustments WHERE id = ?")->execute([$id]); $success = 'Корректировка удалена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } // --- ЦЕЛИ --- if ($action === 'add_goal') { $name = trim($_POST['goal_name'] ?? ''); $target = isset($_POST['goal_target']) ? (float)str_replace(',', '.', $_POST['goal_target']) : 0; $current= isset($_POST['goal_current']) ? (float)str_replace(',', '.', $_POST['goal_current']) : 0; $color = trim($_POST['goal_color'] ?? '#78ff87'); $deadline = trim($_POST['goal_deadline'] ?? ''); $note = trim($_POST['goal_note'] ?? ''); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#78ff87'; if ($deadline !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $deadline)) $deadline = ''; if ($name === '') $error = 'Название цели не может быть пустым.'; elseif ($target <= 0) $error = 'Целевая сумма должна быть больше нуля.'; else { try { $max = $pdo->query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM finance_goals"); $order = (int)$max->fetchColumn(); $stmt = $pdo->prepare("INSERT INTO finance_goals (name, target_amount, current_amount, color, deadline, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)"); $stmt->execute([$name, $target, $current, $color, $deadline ?: null, $note, $order]); $success = 'Цель добавлена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'update_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; $name = trim($_POST['goal_name'] ?? ''); $target = isset($_POST['goal_target']) ? (float)str_replace(',', '.', $_POST['goal_target']) : 0; $current = isset($_POST['goal_current']) ? (float)str_replace(',', '.', $_POST['goal_current']) : 0; $color = trim($_POST['goal_color'] ?? '#78ff87'); $deadline = trim($_POST['goal_deadline'] ?? ''); $note = trim($_POST['goal_note'] ?? ''); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#78ff87'; if ($deadline !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $deadline)) $deadline = ''; if ($id <= 0) $error = 'Неверный ID цели.'; elseif ($name === '') $error = 'Название цели не может быть пустым.'; elseif ($target <= 0) $error = 'Целевая сумма должна быть больше нуля.'; else { try { $stmt = $pdo->prepare("UPDATE finance_goals SET name = ?, target_amount = ?, current_amount = ?, color = ?, deadline = ?, note = ? WHERE id = ?"); $stmt->execute([$name, $target, $current, $color, $deadline ?: null, $note, $id]); $success = 'Цель обновлена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'delete_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; if ($id > 0) { try { $pdo->prepare("DELETE FROM finance_goals WHERE id = ?")->execute([$id]); $success = 'Цель удалена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'archive_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; if ($id > 0) { try { $pdo->prepare("UPDATE finance_goals SET is_archived = 1 WHERE id = ?")->execute([$id]); $success = 'Цель закрыта и перенесена в архив.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'unarchive_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; if ($id > 0) { try { $pdo->prepare("UPDATE finance_goals SET is_archived = 0 WHERE id = ?")->execute([$id]); $success = 'Цель возвращена из архива.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'deposit_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; $amount = isset($_POST['deposit_amount']) ? (float)str_replace(',', '.', $_POST['deposit_amount']) : 0; if ($id > 0 && $amount != 0) { try { $pdo->prepare("UPDATE finance_goals SET current_amount = current_amount + ? WHERE id = ?")->execute([$amount, $id]); $success = 'Сумма добавлена к цели.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } // PRG if (empty($error)) { $redirectUrl = $_SERVER['PHP_SELF']; $queryParams = []; foreach (['period','from','to','an_month','hm_month','chart_month'] as $k) { if (isset($_GET[$k])) $queryParams[$k] = $_GET[$k]; } if (!empty($queryParams)) $redirectUrl .= '?' . http_build_query($queryParams); if (session_status() === PHP_SESSION_NONE) session_start(); if ($success) $_SESSION['fin_flash_success'] = $success; header('Location: ' . $redirectUrl); exit; } } if (session_status() === PHP_SESSION_NONE) session_start(); if (!empty($_SESSION['fin_flash_success'])) { $success = $_SESSION['fin_flash_success']; unset($_SESSION['fin_flash_success']); } // ============================================================ // Общий баланс // ============================================================ $totalIncomeAll = 0.0; $totalExpenseAll = 0.0; $totalAdjustments = 0.0; try { $stmt = $pdo->query("SELECT type, SUM(amount) AS total FROM finance GROUP BY type"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if ($row['type'] === 'income') $totalIncomeAll = (float)$row['total']; if ($row['type'] === 'expense') $totalExpenseAll = (float)$row['total']; } } catch (PDOException $e) {} try { $stmt = $pdo->query("SELECT COALESCE(SUM(amount), 0) FROM finance_adjustments"); $totalAdjustments = (float)$stmt->fetchColumn(); } catch (PDOException $e) { $totalAdjustments = 0.0; } $totalBalance = $totalIncomeAll - $totalExpenseAll + $totalAdjustments; // ============================================================ // Сбережения (сумма расходов по категории "Сбережения") // ============================================================ $totalSavings = 0.0; try { $stmt = $pdo->query(" SELECT COALESCE(SUM(f.amount), 0) FROM finance f INNER JOIN finance_categories c ON c.id = f.category_id WHERE f.type = 'expense' AND c.name = 'Сбережения' "); $totalSavings = (float)$stmt->fetchColumn(); } catch (PDOException $e) {} // ============================================================ // Период // ============================================================ $period = $_GET['period'] ?? 'month'; $today = new DateTime('today'); switch ($period) { case 'day': $from = $to = $today->format('Y-m-d'); break; case 'week': $from = (clone $today)->modify('monday this week')->format('Y-m-d'); $to = (clone $today)->modify('sunday this week')->format('Y-m-d'); break; case 'year': $from = $today->format('Y') . '-01-01'; $to = $today->format('Y') . '-12-31'; break; case 'custom': $from = (isset($_GET['from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['from'])) ? $_GET['from'] : $today->format('Y-m-01'); $to = (isset($_GET['to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['to'])) ? $_GET['to'] : $today->format('Y-m-t'); break; case 'month': default: $period = 'month'; $from = $today->format('Y-m-01'); $to = $today->format('Y-m-t'); break; } // ============================================================ // Категории и Счета // ============================================================ $categories = ['income' => [], 'expense' => []]; try { $stmt = $pdo->query("SELECT * FROM finance_categories ORDER BY type, sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $categories[$row['type']][] = $row; } } catch (PDOException $e) {} $accounts = []; $accountBalances = []; try { $stmt = $pdo->query("SELECT * FROM finance_accounts ORDER BY sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $accounts[] = $row; $accountBalances[(int)$row['id']] = ['income' => 0.0, 'expense' => 0.0, 'adj' => 0.0, 'balance' => 0.0]; } $stmt = $pdo->query("SELECT account_id, type, SUM(amount) AS total FROM finance WHERE account_id IS NOT NULL GROUP BY account_id, type"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $aid = (int)$row['account_id']; if (isset($accountBalances[$aid])) { if ($row['type'] === 'income') $accountBalances[$aid]['income'] = (float)$row['total']; if ($row['type'] === 'expense') $accountBalances[$aid]['expense'] = (float)$row['total']; } } $stmt = $pdo->query("SELECT account_id, SUM(amount) AS total FROM finance_adjustments WHERE account_id IS NOT NULL GROUP BY account_id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $aid = (int)$row['account_id']; if (isset($accountBalances[$aid])) $accountBalances[$aid]['adj'] = (float)$row['total']; } foreach ($accountBalances as $aid => &$b) { $b['balance'] = $b['income'] - $b['expense'] + $b['adj']; } unset($b); } catch (PDOException $e) {} // ============================================================ // Записи за период // ============================================================ $incomes = []; $expenses = []; try { $stmt = $pdo->prepare(" SELECT f.*, c.name AS category_name, c.color AS category_color, a.name AS account_name, a.color AS account_color FROM finance f LEFT JOIN finance_categories c ON c.id = f.category_id LEFT JOIN finance_accounts a ON a.id = f.account_id WHERE f.date BETWEEN ? AND ? ORDER BY f.date DESC, f.id DESC "); $stmt->execute([$from, $to]); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if ($row['type'] === 'income') $incomes[] = $row; else $expenses[] = $row; } } catch (PDOException $e) {} $periodIncome = array_sum(array_column($incomes, 'amount')); $periodExpense = array_sum(array_column($expenses, 'amount')); $byCatExpense = []; foreach ($expenses as $r) { $k = $r['category_name'] ?? 'Без категории'; if (!isset($byCatExpense[$k])) $byCatExpense[$k] = ['sum' => 0.0, 'color' => $r['category_color'] ?? '#a5d8ff']; $byCatExpense[$k]['sum'] += (float)$r['amount']; } uasort($byCatExpense, fn($a, $b) => $b['sum'] <=> $a['sum']); $byCatIncome = []; foreach ($incomes as $r) { $k = $r['category_name'] ?? 'Без категории'; if (!isset($byCatIncome[$k])) $byCatIncome[$k] = ['sum' => 0.0, 'color' => $r['category_color'] ?? '#a5d8ff']; $byCatIncome[$k]['sum'] += (float)$r['amount']; } uasort($byCatIncome, fn($a, $b) => $b['sum'] <=> $a['sum']); // ============================================================ // Корректировки // ============================================================ $adjustments = []; try { $stmt = $pdo->query("SELECT a.*, acc.name AS acc_name FROM finance_adjustments a LEFT JOIN finance_accounts acc ON acc.id = a.account_id ORDER BY a.date DESC, a.id DESC"); $adjustments = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) {} // ============================================================ // Цели (активные и архивные) // ============================================================ $goals = []; $goalsArchived = []; try { $stmt = $pdo->query("SELECT * FROM finance_goals ORDER BY sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if (!empty($row['is_archived'])) $goalsArchived[] = $row; else $goals[] = $row; } } catch (PDOException $e) {} // ============================================================ // АНАЛИЗ: месяцы, прогноз, 50/30/20, перерасход, тепловая карта // ============================================================ function parseMonthParam($param, $fallbackDate) { if ($param && preg_match('/^\d{4}-\d{2}$/', $param)) { $y = (int)substr($param, 0, 4); $m = (int)substr($param, 5, 2); if ($m >= 1 && $m <= 12) return [$y, $m]; } return [(int)$fallbackDate->format('Y'), (int)$fallbackDate->format('n')]; } list($anYear, $anMonth) = parseMonthParam($_GET['an_month'] ?? '', $today); $anFirst = new DateTime(sprintf('%04d-%02d-01', $anYear, $anMonth)); $anLast = (clone $anFirst)->modify('last day of this month'); list($hmYear, $hmMonth) = parseMonthParam($_GET['hm_month'] ?? '', $today); $hmFirst = new DateTime(sprintf('%04d-%02d-01', $hmYear, $hmMonth)); $hmLast = (clone $hmFirst)->modify('last day of this month'); // Месяц для графика динамики list($chYear, $chMonth) = parseMonthParam($_GET['chart_month'] ?? '', $today); $chFirst = new DateTime(sprintf('%04d-%02d-01', $chYear, $chMonth)); $monthNamesRu = [ 1 => 'Январь', 2 => 'Февраль', 3 => 'Март', 4 => 'Апрель', 5 => 'Май', 6 => 'Июнь', 7 => 'Июль', 8 => 'Август', 9 => 'Сентябрь', 10 => 'Октябрь', 11 => 'Ноябрь', 12 => 'Декабрь', ]; function buildUrlWithParams($baseParams) { $url = $_SERVER['PHP_SELF']; $params = array_merge($_GET, $baseParams); foreach ($params as $k => $v) { if ($v === '' || $v === null) unset($params[$k]); } if (!empty($params)) $url .= '?' . http_build_query($params); return $url; } $anPrev = (clone $anFirst)->modify('-1 month'); $anNext = (clone $anFirst)->modify('+1 month'); $hmPrev = (clone $hmFirst)->modify('-1 month'); $hmNext = (clone $hmFirst)->modify('+1 month'); $anPrevUrl = buildUrlWithParams(['an_month' => $anPrev->format('Y-m')]); $anNextUrl = buildUrlWithParams(['an_month' => $anNext->format('Y-m')]); $hmPrevUrl = buildUrlWithParams(['hm_month' => $hmPrev->format('Y-m')]); $hmNextUrl = buildUrlWithParams(['hm_month' => $hmNext->format('Y-m')]); // Прогноз $forecast = null; $currentMonthStart = $today->format('Y-m-01'); $currentMonthEnd = $today->format('Y-m-t'); $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount), 0) FROM finance WHERE type='expense' AND date BETWEEN ? AND ?"); $stmt->execute([$currentMonthStart, $today->format('Y-m-d')]); $spentThisMonth = (float)$stmt->fetchColumn(); $daysPassed = (int)$today->format('j'); $daysInMonth = (int)$today->format('t'); $daysLeft = max(0, $daysInMonth - $daysPassed); $avgPerDay = $daysPassed > 0 ? $spentThisMonth / $daysPassed : 0; $forecastTotal = $spentThisMonth + $avgPerDay * $daysLeft; $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount), 0) FROM finance WHERE type='income' AND date BETWEEN ? AND ?"); $stmt->execute([$currentMonthStart, $currentMonthEnd]); $incomeThisMonth = (float)$stmt->fetchColumn(); $forecast = [ 'spent' => $spentThisMonth, 'income' => $incomeThisMonth, 'avg_per_day' => $avgPerDay, 'days_passed' => $daysPassed, 'days_left' => $daysLeft, 'days_total' => $daysInMonth, 'forecast' => $forecastTotal, 'remainder' => $incomeThisMonth - $forecastTotal, ]; // Норма сбережений (savings rate) $savingsThisMonth = 0.0; try { $stmt = $pdo->prepare(" SELECT COALESCE(SUM(f.amount), 0) FROM finance f INNER JOIN finance_categories c ON c.id = f.category_id WHERE f.type='expense' AND c.name='Сбережения' AND f.date BETWEEN ? AND ? "); $stmt->execute([$currentMonthStart, $today->format('Y-m-d')]); $savingsThisMonth = (float)$stmt->fetchColumn(); } catch (PDOException $e) {} $savingsRate = $incomeThisMonth > 0 ? ($savingsThisMonth / $incomeThisMonth) * 100 : 0.0; $netSavings = $incomeThisMonth - $spentThisMonth; $netSavingsRate = $incomeThisMonth > 0 ? ($netSavings / $incomeThisMonth) * 100 : 0.0; // 50/30/20 $categoryGroups = [ 'Продукты' => 'needs', 'Жильё' => 'needs', '167 кв.' => 'needs', 'Ипотека' => 'needs', 'Транспорт' => 'needs', 'Автомобиль' => 'needs', 'Здоровье' => 'needs', 'Связь' => 'needs', 'Телефон и Интернет' => 'needs', 'Дом' => 'needs', 'Кафе' => 'wants', 'Досуг' => 'wants', 'Одежда' => 'wants', 'Развлечения' => 'wants', 'Подарки' => 'wants', 'Спорт' => 'wants', 'Другое' => 'wants', 'Сбережения' => 'savings', 'Инвестиции' => 'savings', ]; $allExpenseCats = []; foreach ($categories['expense'] as $c) $allExpenseCats[] = $c['name']; $defaultGroupsJson = json_encode($categoryGroups, JSON_UNESCAPED_UNICODE); $allExpenseCatsJson = json_encode($allExpenseCats, JSON_UNESCAPED_UNICODE); $ruleBuckets = [ 'needs' => ['label' => 'Необходимое', 'target' => 50, 'sum' => 0.0, 'color' => '#6ec6ff'], 'wants' => ['label' => 'Желания', 'target' => 30, 'sum' => 0.0, 'color' => '#ffa94d'], 'savings' => ['label' => 'Сбережения', 'target' => 20, 'sum' => 0.0, 'color' => '#78ff87'], ]; foreach ($byCatExpense as $catName => $info) { $group = $categoryGroups[$catName] ?? 'wants'; $ruleBuckets[$group]['sum'] += $info['sum']; } $ruleBase = $periodIncome > 0 ? $periodIncome : max($periodExpense, 1); $ruleResult = []; foreach ($ruleBuckets as $k => $b) { $pct = $ruleBase > 0 ? ($b['sum'] / $ruleBase) * 100 : 0; $ruleResult[$k] = [ 'label' => $b['label'], 'sum' => $b['sum'], 'pct' => $pct, 'target' => $b['target'], 'color' => $b['color'], 'status' => $pct > $b['target'] + 5 ? 'over' : ($pct < $b['target'] - 10 ? 'under' : 'ok'), ]; } // Перерасход $overrunWarnings = []; try { $threeMonthsAgoStart = (clone $anFirst)->modify('-3 months')->format('Y-m-01'); $prevMonthEnd = (clone $anFirst)->modify('-1 day')->format('Y-m-d'); $stmt = $pdo->prepare("SELECT c.name AS cat_name, SUM(f.amount) AS total FROM finance f LEFT JOIN finance_categories c ON c.id = f.category_id WHERE f.type='expense' AND f.date BETWEEN ? AND ? GROUP BY c.name"); $stmt->execute([$threeMonthsAgoStart, $prevMonthEnd]); $prevTotals = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) if (!empty($row['cat_name'])) $prevTotals[$row['cat_name']] = (float)$row['total'] / 3; $stmt = $pdo->prepare("SELECT c.name AS cat_name, c.color AS cat_color, SUM(f.amount) AS total FROM finance f LEFT JOIN finance_categories c ON c.id = f.category_id WHERE f.type='expense' AND f.date BETWEEN ? AND ? GROUP BY c.name, c.color"); $stmt->execute([$anFirst->format('Y-m-01'), $anLast->format('Y-m-d')]); $currentTotals = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) if (!empty($row['cat_name'])) $currentTotals[$row['cat_name']] = ['sum' => (float)$row['total'], 'color' => $row['cat_color'] ?? '#adb5bd']; foreach ($currentTotals as $catName => $info) { $prevAvg = $prevTotals[$catName] ?? 0; if ($prevAvg <= 0) continue; $diffPct = (($info['sum'] - $prevAvg) / $prevAvg) * 100; if ($diffPct >= 20) $overrunWarnings[] = ['name' => $catName, 'color' => $info['color'], 'current' => $info['sum'], 'avg' => $prevAvg, 'pct' => $diffPct]; } usort($overrunWarnings, fn($a, $b) => $b['pct'] <=> $a['pct']); } catch (PDOException $e) {} // Тепловая карта $heatmap = []; try { $stmt = $pdo->prepare("SELECT date, SUM(amount) AS total FROM finance WHERE type='expense' AND date BETWEEN ? AND ? GROUP BY date"); $stmt->execute([$hmFirst->format('Y-m-01'), $hmLast->format('Y-m-d')]); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $heatmap[$row['date']] = (float)$row['total']; } catch (PDOException $e) {} $heatmapMax = !empty($heatmap) ? max($heatmap) : 0; $hmFirstDayDow = (int)$hmFirst->format('N'); $hmDaysInMonth = (int)$hmLast->format('j'); // ============================================================ // ГРАФИК ДИНАМИКИ ПО МЕСЯЦАМ (12 месяцев) // ============================================================ $chartData = []; $chartStart = (clone $chFirst)->modify('-11 months'); $chartEnd = (clone $chFirst)->modify('last day of this month'); try { $stmt = $pdo->prepare(" SELECT DATE_FORMAT(date, '%Y-%m') AS ym, SUM(CASE WHEN type='income' THEN amount ELSE 0 END) AS inc, SUM(CASE WHEN type='expense' THEN amount ELSE 0 END) AS exp FROM finance WHERE date BETWEEN ? AND ? GROUP BY ym ORDER BY ym "); $stmt->execute([$chartStart->format('Y-m-01'), $chartEnd->format('Y-m-d')]); $map = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $map[$row['ym']] = ['inc' => (float)$row['inc'], 'exp' => (float)$row['exp']]; } $cursor = clone $chartStart; for ($i = 0; $i < 12; $i++) { $ym = $cursor->format('Y-m'); $chartData[] = [ 'ym' => $ym, 'label' => $monthNamesRu[(int)$cursor->format('n')] . ' ' . $cursor->format('y'), 'inc' => $map[$ym]['inc'] ?? 0.0, 'exp' => $map[$ym]['exp'] ?? 0.0, ]; $cursor->modify('+1 month'); } } catch (PDOException $e) {} $chartJson = json_encode($chartData, JSON_UNESCAPED_UNICODE); $chPrevUrl = buildUrlWithParams(['chart_month' => (clone $chFirst)->modify('-1 month')->format('Y-m')]); $chNextUrl = buildUrlWithParams(['chart_month' => (clone $chFirst)->modify('+1 month')->format('Y-m')]); $chMonthNameRu = $monthNamesRu[$chMonth] . ' ' . $chYear; // ============================================================ // Хелперы // ============================================================ function fmtMoney($v) { return number_format((float)$v, 2, '.', ' ') . ' ₽'; } function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } function buildList($rows, $type, $categories, $accounts) { if (empty($rows)) return '
Корректировки привязаны к конкретным счетам. Они не влияют на диаграммы доходов и расходов, но меняют баланс выбранного счёта и общий баланс.
Необходимое — до 50% дохода, желания — до 30%, сбережения — минимум 20%. Считается от доходов за выбранный период.
Настройки сохраняются в этом браузере (localStorage) и не влияют на других пользователей.
Сравнение выбранного месяца со средним за 3 предыдущих. Показаны категории, где превышение ≥ 20%.
= $warningsHtml ?>Цвет ячейки — сумма трат за день. Чем ярче, тем больше потрачено. Наведите курсор (или тапните) на день, чтобы увидеть сумму.
= $heatmapHtml ?>Цели помогают отслеживать накопления. Пополняйте цель кнопкой «+» — сумма сохранится. Кнопка 📦 закрывает цель и переносит её в архив (не удаляя).
= $goalsHtml ?> = $goalsArchivedHtml ?>Баланс счёта = все доходы по счёту − все расходы по счёту + корректировки. Для изменения названия дважды кликните по имени.
Сайт — это личный учёт денег: вы записываете доходы и расходы по категориям и счетам, а система автоматически считает баланс, показывает статистику, прогнозы и помогает копить на цели. Все данные хранятся в вашей базе MySQL, а настройки вроде правила 50/30/20 — в вашем браузере.
Общий баланс = сумма всех доходов − сумма всех расходов + сумма всех корректировок. Корректировка — это ручная правка баланса (например, если вы сняли наличные или перенесли деньги между счетами, и баланс нужно «подогнать»).
Баланс счёта = доходы по этому счёту − расходы по этому счёту + корректировки по этому счёту. Если вы редактируете счёт и вводите «Новый баланс», система автоматически создаёт корректировку на разницу: `новый − текущий`. Так баланс всегда сходится, а история изменений сохраняется.
Сбережения (расходы) — это сумма всех операций типа «расход» по категории с названием «Сбережения». Такие траты не уменьшают ваш капитал, а откладываются. Они показываются отдельной строкой под балансом.
Расходы — список всех расходов за выбранный период. Записи сгруппированы по категориям (свёрнуты по умолчанию). В заголовке группы: цвет, название, количество записей и общая сумма. Нажмите на заголовок, чтобы раскрыть/свернуть. Внутри каждой строки: дата, категория, примечание, сумма, кнопки ✎ (редактировать) и × (удалить).
Доходы — то же самое, но для доходов. Категории доходов свои (зарплата, отпускные, подарки и т.д.).
Корректировки — история ручных правок баланса. Здесь можно добавить новую корректировку: выберите счёт, укажите сумму (можно отрицательную), дату и примечание. Корректировки меняют баланс счёта, но не попадают в диаграммы доходов/расходов.
Анализ — четыре блока: прогноз расходов до конца месяца, правило 50/30/20, перерасход по категориям, тепловая карта расходов и график динамики по месяцам. Подробно о каждом — ниже.
Цели — список ваших финансовых целей с прогресс-барами. Каждую цель можно пополнять (кнопка «+ Сумма»), редактировать (✎), закрыть и перенести в архив (📦) или удалить (×). Архивные цели хранятся, но не учитываются в общем прогрессе под балансом.
Категории — управление категориями расходов и доходов. Можно добавить новую категорию с цветом, изменить цвет существующей или удалить (если по ней нет операций).
Счета — список ваших счетов (карты, наличные, вклады). У каждого счёта свой цвет и баланс. Двойной клик по названию — редактирование. Кнопка ✎ открывает окно с изменением названия, цвета и баланса.
Настройки — экспорт в CSV/SQL, импорт из SQL и удаление всех данных с возможностью сохранить категории, счета, корректировки и цели.
Вверху страницы есть выбор периода: день, неделя, месяц, год или произвольный диапазон. От выбранного периода зависят списки операций, суммы «За период» и диаграммы. Кнопка «Показать» применяет изменения.
На вкладках «Расходы» и «Доходы» есть строка поиска. Она ищет по примечанию, названию категории, названию счёта, сумме и дате в формате дд.мм.гггг. Если ввести «бензин» — останутся только записи с этим словом в примечании. Если ввести «Ипотека» — только записи этой категории.
Прогноз расходов до конца месяца считается так: `средний расход в день × оставшиеся дни + уже потрачено`. Средний расход = потрачено / прошедшие дни. Если прогноз превышает доход за месяц, блок «Остаток» подсвечивается красным и показывает дефицит.
Норма сбережений = сумма расходов по категории «Сбережения» / доход за месяц × 100%. Если вы откладываете 20% и больше — зелёный, 10–20% — жёлтый, меньше 10% — красный.
Чистая норма = (доход − расход) / доход × 100%. Это реальный профицит или дефицит месяца. Если вы тратите больше, чем зарабатываете, норма будет отрицательной.
Правило делит все расходы на три группы: Необходимое (до 50% дохода), Желания (до 30%), Сбережения (минимум 20%). Категории распределяются по группам автоматически по названию. Если название нестандартное — по умолчанию попадает в «Желания».
Кнопка «⚙ Настроить категории» открывает панель, где можно вручную указать, к какой группе относится каждая категория. Настройки сохраняются в localStorage вашего браузера — на других устройствах они не появятся. Кнопка «Сбросить к дефолту» возвращает распределение по умолчанию.
Для каждой группы показывается: сумма, процент от дохода, целевой процент и статус: «в норме», «перерасход» (превышение на 5%+) или «запас» (недобор на 10%+).
Система берёт выбранный месяц (стрелки ‹ ›) и сравнивает траты по каждой категории со средним за 3 предыдущих месяца. Если текущий месяц превышает среднее на 20% и больше — категория попадает в список предупреждений. Так видно, где вы «сорвались» и потратили больше обычного.
Календарь на выбранный месяц. Каждая ячейка — день, цвет зависит от суммы трат: чем ярче и краснее — тем больше потрачено. Наведите курсор (или тапните) — появится подсказка с точной суммой. Сегодняшний день обведён зелёным.
Линейный график доходов и расходов за последние 12 месяцев (до выбранного месяца включительно). Синяя линия — доходы, красная — расходы, зелёная — сальдо (разница). Стрелки ‹ › переключают окно на предыдущий/следующий месяц. График помогает увидеть тренд: растут ли расходы, стабилен ли доход и в каком месяце был «провал».
Каждая цель — это название, целевая сумма, уже накопленная сумма, необязательный дедлайн, цвет и примечание. Прогресс-бар показывает процент выполнения. Под ним: «Осталось» и дедлайн (жёлтый — меньше 30 дней, красный — просрочен).
Пополнение: введите сумму в поле «+ Сумма» и нажмите «+». Сумма прибавится к текущей. Чтобы изменить параметры цели — нажмите ✎. Чтобы закрыть и убрать с главного экрана — 📦 (цель уходит в «Архив целей» внизу вкладки, но остаётся в базе). Вернуть из архива — кнопка ↺. Удалить навсегда — ×.
Общий прогресс под балансом = сумма накоплений по всем активным целям / сумма целевых сумм × 100%. Архивные цели в этом расчёте не участвуют.
Экспорт в CSV — скачивает файл, который открывается в Excel. Можно выбрать «Всё», «Только операции», «Только категории», «Только корректировки» или «Только цели». Разделитель `;`, кодировка UTF-8 с BOM — Excel откроет без «кракозябр».
Экспорт в SQL — скачивает .sql-файл с INSERT-ами для всех таблиц: категории, счета, операции, корректировки, цели. Его можно импортировать в другую базу или сохранить как резервную копию.
Импорт из SQL — загружаете .sql-файл, система выполняет только INSERT-ы в таблицы `finance*`. Старые данные не удаляются. Если запись с таким `id` уже есть — она пропускается (ошибка 23000).
В «Настройках» внизу — блок «Удалить все данные». Нужно ввести слово УДАЛИТЬ заглавными. Можно сохранить категории, счета, корректировки и цели, поставив галочки — тогда удалятся только операции. Действие необратимо, поэтому сначала сделайте экспорт.
Страница работает на PHP + PDO + MySQL. Все операции защищены от SQL-инъекций (prepared statements). После добавления/редактирования/удаления используется PRG (Post/Redirect/Get), чтобы при обновлении страницы действие не повторилось. Сообщения об успехе/ошибке передаются через сессию.
Состояние вкладок (какая открыта) и правило 50/30/20 сохраняются в localStorage браузера. Свёрнутость групп категорий по умолчанию всегда сброшена — при обновлении страницы все группы свёрнуты.
Структура таблиц: `finance` (операции), `finance_categories` (категории), `finance_accounts` (счета), `finance_adjustments` (корректировки), `finance_goals` (цели). Таблица целей создаётся автоматически при первом заходе, если её нет.
1. Откройте вкладку «Счета» и добавьте свой счёт (например, «Карта»). 2. Откройте «Категории» и проверьте/добавьте нужные категории расходов и доходов. 3. Нажмите «+ Добавить» на вкладке «Расходы» или «Доходы» и внесите первую операцию. 4. Перейдите в «Анализ» — увидите прогноз и графики. 5. Создайте цель на вкладке «Цели» и пополняйте её. Готово — теперь вы контролируете свои финансы.
Скачивается CSV-файл, который открывается в Excel.
Скачивается .sql-файл с INSERT-ами для всех таблиц.
Загрузите .sql-файл с INSERT-ами (например, экспорт из этого приложения). Файл выполняется построчно через PDO. Старые данные не удаляются.
Осторожно: действие необратимо. Перед удалением рекомендуется сделать экспорт (CSV или SQL).