| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864 |
- 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<string, DataGridView> _grids = new();
- private readonly Dictionary<string, TextBox> _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<DataColumn>().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 = "<!doctype html><meta charset=\"utf-8\"><title>Отчет Автопрокат</title>" +
- "<style>body{font-family:Segoe UI,Arial;margin:32px}pre{white-space:pre-wrap;font:14px Consolas}</style>" +
- "<h1>Отчет по системе Автопрокат</h1><pre>" + System.Net.WebUtility.HtmlEncode(text) + "</pre>";
- 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<DataColumn>().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<TabPage>().SelectMany(p => p.Controls.Find("reportBox", true)).OfType<TextBox>().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<string, Control> _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("Неизвестная таблица.")
- };
- }
|