using System.Data; using System.Diagnostics; using System.Globalization; using System.Text; namespace AutorentWinForms; public sealed class MainForm : Form { private readonly Database _db; private readonly UserSession _session; private readonly TabControl _tabs = new() { Dock = DockStyle.Fill }; private readonly Dictionary _grids = new(); private readonly Dictionary _searchBoxes = new(); private bool CanWrite => _session.Role is "admin" or "manager"; private bool CanEditBrands => _session.Role == "admin"; private bool IsAdmin => _session.Role == "admin"; public MainForm(Database db, UserSession session) { _db = db; _session = session; Text = $"Автопрокат - {_session.FullName} ({RoleName(_session.Role)})"; StartPosition = FormStartPosition.CenterScreen; WindowState = FormWindowState.Maximized; MinimumSize = new Size(1100, 700); Font = new Font("Segoe UI", 10F); Controls.Add(_tabs); CreateDataTab("clients", "Клиенты"); CreateDataTab("cars", "Автомобили"); CreateDataTab("brands", "Марки"); CreateDataTab("rentals", "Аренда"); if (_session.Role != "viewer") { CreateReportsTab(); CreateSecurityTab(); CreateAuditTab(); } LoadAll(); } private void CreateDataTab(string table, string caption) { var page = new TabPage(caption); var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 }; root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(8), WrapContents = false }; var search = new TextBox { Width = 320, PlaceholderText = "Поиск..." }; search.TextChanged += (_, _) => LoadTable(table); _searchBoxes[table] = search; var add = new Button { Text = "Добавить", Width = 105, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) }; add.Click += (_, _) => EditRecord(table, null); var edit = new Button { Text = "Редактировать", Width = 125, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) }; edit.Click += (_, _) => EditSelected(table); var delete = new Button { Text = "Удалить", Width = 95, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) }; delete.Click += (_, _) => DeleteSelected(table); var refresh = new Button { Text = "Обновить", Width = 100, Height = 30 }; refresh.Click += (_, _) => LoadTable(table); var export = new Button { Text = "CSV", Width = 70, Height = 30 }; export.Click += (_, _) => ExportCsv(table); bar.Controls.AddRange([search, add, edit, delete, refresh, export]); var grid = new DataGridView { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, AllowUserToDeleteRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect, MultiSelect = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, BackgroundColor = Color.White }; grid.CellDoubleClick += (_, _) => { if (CanWrite && (table != "brands" || CanEditBrands)) EditSelected(table); }; _grids[table] = grid; root.Controls.Add(bar, 0, 0); root.Controls.Add(grid, 0, 1); page.Controls.Add(root); _tabs.TabPages.Add(page); } private void CreateReportsTab() { var page = new TabPage("Отчеты"); var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 }; root.RowStyles.Add(new RowStyle(SizeType.Absolute, 54)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(8) }; var refresh = new Button { Text = "Сформировать", Width = 130, Height = 32 }; var html = new Button { Text = "Отчет HTML", Width = 120, Height = 32 }; var txt = new Button { Text = "Отчет TXT", Width = 110, Height = 32 }; refresh.Click += (_, _) => LoadReports(); html.Click += (_, _) => SaveReport("html"); txt.Click += (_, _) => SaveReport("txt"); bar.Controls.AddRange([refresh, html, txt]); var report = new TextBox { Name = "reportBox", Dock = DockStyle.Fill, Multiline = true, ScrollBars = ScrollBars.Both, ReadOnly = true, Font = new Font("Consolas", 10F), WordWrap = false }; root.Controls.Add(bar, 0, 0); root.Controls.Add(report, 0, 1); page.Controls.Add(root); _tabs.TabPages.Add(page); } private void CreateSecurityTab() { var page = new TabPage("Защита"); page.BackColor = Color.FromArgb(245, 247, 250); var scroll = new Panel { Dock = DockStyle.Fill, AutoScroll = true, Padding = new Padding(18), BackColor = Color.FromArgb(245, 247, 250) }; var content = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 1, BackColor = Color.Transparent }; content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); content.Controls.Add(CreateSectionTitle("Пользователи БД и обязанности"), 0, content.RowCount++); content.Controls.Add(CreateRolesPanel(), 0, content.RowCount++); content.Controls.Add(CreateSectionTitle("Реализованные уровни защиты"), 0, content.RowCount++); content.Controls.Add(CreateProtectionMatrix(), 0, content.RowCount++); content.Controls.Add(CreateSectionTitle("Защита на уровне базы данных"), 0, content.RowCount++); content.Controls.Add(CreateBulletsCard("Ограничения целостности и контроль данных", [ "PRIMARY KEY: brand_id, car_id, client_id, rental_id исключают дублирование основных сущностей.", "FOREIGN KEY: car.brand_id, rental.car_id, rental.client_id защищают связи между марками, автомобилями, клиентами и договорами.", "ON UPDATE CASCADE сохраняет связи при изменении ключей, ON DELETE RESTRICT запрещает удаление используемых записей.", "UNIQUE: название марки, госномер, телефон клиента и номер водительского удостоверения не могут повторяться.", "CHECK: год выпуска 1990-2030, стоимость аренды больше 0, сумма договора не отрицательная, дата окончания не раньше даты начала.", "CHECK по статусам ограничивает значения: автомобиль может быть только «Свободен», «В аренде» или «На ТО», договор - «Активна» или «Завершена».", "Индексы idx_car_brand_id, idx_car_status, idx_rental_car_id, idx_rental_cli_id, idx_rental_dates, idx_rental_status ускоряют поиск и отчеты." ]), 0, content.RowCount++); content.Controls.Add(CreateBulletsCard("Представления для безопасной отчетности", [ "v_rentals_full показывает договоры с ФИО клиента, телефоном, номером автомобиля, маркой, датами, суммой и статусом.", "v_available_cars ограничивает выборку только свободными автомобилями.", "v_client_stats агрегирует количество аренд и сумму затрат по клиентам.", "Отчеты приложения строятся по представлениям и агрегатным запросам, без ручной сборки данных пользователем." ]), 0, content.RowCount++); content.Controls.Add(CreateSectionTitle("Разграничение доступа"), 0, content.RowCount++); content.Controls.Add(CreateBulletsCard("Роли PostgreSQL из проекта", [ "rent_admin - полный доступ ко всем таблицам и последовательностям.", "rent_manager - SELECT, INSERT, UPDATE для client, car, rental; SELECT по brand и отчетным VIEW.", "rent_viewer - только SELECT, подходит для аналитика и преподавательской проверки.", "Row Level Security включен для rental: политики разрешают полный доступ администратору и менеджеру, а просмотр можно ограничивать отдельной SELECT-политикой.", "В приложении дополнительно используются роли admin, manager, viewer в таблице app_user, чтобы управлять доступностью кнопок и операций интерфейса." ]), 0, content.RowCount++); content.Controls.Add(CreateSectionTitle("Защита на уровне приложения"), 0, content.RowCount++); content.Controls.Add(CreateBulletsCard("Меры, реализованные в Windows Forms", [ "Авторизация по логину и паролю перед открытием главного окна.", "Пароли хранятся как PBKDF2-SHA256 хеш с индивидуальной солью, а не в открытом виде.", "Права интерфейса зависят от роли: viewer не может добавлять, редактировать и удалять записи.", "Все запросы выполняются через параметры Npgsql, что защищает от SQL-инъекций.", "Строка подключения вынесена в dbsettings.json или переменную AUTORENT_CONNECTION_STRING.", "dbsettings.json добавлен в .gitignore, чтобы пароль от БД не попадал в репозиторий.", "При запуске приложение синхронизирует SERIAL-последовательности с MAX(id), чтобы исключить duplicate key после ручного импорта данных." ]), 0, content.RowCount++); content.Controls.Add(CreateSectionTitle("Аудит операций"), 0, content.RowCount++); content.Controls.Add(CreateBulletsCard("Логирование изменений", [ "Таблица audit_log хранит время операции, пользователя, действие, таблицу, ID записи и детали изменения.", "Триггеры AFTER INSERT OR UPDATE OR DELETE установлены на brand, car, client и rental.", "Текущий пользователь приложения сохраняется в app_context и попадает в журнал аудита.", "Отдельная вкладка «Журнал операций» показывает последние операции из audit_log.", "Такой аудит помогает доказать, кто и когда изменял автомобили, клиентов и договоры аренды." ]), 0, content.RowCount++); content.Controls.Add(CreateSqlCard(), 0, content.RowCount++); scroll.Controls.Add(content); page.Controls.Add(scroll); _tabs.TabPages.Add(page); } private static Label CreateSectionTitle(string text) => new() { Text = text, Dock = DockStyle.Top, Height = 34, ForeColor = Color.FromArgb(30, 41, 59), Font = new Font("Segoe UI", 13F, FontStyle.Bold), Margin = new Padding(0, 8, 0, 4) }; private static Control CreateRolesPanel() { var layout = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 3, Margin = new Padding(0, 0, 0, 12) }; layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F)); layout.Controls.Add(CreateRoleCard("admin", "Администратор", "Полный доступ", "Управляет структурой БД, справочниками, пользователями, аудитом, резервным копированием и контролем целостности.", Color.FromArgb(185, 28, 28)), 0, 0); layout.Controls.Add(CreateRoleCard("manager", "Менеджер проката", "Рабочий CRUD", "Вводит клиентов, автомобили и договоры аренды, редактирует рабочие данные, формирует отчеты.", Color.FromArgb(21, 91, 158)), 1, 0); layout.Controls.Add(CreateRoleCard("viewer", "Аналитик", "Только чтение", "Просматривает данные, строит отчеты и проверяет показатели без права изменения записей.", Color.FromArgb(22, 101, 52)), 2, 0); return layout; } private static Panel CreateRoleCard(string login, string title, string access, string description, Color accent) { var panel = CreateBaseCard(174); panel.Margin = new Padding(0, 0, 10, 0); panel.Padding = new Padding(14); var loginLabel = new Label { Text = login, ForeColor = accent, Font = new Font("Segoe UI", 11F, FontStyle.Bold), Dock = DockStyle.Top, Height = 26 }; var titleLabel = new Label { Text = title, ForeColor = Color.FromArgb(15, 23, 42), Font = new Font("Segoe UI", 12F, FontStyle.Bold), Dock = DockStyle.Top, Height = 30 }; var accessLabel = new Label { Text = access, ForeColor = Color.White, BackColor = accent, Font = new Font("Segoe UI", 9F, FontStyle.Bold), AutoSize = false, Height = 26, Dock = DockStyle.Top, TextAlign = ContentAlignment.MiddleCenter, Margin = new Padding(0, 0, 0, 8) }; var body = new Label { Text = description, ForeColor = Color.FromArgb(71, 85, 105), Font = new Font("Segoe UI", 9.5F), Dock = DockStyle.Fill }; panel.Controls.Add(body); panel.Controls.Add(accessLabel); panel.Controls.Add(titleLabel); panel.Controls.Add(loginLabel); return panel; } private static Control CreateProtectionMatrix() { var layout = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 2, Margin = new Padding(0, 0, 0, 12) }; layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); layout.Controls.Add(CreateBulletsCard("1. Идентификация и аутентификация", [ "Пользователь входит по логину и паролю.", "Учетные записи хранятся в app_user.", "Пароль проверяется по хешу PBKDF2-SHA256 с солью." ]), 0, 0); layout.Controls.Add(CreateBulletsCard("2. Авторизация и роли", [ "admin, manager, viewer управляют доступом в интерфейсе.", "rent_admin, rent_manager, rent_viewer описывают права на уровне PostgreSQL.", "Viewer не получает кнопки изменения данных." ]), 1, 0); layout.Controls.Add(CreateBulletsCard("3. Целостность данных", [ "PK, FK, UNIQUE, CHECK защищают структуру БД.", "ON DELETE RESTRICT запрещает удалить запись, если она участвует в договоре.", "Последовательности синхронизируются после импорта." ]), 0, 1); layout.Controls.Add(CreateBulletsCard("4. Контроль и аудит", [ "Триггеры фиксируют INSERT, UPDATE и DELETE.", "audit_log хранит пользователя, таблицу, действие и детали.", "Журнал доступен в отдельной вкладке приложения." ]), 1, 1); return layout; } private static Panel CreateBulletsCard(string title, string[] bullets) { var panel = CreateBaseCard(Math.Max(112, 48 + bullets.Length * 26)); panel.Padding = new Padding(14); panel.Margin = new Padding(0, 0, 10, 12); var titleLabel = new Label { Text = title, Dock = DockStyle.Top, Height = 30, ForeColor = Color.FromArgb(15, 23, 42), Font = new Font("Segoe UI", 11F, FontStyle.Bold) }; var body = new Label { Text = string.Join(Environment.NewLine, bullets.Select(x => " - " + x)), Dock = DockStyle.Fill, ForeColor = Color.FromArgb(51, 65, 85), Font = new Font("Segoe UI", 9.6F), AutoEllipsis = false }; panel.Controls.Add(body); panel.Controls.Add(titleLabel); return panel; } private static Panel CreateSqlCard() { var panel = CreateBaseCard(270); panel.Padding = new Padding(14); panel.Margin = new Padding(0, 0, 0, 12); var title = new Label { Text = "Ключевые SQL-механизмы защиты", Dock = DockStyle.Top, Height = 30, ForeColor = Color.FromArgb(15, 23, 42), Font = new Font("Segoe UI", 11F, FontStyle.Bold) }; var sql = new RichTextBox { Dock = DockStyle.Fill, ReadOnly = true, BorderStyle = BorderStyle.None, BackColor = Color.FromArgb(248, 250, 252), ForeColor = Color.FromArgb(30, 41, 59), Font = new Font("Consolas", 9F), Text = """ -- Ограничения целостности CONSTRAINT uq_client_phone UNIQUE, CONSTRAINT chk_rental_dates CHECK (end_date >= start_date), CONSTRAINT fk_rental_client REFERENCES client(client_id) ON DELETE RESTRICT -- Роли и права CREATE ROLE rent_admin NOLOGIN; CREATE ROLE rent_manager NOLOGIN; CREATE ROLE rent_viewer NOLOGIN; GRANT SELECT, INSERT, UPDATE ON client, car, rental TO rent_manager; GRANT SELECT ON ALL TABLES IN SCHEMA public TO rent_viewer; -- Row Level Security ALTER TABLE rental ENABLE ROW LEVEL SECURITY; CREATE POLICY manager_access ON rental FOR ALL TO rent_manager USING (true) WITH CHECK (true); -- Аудит CREATE TRIGGER trg_audit_client AFTER INSERT OR UPDATE OR DELETE ON client FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('client_id'); """ }; panel.Controls.Add(sql); panel.Controls.Add(title); return panel; } private static Panel CreateBaseCard(int height) => new() { Height = height, Dock = DockStyle.Top, BackColor = Color.White, BorderStyle = BorderStyle.FixedSingle }; private void CreateAuditTab() { var page = new TabPage("Журнал операций"); var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2 }; root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(8) }; var refresh = new Button { Text = "Обновить", Width = 105, Height = 30 }; refresh.Click += (_, _) => LoadAudit(); bar.Controls.Add(refresh); var grid = new DataGridView { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, BackgroundColor = Color.White }; _grids["audit"] = grid; root.Controls.Add(bar, 0, 0); root.Controls.Add(grid, 0, 1); page.Controls.Add(root); _tabs.TabPages.Add(page); } private void LoadAll() { foreach (var table in new[] { "clients", "cars", "brands", "rentals" }) LoadTable(table); if (_session.Role != "viewer") { LoadReports(); LoadAudit(); } } private void LoadTable(string table) { var q = _searchBoxes.TryGetValue(table, out var box) ? box.Text.Trim() : ""; var like = $"%{q}%"; var data = table switch { "clients" => _db.Query(""" SELECT client_id AS ID, full_name AS ФИО, phone AS Телефон, address AS Адрес, license_no AS ВУ, date(created_at) AS Регистрация FROM client WHERE @q = '' OR full_name LIKE @like OR phone LIKE @like OR address LIKE @like OR license_no LIKE @like ORDER BY client_id DESC """, new() { ["@q"] = q, ["@like"] = like }), "cars" => _db.Query(""" SELECT ca.car_id AS ID, ca.license_plate AS Госномер, b.name AS Марка, ca.year AS Год, ca.daily_rate AS Цена_день, ca.status AS Статус FROM car ca JOIN brand b ON b.brand_id = ca.brand_id WHERE @q = '' OR ca.license_plate LIKE @like OR b.name LIKE @like OR ca.status LIKE @like ORDER BY ca.car_id DESC """, new() { ["@q"] = q, ["@like"] = like }), "brands" => _db.Query(""" SELECT b.brand_id AS ID, b.name AS Марка, b.country AS Страна, COUNT(c.car_id) AS Автомобилей FROM brand b LEFT JOIN car c ON c.brand_id = b.brand_id WHERE @q = '' OR b.name LIKE @like OR b.country LIKE @like GROUP BY b.brand_id, b.name, b.country ORDER BY b.brand_id DESC """, new() { ["@q"] = q, ["@like"] = like }), "rentals" => _db.Query(""" SELECT r.rental_id AS ID, c.full_name AS Клиент, ca.license_plate AS Авто, b.name AS Марка, r.start_date AS Начало, r.end_date AS Конец, r.actual_return AS Возврат, r.total_cost AS Сумма, r.status AS Статус FROM rental r JOIN client c ON c.client_id = r.client_id JOIN car ca ON ca.car_id = r.car_id JOIN brand b ON b.brand_id = ca.brand_id WHERE @q = '' OR c.full_name LIKE @like OR ca.license_plate LIKE @like OR b.name LIKE @like OR r.status LIKE @like ORDER BY r.rental_id DESC """, new() { ["@q"] = q, ["@like"] = like }), _ => new DataTable() }; _grids[table].DataSource = data; } private void EditSelected(string table) { var row = _grids[table].CurrentRow; if (row?.Cells["ID"].Value is null) return; EditRecord(table, Convert.ToInt32(row.Cells["ID"].Value)); } private void EditRecord(string table, int? id) { try { using var form = new RecordForm(_db, table, id); if (form.ShowDialog(this) != DialogResult.OK) return; LoadTable(table); LoadReports(); LoadAudit(); } catch (Exception ex) { MessageBox.Show(ex.Message, "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void DeleteSelected(string table) { var row = _grids[table].CurrentRow; if (row?.Cells["ID"].Value is null) return; var id = Convert.ToInt32(row.Cells["ID"].Value); if (MessageBox.Show("Удалить выбранную запись?", "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) return; try { var pk = table switch { "clients" => "client_id", "cars" => "car_id", "brands" => "brand_id", "rentals" => "rental_id", _ => "id" }; var dbTable = table.TrimEnd('s'); if (table == "clients") dbTable = "client"; if (table == "cars") dbTable = "car"; if (table == "brands") dbTable = "brand"; if (table == "rentals") dbTable = "rental"; _db.ExecuteNonQuery($"DELETE FROM {dbTable} WHERE {pk} = @id", new() { ["@id"] = id }); LoadTable(table); LoadReports(); LoadAudit(); } catch (Exception ex) { MessageBox.Show("Удаление невозможно: запись используется в связанных таблицах или нарушает ограничения.\n\n" + ex.Message, "Защита целостности", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private void LoadAudit() { _grids["audit"].DataSource = _db.Query(""" SELECT audit_id AS ID, changed_at AS Время, user_login AS Пользователь, action AS Операция, entity AS Таблица, entity_id AS Запись, details AS Детали FROM audit_log ORDER BY audit_id DESC LIMIT 300 """); } private void LoadReports() { var box = FindReportBox(); box.Text = BuildReportText(); } private string BuildReportText() { var stats = _db.Query(""" SELECT (SELECT COUNT(*) FROM client) AS clients, (SELECT COUNT(*) FROM car) AS cars, (SELECT COUNT(*) FROM rental WHERE status = 'Активна') AS active_rentals, (SELECT COALESCE(SUM(total_cost),0) FROM rental WHERE status = 'Завершена') AS revenue """).Rows[0]; var sb = new StringBuilder(); sb.AppendLine("ОТЧЕТ ПО СИСТЕМЕ АВТОПРОКАТА"); sb.AppendLine($"Дата формирования: {DateTime.Now:dd.MM.yyyy HH:mm}"); sb.AppendLine($"Пользователь: {_session.FullName} ({RoleName(_session.Role)})"); sb.AppendLine(new string('=', 72)); sb.AppendLine($"Клиентов: {stats["clients"]}"); sb.AppendLine($"Автомобилей: {stats["cars"]}"); sb.AppendLine($"Активных аренд: {stats["active_rentals"]}"); sb.AppendLine($"Выручка по завершенным арендам: {Convert.ToDecimal(stats["revenue"]).ToString("N2", CultureInfo.GetCultureInfo("ru-RU"))} руб."); sb.AppendLine(); AppendTable(sb, "Активные договоры", _db.Query("SELECT * FROM v_rentals_full WHERE status='Активна' ORDER BY start_date DESC")); AppendTable(sb, "Статистика по клиентам", _db.Query("SELECT full_name, total_rentals, total_spent FROM v_client_stats ORDER BY total_spent DESC")); AppendTable(sb, "Свободные автомобили", _db.Query("SELECT * FROM v_available_cars ORDER BY daily_rate DESC")); return sb.ToString(); } private static void AppendTable(StringBuilder sb, string title, DataTable table) { sb.AppendLine(title); sb.AppendLine(new string('-', title.Length)); if (table.Rows.Count == 0) { sb.AppendLine("Нет данных."); sb.AppendLine(); return; } sb.AppendLine(string.Join(" | ", table.Columns.Cast().Select(c => c.ColumnName))); foreach (DataRow row in table.Rows) sb.AppendLine(string.Join(" | ", row.ItemArray.Select(v => Convert.ToString(v)))); sb.AppendLine(); } private void SaveReport(string format) { var reportsDir = Path.Combine(AppContext.BaseDirectory, "Reports"); Directory.CreateDirectory(reportsDir); var path = Path.Combine(reportsDir, $"autorent_report_{DateTime.Now:yyyyMMdd_HHmmss}.{format}"); var text = BuildReportText(); if (format == "html") { var html = "Отчет Автопрокат" + "" + "

Отчет по системе Автопрокат

" + System.Net.WebUtility.HtmlEncode(text) + "
"; File.WriteAllText(path, html, Encoding.UTF8); } else { File.WriteAllText(path, text, Encoding.UTF8); } MessageBox.Show($"Отчет сохранен:\n{path}", "Готово", MessageBoxButtons.OK, MessageBoxIcon.Information); try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } catch { } } private void ExportCsv(string table) { if (_grids[table].DataSource is not DataTable data) return; var reportsDir = Path.Combine(AppContext.BaseDirectory, "Reports"); Directory.CreateDirectory(reportsDir); var path = Path.Combine(reportsDir, $"{table}_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); var sb = new StringBuilder(); sb.AppendLine(string.Join(";", data.Columns.Cast().Select(c => EscapeCsv(c.ColumnName)))); foreach (DataRow row in data.Rows) sb.AppendLine(string.Join(";", row.ItemArray.Select(v => EscapeCsv(Convert.ToString(v) ?? "")))); File.WriteAllText(path, sb.ToString(), new UTF8Encoding(true)); MessageBox.Show($"CSV сохранен:\n{path}", "Экспорт", MessageBoxButtons.OK, MessageBoxIcon.Information); } private static string EscapeCsv(string value) => "\"" + value.Replace("\"", "\"\"") + "\""; private TextBox FindReportBox() { return _tabs.TabPages.Cast().SelectMany(p => p.Controls.Find("reportBox", true)).OfType().First(); } private static string RoleName(string role) => role switch { "admin" => "Администратор", "manager" => "Менеджер", "viewer" => "Аналитик", _ => role }; private string SecurityText() => """ Пользователи и обязанности 1. admin / Администратор БД Полный доступ к справочникам, автомобилям, клиентам, договорам, отчетам и журналу аудита. Отвечает за структуру БД, учетные записи, резервное копирование и контроль целостности. 2. manager / Менеджер проката Вводит клиентов, автомобили и договоры аренды, редактирует рабочие данные, формирует отчеты. Не управляет ролями и справочником марок без администратора. 3. viewer / Аналитик Имеет доступ только к просмотру данных и формированию отчетов. Изменение и удаление записей запрещено. Защита на уровне приложения - вход по логину и паролю; - хранение паролей в виде PBKDF2-SHA256 хеша с индивидуальной солью; - разграничение прав по ролям admin, manager, viewer; - запрет кнопок добавления, редактирования и удаления для роли viewer; - параметризованные SQL-запросы вместо конкатенации пользовательского ввода; - обработка ошибок ограничений БД без раскрытия лишней служебной информации пользователю. Защита на уровне базы данных - первичные и внешние ключи; - UNIQUE для госномера, телефона, водительского удостоверения и названия марки; - CHECK для статусов, положительной цены, корректного года выпуска и дат аренды; - ON DELETE RESTRICT для защиты связанных договоров от случайного удаления; - представления v_rentals_full, v_available_cars, v_client_stats для отчетности; - триггеры INSERT/UPDATE/DELETE на основных таблицах пишут операции в audit_log; - PRAGMA foreign_keys = ON и WAL-журналирование при работе приложения. """; } internal sealed class RecordForm : Form { private readonly Database _db; private readonly string _table; private readonly int? _id; private readonly Dictionary _fields = new(); public RecordForm(Database db, string table, int? id) { _db = db; _table = table; _id = id; Text = id is null ? "Добавить запись" : "Редактировать запись"; StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; MinimizeBox = false; ClientSize = new Size(520, table == "rentals" ? 430 : 350); Font = new Font("Segoe UI", 10F); BuildForm(); } private void BuildForm() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), ColumnCount = 2, AutoScroll = true }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 170)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); var data = _id is null ? null : LoadRecord(); foreach (var field in Fields()) AddField(panel, field, data); var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill }; var save = new Button { Text = "Сохранить", Width = 110, Height = 32 }; var cancel = new Button { Text = "Отмена", Width = 90, Height = 32 }; save.Click += (_, _) => Save(); cancel.Click += (_, _) => Close(); buttons.Controls.AddRange([save, cancel]); panel.Controls.Add(new Label(), 0, panel.RowCount); panel.Controls.Add(buttons, 1, panel.RowCount++); Controls.Add(panel); AcceptButton = save; } private DataRow LoadRecord() { var (dbTable, pk) = DbTable(); return _db.Query($"SELECT * FROM {dbTable} WHERE {pk}=@id", new() { ["@id"] = _id }).Rows[0]; } private IEnumerable<(string Name, string Label, string Type)> Fields() => _table switch { "clients" => [("full_name", "ФИО", "text"), ("phone", "Телефон", "text"), ("address", "Адрес", "text"), ("license_no", "Водительское удостоверение", "text")], "cars" => [("license_plate", "Госномер", "text"), ("brand_id", "Марка", "brand"), ("year", "Год выпуска", "number"), ("daily_rate", "Цена за день", "money"), ("status", "Статус", "car_status")], "brands" => [("name", "Марка", "text"), ("country", "Страна", "text")], "rentals" => [("client_id", "Клиент", "client"), ("car_id", "Автомобиль", "car"), ("start_date", "Дата начала", "date"), ("end_date", "Дата окончания", "date"), ("actual_return", "Фактический возврат", "date"), ("total_cost", "Сумма", "money"), ("status", "Статус", "rental_status")], _ => [] }; private void AddField(TableLayoutPanel panel, (string Name, string Label, string Type) field, DataRow? data) { panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); panel.Controls.Add(new Label { Text = field.Label, AutoSize = true, Anchor = AnchorStyles.Left }, 0, panel.RowCount); Control input = field.Type switch { "brand" => Combo("SELECT brand_id, name FROM brand ORDER BY name", "brand_id", data), "client" => Combo("SELECT client_id, full_name FROM client ORDER BY full_name", "client_id", data), "car" => Combo("SELECT car_id, license_plate || ' - ' || (SELECT name FROM brand WHERE brand_id=car.brand_id) FROM car ORDER BY license_plate", "car_id", data), "car_status" => ComboValues(["Свободен", "В аренде", "На ТО"], field.Name, data), "rental_status" => ComboValues(["Активна", "Завершена"], field.Name, data), "date" => new DateTimePicker { Width = 280, Format = DateTimePickerFormat.Short, ShowCheckBox = field.Name == "actual_return" }, _ => new TextBox { Width = 280 } }; if (input is TextBox tb) tb.Text = data is null ? "" : Convert.ToString(data[field.Name]); if (input is DateTimePicker dtp) { var value = data is null ? "" : Convert.ToString(data[field.Name]); if (DateTime.TryParse(value, out var date)) { dtp.Value = date; dtp.Checked = true; } else { dtp.Value = DateTime.Today; dtp.Checked = field.Name != "actual_return"; } } input.Anchor = AnchorStyles.Left; _fields[field.Name] = input; panel.Controls.Add(input, 1, panel.RowCount++); } private ComboBox Combo(string sql, string fieldName, DataRow? data) { var combo = new ComboBox { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList }; var table = _db.Query(sql); combo.DataSource = table; combo.ValueMember = table.Columns[0].ColumnName; combo.DisplayMember = table.Columns[1].ColumnName; if (data is not null && data[fieldName] != DBNull.Value) combo.SelectedValue = Convert.ToInt32(data[fieldName]); return combo; } private static ComboBox ComboValues(string[] values, string fieldName, DataRow? data) { var combo = new ComboBox { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList }; combo.Items.AddRange(values); combo.SelectedItem = data is null ? values[0] : Convert.ToString(data[fieldName]); return combo; } private void Save() { try { ValidateRequired(); var (dbTable, pk) = DbTable(); var fields = Fields().Select(f => f.Name).ToArray(); var values = fields.ToDictionary(f => "@" + f, ReadValue); if (_id is null) { var sql = $"INSERT INTO {dbTable}({string.Join(",", fields)}) VALUES({string.Join(",", values.Keys)})"; _db.ExecuteNonQuery(sql, values); } else { values["@id"] = _id; var set = string.Join(",", fields.Select(f => $"{f}=@{f}")); _db.ExecuteNonQuery($"UPDATE {dbTable} SET {set} WHERE {pk}=@id", values); } DialogResult = DialogResult.OK; Close(); } catch (Exception ex) { MessageBox.Show(ex.Message, "Ошибка сохранения", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void ValidateRequired() { foreach (var field in Fields()) { if (_fields[field.Name] is TextBox tb && string.IsNullOrWhiteSpace(tb.Text) && field.Name is "full_name" or "license_plate" or "name") throw new InvalidOperationException($"Заполните поле «{field.Label}»."); } } private object? ReadValue(string parameterName) { var name = parameterName.TrimStart('@'); var control = _fields[name]; return control switch { TextBox tb when name is "year" => int.TryParse(tb.Text, out var i) ? i : null, TextBox tb when name is "daily_rate" or "total_cost" => decimal.TryParse(tb.Text.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null, TextBox tb => string.IsNullOrWhiteSpace(tb.Text) ? null : tb.Text.Trim(), ComboBox cb => cb.SelectedValue is DataRowView ? cb.Text : cb.SelectedValue ?? cb.SelectedItem, DateTimePicker dtp => dtp.Checked ? dtp.Value.ToString("yyyy-MM-dd") : null, _ => null }; } private (string Table, string Pk) DbTable() => _table switch { "clients" => ("client", "client_id"), "cars" => ("car", "car_id"), "brands" => ("brand", "brand_id"), "rentals" => ("rental", "rental_id"), _ => throw new InvalidOperationException("Неизвестная таблица.") }; }