MainForm.cs 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. using System.Data;
  2. using System.Diagnostics;
  3. using System.Globalization;
  4. using System.Text;
  5. namespace AutorentWinForms;
  6. public sealed class MainForm : Form
  7. {
  8. private readonly Database _db;
  9. private readonly UserSession _session;
  10. private readonly TabControl _tabs = new() { Dock = DockStyle.Fill };
  11. private readonly Dictionary<string, DataGridView> _grids = new();
  12. private readonly Dictionary<string, TextBox> _searchBoxes = new();
  13. private bool CanWrite => _session.Role is "admin" or "manager";
  14. private bool CanEditBrands => _session.Role == "admin";
  15. private bool IsAdmin => _session.Role == "admin";
  16. public MainForm(Database db, UserSession session)
  17. {
  18. _db = db;
  19. _session = session;
  20. Text = $"Автопрокат - {_session.FullName} ({RoleName(_session.Role)})";
  21. StartPosition = FormStartPosition.CenterScreen;
  22. WindowState = FormWindowState.Maximized;
  23. MinimumSize = new Size(1100, 700);
  24. Font = new Font("Segoe UI", 10F);
  25. Controls.Add(_tabs);
  26. CreateDataTab("clients", "Клиенты");
  27. CreateDataTab("cars", "Автомобили");
  28. CreateDataTab("brands", "Марки");
  29. CreateDataTab("rentals", "Аренда");
  30. if (_session.Role != "viewer")
  31. {
  32. CreateReportsTab();
  33. CreateSecurityTab();
  34. CreateAuditTab();
  35. }
  36. LoadAll();
  37. }
  38. private void CreateDataTab(string table, string caption)
  39. {
  40. var page = new TabPage(caption);
  41. var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
  42. root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
  43. root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
  44. var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(8), WrapContents = false };
  45. var search = new TextBox { Width = 320, PlaceholderText = "Поиск..." };
  46. search.TextChanged += (_, _) => LoadTable(table);
  47. _searchBoxes[table] = search;
  48. var add = new Button { Text = "Добавить", Width = 105, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
  49. add.Click += (_, _) => EditRecord(table, null);
  50. var edit = new Button { Text = "Редактировать", Width = 125, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
  51. edit.Click += (_, _) => EditSelected(table);
  52. var delete = new Button { Text = "Удалить", Width = 95, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
  53. delete.Click += (_, _) => DeleteSelected(table);
  54. var refresh = new Button { Text = "Обновить", Width = 100, Height = 30 };
  55. refresh.Click += (_, _) => LoadTable(table);
  56. var export = new Button { Text = "CSV", Width = 70, Height = 30 };
  57. export.Click += (_, _) => ExportCsv(table);
  58. bar.Controls.AddRange([search, add, edit, delete, refresh, export]);
  59. var grid = new DataGridView
  60. {
  61. Dock = DockStyle.Fill,
  62. ReadOnly = true,
  63. AllowUserToAddRows = false,
  64. AllowUserToDeleteRows = false,
  65. SelectionMode = DataGridViewSelectionMode.FullRowSelect,
  66. MultiSelect = false,
  67. AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
  68. BackgroundColor = Color.White
  69. };
  70. grid.CellDoubleClick += (_, _) =>
  71. {
  72. if (CanWrite && (table != "brands" || CanEditBrands))
  73. EditSelected(table);
  74. };
  75. _grids[table] = grid;
  76. root.Controls.Add(bar, 0, 0);
  77. root.Controls.Add(grid, 0, 1);
  78. page.Controls.Add(root);
  79. _tabs.TabPages.Add(page);
  80. }
  81. private void CreateReportsTab()
  82. {
  83. var page = new TabPage("Отчеты");
  84. var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
  85. root.RowStyles.Add(new RowStyle(SizeType.Absolute, 54));
  86. root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
  87. var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(8) };
  88. var refresh = new Button { Text = "Сформировать", Width = 130, Height = 32 };
  89. var html = new Button { Text = "Отчет HTML", Width = 120, Height = 32 };
  90. var txt = new Button { Text = "Отчет TXT", Width = 110, Height = 32 };
  91. refresh.Click += (_, _) => LoadReports();
  92. html.Click += (_, _) => SaveReport("html");
  93. txt.Click += (_, _) => SaveReport("txt");
  94. bar.Controls.AddRange([refresh, html, txt]);
  95. var report = new TextBox
  96. {
  97. Name = "reportBox",
  98. Dock = DockStyle.Fill,
  99. Multiline = true,
  100. ScrollBars = ScrollBars.Both,
  101. ReadOnly = true,
  102. Font = new Font("Consolas", 10F),
  103. WordWrap = false
  104. };
  105. root.Controls.Add(bar, 0, 0);
  106. root.Controls.Add(report, 0, 1);
  107. page.Controls.Add(root);
  108. _tabs.TabPages.Add(page);
  109. }
  110. private void CreateSecurityTab()
  111. {
  112. var page = new TabPage("Защита");
  113. page.BackColor = Color.FromArgb(245, 247, 250);
  114. var scroll = new Panel
  115. {
  116. Dock = DockStyle.Fill,
  117. AutoScroll = true,
  118. Padding = new Padding(18),
  119. BackColor = Color.FromArgb(245, 247, 250)
  120. };
  121. var content = new TableLayoutPanel
  122. {
  123. Dock = DockStyle.Top,
  124. AutoSize = true,
  125. ColumnCount = 1,
  126. BackColor = Color.Transparent
  127. };
  128. content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
  129. content.Controls.Add(CreateSectionTitle("Пользователи БД и обязанности"), 0, content.RowCount++);
  130. content.Controls.Add(CreateRolesPanel(), 0, content.RowCount++);
  131. content.Controls.Add(CreateSectionTitle("Реализованные уровни защиты"), 0, content.RowCount++);
  132. content.Controls.Add(CreateProtectionMatrix(), 0, content.RowCount++);
  133. content.Controls.Add(CreateSectionTitle("Защита на уровне базы данных"), 0, content.RowCount++);
  134. content.Controls.Add(CreateBulletsCard("Ограничения целостности и контроль данных", [
  135. "PRIMARY KEY: brand_id, car_id, client_id, rental_id исключают дублирование основных сущностей.",
  136. "FOREIGN KEY: car.brand_id, rental.car_id, rental.client_id защищают связи между марками, автомобилями, клиентами и договорами.",
  137. "ON UPDATE CASCADE сохраняет связи при изменении ключей, ON DELETE RESTRICT запрещает удаление используемых записей.",
  138. "UNIQUE: название марки, госномер, телефон клиента и номер водительского удостоверения не могут повторяться.",
  139. "CHECK: год выпуска 1990-2030, стоимость аренды больше 0, сумма договора не отрицательная, дата окончания не раньше даты начала.",
  140. "CHECK по статусам ограничивает значения: автомобиль может быть только «Свободен», «В аренде» или «На ТО», договор - «Активна» или «Завершена».",
  141. "Индексы idx_car_brand_id, idx_car_status, idx_rental_car_id, idx_rental_cli_id, idx_rental_dates, idx_rental_status ускоряют поиск и отчеты."
  142. ]), 0, content.RowCount++);
  143. content.Controls.Add(CreateBulletsCard("Представления для безопасной отчетности", [
  144. "v_rentals_full показывает договоры с ФИО клиента, телефоном, номером автомобиля, маркой, датами, суммой и статусом.",
  145. "v_available_cars ограничивает выборку только свободными автомобилями.",
  146. "v_client_stats агрегирует количество аренд и сумму затрат по клиентам.",
  147. "Отчеты приложения строятся по представлениям и агрегатным запросам, без ручной сборки данных пользователем."
  148. ]), 0, content.RowCount++);
  149. content.Controls.Add(CreateSectionTitle("Разграничение доступа"), 0, content.RowCount++);
  150. content.Controls.Add(CreateBulletsCard("Роли PostgreSQL из проекта", [
  151. "rent_admin - полный доступ ко всем таблицам и последовательностям.",
  152. "rent_manager - SELECT, INSERT, UPDATE для client, car, rental; SELECT по brand и отчетным VIEW.",
  153. "rent_viewer - только SELECT, подходит для аналитика и преподавательской проверки.",
  154. "Row Level Security включен для rental: политики разрешают полный доступ администратору и менеджеру, а просмотр можно ограничивать отдельной SELECT-политикой.",
  155. "В приложении дополнительно используются роли admin, manager, viewer в таблице app_user, чтобы управлять доступностью кнопок и операций интерфейса."
  156. ]), 0, content.RowCount++);
  157. content.Controls.Add(CreateSectionTitle("Защита на уровне приложения"), 0, content.RowCount++);
  158. content.Controls.Add(CreateBulletsCard("Меры, реализованные в Windows Forms", [
  159. "Авторизация по логину и паролю перед открытием главного окна.",
  160. "Пароли хранятся как PBKDF2-SHA256 хеш с индивидуальной солью, а не в открытом виде.",
  161. "Права интерфейса зависят от роли: viewer не может добавлять, редактировать и удалять записи.",
  162. "Все запросы выполняются через параметры Npgsql, что защищает от SQL-инъекций.",
  163. "Строка подключения вынесена в dbsettings.json или переменную AUTORENT_CONNECTION_STRING.",
  164. "dbsettings.json добавлен в .gitignore, чтобы пароль от БД не попадал в репозиторий.",
  165. "При запуске приложение синхронизирует SERIAL-последовательности с MAX(id), чтобы исключить duplicate key после ручного импорта данных."
  166. ]), 0, content.RowCount++);
  167. content.Controls.Add(CreateSectionTitle("Аудит операций"), 0, content.RowCount++);
  168. content.Controls.Add(CreateBulletsCard("Логирование изменений", [
  169. "Таблица audit_log хранит время операции, пользователя, действие, таблицу, ID записи и детали изменения.",
  170. "Триггеры AFTER INSERT OR UPDATE OR DELETE установлены на brand, car, client и rental.",
  171. "Текущий пользователь приложения сохраняется в app_context и попадает в журнал аудита.",
  172. "Отдельная вкладка «Журнал операций» показывает последние операции из audit_log.",
  173. "Такой аудит помогает доказать, кто и когда изменял автомобили, клиентов и договоры аренды."
  174. ]), 0, content.RowCount++);
  175. content.Controls.Add(CreateSqlCard(), 0, content.RowCount++);
  176. scroll.Controls.Add(content);
  177. page.Controls.Add(scroll);
  178. _tabs.TabPages.Add(page);
  179. }
  180. private static Label CreateSectionTitle(string text) => new()
  181. {
  182. Text = text,
  183. Dock = DockStyle.Top,
  184. Height = 34,
  185. ForeColor = Color.FromArgb(30, 41, 59),
  186. Font = new Font("Segoe UI", 13F, FontStyle.Bold),
  187. Margin = new Padding(0, 8, 0, 4)
  188. };
  189. private static Control CreateRolesPanel()
  190. {
  191. var layout = new TableLayoutPanel
  192. {
  193. Dock = DockStyle.Top,
  194. AutoSize = true,
  195. ColumnCount = 3,
  196. Margin = new Padding(0, 0, 0, 12)
  197. };
  198. layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
  199. layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
  200. layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
  201. layout.Controls.Add(CreateRoleCard("admin", "Администратор", "Полный доступ", "Управляет структурой БД, справочниками, пользователями, аудитом, резервным копированием и контролем целостности.", Color.FromArgb(185, 28, 28)), 0, 0);
  202. layout.Controls.Add(CreateRoleCard("manager", "Менеджер проката", "Рабочий CRUD", "Вводит клиентов, автомобили и договоры аренды, редактирует рабочие данные, формирует отчеты.", Color.FromArgb(21, 91, 158)), 1, 0);
  203. layout.Controls.Add(CreateRoleCard("viewer", "Аналитик", "Только чтение", "Просматривает данные, строит отчеты и проверяет показатели без права изменения записей.", Color.FromArgb(22, 101, 52)), 2, 0);
  204. return layout;
  205. }
  206. private static Panel CreateRoleCard(string login, string title, string access, string description, Color accent)
  207. {
  208. var panel = CreateBaseCard(174);
  209. panel.Margin = new Padding(0, 0, 10, 0);
  210. panel.Padding = new Padding(14);
  211. var loginLabel = new Label
  212. {
  213. Text = login,
  214. ForeColor = accent,
  215. Font = new Font("Segoe UI", 11F, FontStyle.Bold),
  216. Dock = DockStyle.Top,
  217. Height = 26
  218. };
  219. var titleLabel = new Label
  220. {
  221. Text = title,
  222. ForeColor = Color.FromArgb(15, 23, 42),
  223. Font = new Font("Segoe UI", 12F, FontStyle.Bold),
  224. Dock = DockStyle.Top,
  225. Height = 30
  226. };
  227. var accessLabel = new Label
  228. {
  229. Text = access,
  230. ForeColor = Color.White,
  231. BackColor = accent,
  232. Font = new Font("Segoe UI", 9F, FontStyle.Bold),
  233. AutoSize = false,
  234. Height = 26,
  235. Dock = DockStyle.Top,
  236. TextAlign = ContentAlignment.MiddleCenter,
  237. Margin = new Padding(0, 0, 0, 8)
  238. };
  239. var body = new Label
  240. {
  241. Text = description,
  242. ForeColor = Color.FromArgb(71, 85, 105),
  243. Font = new Font("Segoe UI", 9.5F),
  244. Dock = DockStyle.Fill
  245. };
  246. panel.Controls.Add(body);
  247. panel.Controls.Add(accessLabel);
  248. panel.Controls.Add(titleLabel);
  249. panel.Controls.Add(loginLabel);
  250. return panel;
  251. }
  252. private static Control CreateProtectionMatrix()
  253. {
  254. var layout = new TableLayoutPanel
  255. {
  256. Dock = DockStyle.Top,
  257. AutoSize = true,
  258. ColumnCount = 2,
  259. Margin = new Padding(0, 0, 0, 12)
  260. };
  261. layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
  262. layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
  263. layout.Controls.Add(CreateBulletsCard("1. Идентификация и аутентификация", [
  264. "Пользователь входит по логину и паролю.",
  265. "Учетные записи хранятся в app_user.",
  266. "Пароль проверяется по хешу PBKDF2-SHA256 с солью."
  267. ]), 0, 0);
  268. layout.Controls.Add(CreateBulletsCard("2. Авторизация и роли", [
  269. "admin, manager, viewer управляют доступом в интерфейсе.",
  270. "rent_admin, rent_manager, rent_viewer описывают права на уровне PostgreSQL.",
  271. "Viewer не получает кнопки изменения данных."
  272. ]), 1, 0);
  273. layout.Controls.Add(CreateBulletsCard("3. Целостность данных", [
  274. "PK, FK, UNIQUE, CHECK защищают структуру БД.",
  275. "ON DELETE RESTRICT запрещает удалить запись, если она участвует в договоре.",
  276. "Последовательности синхронизируются после импорта."
  277. ]), 0, 1);
  278. layout.Controls.Add(CreateBulletsCard("4. Контроль и аудит", [
  279. "Триггеры фиксируют INSERT, UPDATE и DELETE.",
  280. "audit_log хранит пользователя, таблицу, действие и детали.",
  281. "Журнал доступен в отдельной вкладке приложения."
  282. ]), 1, 1);
  283. return layout;
  284. }
  285. private static Panel CreateBulletsCard(string title, string[] bullets)
  286. {
  287. var panel = CreateBaseCard(Math.Max(112, 48 + bullets.Length * 26));
  288. panel.Padding = new Padding(14);
  289. panel.Margin = new Padding(0, 0, 10, 12);
  290. var titleLabel = new Label
  291. {
  292. Text = title,
  293. Dock = DockStyle.Top,
  294. Height = 30,
  295. ForeColor = Color.FromArgb(15, 23, 42),
  296. Font = new Font("Segoe UI", 11F, FontStyle.Bold)
  297. };
  298. var body = new Label
  299. {
  300. Text = string.Join(Environment.NewLine, bullets.Select(x => " - " + x)),
  301. Dock = DockStyle.Fill,
  302. ForeColor = Color.FromArgb(51, 65, 85),
  303. Font = new Font("Segoe UI", 9.6F),
  304. AutoEllipsis = false
  305. };
  306. panel.Controls.Add(body);
  307. panel.Controls.Add(titleLabel);
  308. return panel;
  309. }
  310. private static Panel CreateSqlCard()
  311. {
  312. var panel = CreateBaseCard(270);
  313. panel.Padding = new Padding(14);
  314. panel.Margin = new Padding(0, 0, 0, 12);
  315. var title = new Label
  316. {
  317. Text = "Ключевые SQL-механизмы защиты",
  318. Dock = DockStyle.Top,
  319. Height = 30,
  320. ForeColor = Color.FromArgb(15, 23, 42),
  321. Font = new Font("Segoe UI", 11F, FontStyle.Bold)
  322. };
  323. var sql = new RichTextBox
  324. {
  325. Dock = DockStyle.Fill,
  326. ReadOnly = true,
  327. BorderStyle = BorderStyle.None,
  328. BackColor = Color.FromArgb(248, 250, 252),
  329. ForeColor = Color.FromArgb(30, 41, 59),
  330. Font = new Font("Consolas", 9F),
  331. Text = """
  332. -- Ограничения целостности
  333. CONSTRAINT uq_client_phone UNIQUE,
  334. CONSTRAINT chk_rental_dates CHECK (end_date >= start_date),
  335. CONSTRAINT fk_rental_client REFERENCES client(client_id) ON DELETE RESTRICT
  336. -- Роли и права
  337. CREATE ROLE rent_admin NOLOGIN;
  338. CREATE ROLE rent_manager NOLOGIN;
  339. CREATE ROLE rent_viewer NOLOGIN;
  340. GRANT SELECT, INSERT, UPDATE ON client, car, rental TO rent_manager;
  341. GRANT SELECT ON ALL TABLES IN SCHEMA public TO rent_viewer;
  342. -- Row Level Security
  343. ALTER TABLE rental ENABLE ROW LEVEL SECURITY;
  344. CREATE POLICY manager_access ON rental
  345. FOR ALL TO rent_manager USING (true) WITH CHECK (true);
  346. -- Аудит
  347. CREATE TRIGGER trg_audit_client
  348. AFTER INSERT OR UPDATE OR DELETE ON client
  349. FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('client_id');
  350. """
  351. };
  352. panel.Controls.Add(sql);
  353. panel.Controls.Add(title);
  354. return panel;
  355. }
  356. private static Panel CreateBaseCard(int height) => new()
  357. {
  358. Height = height,
  359. Dock = DockStyle.Top,
  360. BackColor = Color.White,
  361. BorderStyle = BorderStyle.FixedSingle
  362. };
  363. private void CreateAuditTab()
  364. {
  365. var page = new TabPage("Журнал операций");
  366. var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2 };
  367. root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
  368. root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
  369. var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(8) };
  370. var refresh = new Button { Text = "Обновить", Width = 105, Height = 30 };
  371. refresh.Click += (_, _) => LoadAudit();
  372. bar.Controls.Add(refresh);
  373. var grid = new DataGridView
  374. {
  375. Dock = DockStyle.Fill,
  376. ReadOnly = true,
  377. AllowUserToAddRows = false,
  378. AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
  379. BackgroundColor = Color.White
  380. };
  381. _grids["audit"] = grid;
  382. root.Controls.Add(bar, 0, 0);
  383. root.Controls.Add(grid, 0, 1);
  384. page.Controls.Add(root);
  385. _tabs.TabPages.Add(page);
  386. }
  387. private void LoadAll()
  388. {
  389. foreach (var table in new[] { "clients", "cars", "brands", "rentals" })
  390. LoadTable(table);
  391. if (_session.Role != "viewer")
  392. {
  393. LoadReports();
  394. LoadAudit();
  395. }
  396. }
  397. private void LoadTable(string table)
  398. {
  399. var q = _searchBoxes.TryGetValue(table, out var box) ? box.Text.Trim() : "";
  400. var like = $"%{q}%";
  401. var data = table switch
  402. {
  403. "clients" => _db.Query("""
  404. SELECT client_id AS ID, full_name AS ФИО, phone AS Телефон, address AS Адрес,
  405. license_no AS ВУ, date(created_at) AS Регистрация
  406. FROM client
  407. WHERE @q = '' OR full_name LIKE @like OR phone LIKE @like OR address LIKE @like OR license_no LIKE @like
  408. ORDER BY client_id DESC
  409. """, new() { ["@q"] = q, ["@like"] = like }),
  410. "cars" => _db.Query("""
  411. SELECT ca.car_id AS ID, ca.license_plate AS Госномер, b.name AS Марка, ca.year AS Год,
  412. ca.daily_rate AS Цена_день, ca.status AS Статус
  413. FROM car ca JOIN brand b ON b.brand_id = ca.brand_id
  414. WHERE @q = '' OR ca.license_plate LIKE @like OR b.name LIKE @like OR ca.status LIKE @like
  415. ORDER BY ca.car_id DESC
  416. """, new() { ["@q"] = q, ["@like"] = like }),
  417. "brands" => _db.Query("""
  418. SELECT b.brand_id AS ID, b.name AS Марка, b.country AS Страна, COUNT(c.car_id) AS Автомобилей
  419. FROM brand b LEFT JOIN car c ON c.brand_id = b.brand_id
  420. WHERE @q = '' OR b.name LIKE @like OR b.country LIKE @like
  421. GROUP BY b.brand_id, b.name, b.country
  422. ORDER BY b.brand_id DESC
  423. """, new() { ["@q"] = q, ["@like"] = like }),
  424. "rentals" => _db.Query("""
  425. SELECT r.rental_id AS ID, c.full_name AS Клиент, ca.license_plate AS Авто, b.name AS Марка,
  426. r.start_date AS Начало, r.end_date AS Конец, r.actual_return AS Возврат,
  427. r.total_cost AS Сумма, r.status AS Статус
  428. FROM rental r
  429. JOIN client c ON c.client_id = r.client_id
  430. JOIN car ca ON ca.car_id = r.car_id
  431. JOIN brand b ON b.brand_id = ca.brand_id
  432. WHERE @q = '' OR c.full_name LIKE @like OR ca.license_plate LIKE @like OR b.name LIKE @like OR r.status LIKE @like
  433. ORDER BY r.rental_id DESC
  434. """, new() { ["@q"] = q, ["@like"] = like }),
  435. _ => new DataTable()
  436. };
  437. _grids[table].DataSource = data;
  438. }
  439. private void EditSelected(string table)
  440. {
  441. var row = _grids[table].CurrentRow;
  442. if (row?.Cells["ID"].Value is null) return;
  443. EditRecord(table, Convert.ToInt32(row.Cells["ID"].Value));
  444. }
  445. private void EditRecord(string table, int? id)
  446. {
  447. try
  448. {
  449. using var form = new RecordForm(_db, table, id);
  450. if (form.ShowDialog(this) != DialogResult.OK) return;
  451. LoadTable(table);
  452. LoadReports();
  453. LoadAudit();
  454. }
  455. catch (Exception ex)
  456. {
  457. MessageBox.Show(ex.Message, "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
  458. }
  459. }
  460. private void DeleteSelected(string table)
  461. {
  462. var row = _grids[table].CurrentRow;
  463. if (row?.Cells["ID"].Value is null) return;
  464. var id = Convert.ToInt32(row.Cells["ID"].Value);
  465. if (MessageBox.Show("Удалить выбранную запись?", "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes)
  466. return;
  467. try
  468. {
  469. var pk = table switch { "clients" => "client_id", "cars" => "car_id", "brands" => "brand_id", "rentals" => "rental_id", _ => "id" };
  470. var dbTable = table.TrimEnd('s');
  471. if (table == "clients") dbTable = "client";
  472. if (table == "cars") dbTable = "car";
  473. if (table == "brands") dbTable = "brand";
  474. if (table == "rentals") dbTable = "rental";
  475. _db.ExecuteNonQuery($"DELETE FROM {dbTable} WHERE {pk} = @id", new() { ["@id"] = id });
  476. LoadTable(table);
  477. LoadReports();
  478. LoadAudit();
  479. }
  480. catch (Exception ex)
  481. {
  482. MessageBox.Show("Удаление невозможно: запись используется в связанных таблицах или нарушает ограничения.\n\n" + ex.Message,
  483. "Защита целостности", MessageBoxButtons.OK, MessageBoxIcon.Warning);
  484. }
  485. }
  486. private void LoadAudit()
  487. {
  488. _grids["audit"].DataSource = _db.Query("""
  489. SELECT audit_id AS ID, changed_at AS Время, user_login AS Пользователь,
  490. action AS Операция, entity AS Таблица, entity_id AS Запись, details AS Детали
  491. FROM audit_log
  492. ORDER BY audit_id DESC
  493. LIMIT 300
  494. """);
  495. }
  496. private void LoadReports()
  497. {
  498. var box = FindReportBox();
  499. box.Text = BuildReportText();
  500. }
  501. private string BuildReportText()
  502. {
  503. var stats = _db.Query("""
  504. SELECT
  505. (SELECT COUNT(*) FROM client) AS clients,
  506. (SELECT COUNT(*) FROM car) AS cars,
  507. (SELECT COUNT(*) FROM rental WHERE status = 'Активна') AS active_rentals,
  508. (SELECT COALESCE(SUM(total_cost),0) FROM rental WHERE status = 'Завершена') AS revenue
  509. """).Rows[0];
  510. var sb = new StringBuilder();
  511. sb.AppendLine("ОТЧЕТ ПО СИСТЕМЕ АВТОПРОКАТА");
  512. sb.AppendLine($"Дата формирования: {DateTime.Now:dd.MM.yyyy HH:mm}");
  513. sb.AppendLine($"Пользователь: {_session.FullName} ({RoleName(_session.Role)})");
  514. sb.AppendLine(new string('=', 72));
  515. sb.AppendLine($"Клиентов: {stats["clients"]}");
  516. sb.AppendLine($"Автомобилей: {stats["cars"]}");
  517. sb.AppendLine($"Активных аренд: {stats["active_rentals"]}");
  518. sb.AppendLine($"Выручка по завершенным арендам: {Convert.ToDecimal(stats["revenue"]).ToString("N2", CultureInfo.GetCultureInfo("ru-RU"))} руб.");
  519. sb.AppendLine();
  520. AppendTable(sb, "Активные договоры", _db.Query("SELECT * FROM v_rentals_full WHERE status='Активна' ORDER BY start_date DESC"));
  521. AppendTable(sb, "Статистика по клиентам", _db.Query("SELECT full_name, total_rentals, total_spent FROM v_client_stats ORDER BY total_spent DESC"));
  522. AppendTable(sb, "Свободные автомобили", _db.Query("SELECT * FROM v_available_cars ORDER BY daily_rate DESC"));
  523. return sb.ToString();
  524. }
  525. private static void AppendTable(StringBuilder sb, string title, DataTable table)
  526. {
  527. sb.AppendLine(title);
  528. sb.AppendLine(new string('-', title.Length));
  529. if (table.Rows.Count == 0)
  530. {
  531. sb.AppendLine("Нет данных.");
  532. sb.AppendLine();
  533. return;
  534. }
  535. sb.AppendLine(string.Join(" | ", table.Columns.Cast<DataColumn>().Select(c => c.ColumnName)));
  536. foreach (DataRow row in table.Rows)
  537. sb.AppendLine(string.Join(" | ", row.ItemArray.Select(v => Convert.ToString(v))));
  538. sb.AppendLine();
  539. }
  540. private void SaveReport(string format)
  541. {
  542. var reportsDir = Path.Combine(AppContext.BaseDirectory, "Reports");
  543. Directory.CreateDirectory(reportsDir);
  544. var path = Path.Combine(reportsDir, $"autorent_report_{DateTime.Now:yyyyMMdd_HHmmss}.{format}");
  545. var text = BuildReportText();
  546. if (format == "html")
  547. {
  548. var html = "<!doctype html><meta charset=\"utf-8\"><title>Отчет Автопрокат</title>" +
  549. "<style>body{font-family:Segoe UI,Arial;margin:32px}pre{white-space:pre-wrap;font:14px Consolas}</style>" +
  550. "<h1>Отчет по системе Автопрокат</h1><pre>" + System.Net.WebUtility.HtmlEncode(text) + "</pre>";
  551. File.WriteAllText(path, html, Encoding.UTF8);
  552. }
  553. else
  554. {
  555. File.WriteAllText(path, text, Encoding.UTF8);
  556. }
  557. MessageBox.Show($"Отчет сохранен:\n{path}", "Готово", MessageBoxButtons.OK, MessageBoxIcon.Information);
  558. try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } catch { }
  559. }
  560. private void ExportCsv(string table)
  561. {
  562. if (_grids[table].DataSource is not DataTable data) return;
  563. var reportsDir = Path.Combine(AppContext.BaseDirectory, "Reports");
  564. Directory.CreateDirectory(reportsDir);
  565. var path = Path.Combine(reportsDir, $"{table}_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
  566. var sb = new StringBuilder();
  567. sb.AppendLine(string.Join(";", data.Columns.Cast<DataColumn>().Select(c => EscapeCsv(c.ColumnName))));
  568. foreach (DataRow row in data.Rows)
  569. sb.AppendLine(string.Join(";", row.ItemArray.Select(v => EscapeCsv(Convert.ToString(v) ?? ""))));
  570. File.WriteAllText(path, sb.ToString(), new UTF8Encoding(true));
  571. MessageBox.Show($"CSV сохранен:\n{path}", "Экспорт", MessageBoxButtons.OK, MessageBoxIcon.Information);
  572. }
  573. private static string EscapeCsv(string value) => "\"" + value.Replace("\"", "\"\"") + "\"";
  574. private TextBox FindReportBox()
  575. {
  576. return _tabs.TabPages.Cast<TabPage>().SelectMany(p => p.Controls.Find("reportBox", true)).OfType<TextBox>().First();
  577. }
  578. private static string RoleName(string role) => role switch
  579. {
  580. "admin" => "Администратор",
  581. "manager" => "Менеджер",
  582. "viewer" => "Аналитик",
  583. _ => role
  584. };
  585. private string SecurityText() => """
  586. Пользователи и обязанности
  587. 1. admin / Администратор БД
  588. Полный доступ к справочникам, автомобилям, клиентам, договорам, отчетам и журналу аудита.
  589. Отвечает за структуру БД, учетные записи, резервное копирование и контроль целостности.
  590. 2. manager / Менеджер проката
  591. Вводит клиентов, автомобили и договоры аренды, редактирует рабочие данные, формирует отчеты.
  592. Не управляет ролями и справочником марок без администратора.
  593. 3. viewer / Аналитик
  594. Имеет доступ только к просмотру данных и формированию отчетов. Изменение и удаление записей запрещено.
  595. Защита на уровне приложения
  596. - вход по логину и паролю;
  597. - хранение паролей в виде PBKDF2-SHA256 хеша с индивидуальной солью;
  598. - разграничение прав по ролям admin, manager, viewer;
  599. - запрет кнопок добавления, редактирования и удаления для роли viewer;
  600. - параметризованные SQL-запросы вместо конкатенации пользовательского ввода;
  601. - обработка ошибок ограничений БД без раскрытия лишней служебной информации пользователю.
  602. Защита на уровне базы данных
  603. - первичные и внешние ключи;
  604. - UNIQUE для госномера, телефона, водительского удостоверения и названия марки;
  605. - CHECK для статусов, положительной цены, корректного года выпуска и дат аренды;
  606. - ON DELETE RESTRICT для защиты связанных договоров от случайного удаления;
  607. - представления v_rentals_full, v_available_cars, v_client_stats для отчетности;
  608. - триггеры INSERT/UPDATE/DELETE на основных таблицах пишут операции в audit_log;
  609. - PRAGMA foreign_keys = ON и WAL-журналирование при работе приложения.
  610. """;
  611. }
  612. internal sealed class RecordForm : Form
  613. {
  614. private readonly Database _db;
  615. private readonly string _table;
  616. private readonly int? _id;
  617. private readonly Dictionary<string, Control> _fields = new();
  618. public RecordForm(Database db, string table, int? id)
  619. {
  620. _db = db;
  621. _table = table;
  622. _id = id;
  623. Text = id is null ? "Добавить запись" : "Редактировать запись";
  624. StartPosition = FormStartPosition.CenterParent;
  625. FormBorderStyle = FormBorderStyle.FixedDialog;
  626. MaximizeBox = false;
  627. MinimizeBox = false;
  628. ClientSize = new Size(520, table == "rentals" ? 430 : 350);
  629. Font = new Font("Segoe UI", 10F);
  630. BuildForm();
  631. }
  632. private void BuildForm()
  633. {
  634. var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), ColumnCount = 2, AutoScroll = true };
  635. panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 170));
  636. panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
  637. var data = _id is null ? null : LoadRecord();
  638. foreach (var field in Fields())
  639. AddField(panel, field, data);
  640. var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
  641. var save = new Button { Text = "Сохранить", Width = 110, Height = 32 };
  642. var cancel = new Button { Text = "Отмена", Width = 90, Height = 32 };
  643. save.Click += (_, _) => Save();
  644. cancel.Click += (_, _) => Close();
  645. buttons.Controls.AddRange([save, cancel]);
  646. panel.Controls.Add(new Label(), 0, panel.RowCount);
  647. panel.Controls.Add(buttons, 1, panel.RowCount++);
  648. Controls.Add(panel);
  649. AcceptButton = save;
  650. }
  651. private DataRow LoadRecord()
  652. {
  653. var (dbTable, pk) = DbTable();
  654. return _db.Query($"SELECT * FROM {dbTable} WHERE {pk}=@id", new() { ["@id"] = _id }).Rows[0];
  655. }
  656. private IEnumerable<(string Name, string Label, string Type)> Fields() => _table switch
  657. {
  658. "clients" => [("full_name", "ФИО", "text"), ("phone", "Телефон", "text"), ("address", "Адрес", "text"), ("license_no", "Водительское удостоверение", "text")],
  659. "cars" => [("license_plate", "Госномер", "text"), ("brand_id", "Марка", "brand"), ("year", "Год выпуска", "number"), ("daily_rate", "Цена за день", "money"), ("status", "Статус", "car_status")],
  660. "brands" => [("name", "Марка", "text"), ("country", "Страна", "text")],
  661. "rentals" => [("client_id", "Клиент", "client"), ("car_id", "Автомобиль", "car"), ("start_date", "Дата начала", "date"), ("end_date", "Дата окончания", "date"), ("actual_return", "Фактический возврат", "date"), ("total_cost", "Сумма", "money"), ("status", "Статус", "rental_status")],
  662. _ => []
  663. };
  664. private void AddField(TableLayoutPanel panel, (string Name, string Label, string Type) field, DataRow? data)
  665. {
  666. panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
  667. panel.Controls.Add(new Label { Text = field.Label, AutoSize = true, Anchor = AnchorStyles.Left }, 0, panel.RowCount);
  668. Control input = field.Type switch
  669. {
  670. "brand" => Combo("SELECT brand_id, name FROM brand ORDER BY name", "brand_id", data),
  671. "client" => Combo("SELECT client_id, full_name FROM client ORDER BY full_name", "client_id", data),
  672. "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),
  673. "car_status" => ComboValues(["Свободен", "В аренде", "На ТО"], field.Name, data),
  674. "rental_status" => ComboValues(["Активна", "Завершена"], field.Name, data),
  675. "date" => new DateTimePicker { Width = 280, Format = DateTimePickerFormat.Short, ShowCheckBox = field.Name == "actual_return" },
  676. _ => new TextBox { Width = 280 }
  677. };
  678. if (input is TextBox tb)
  679. tb.Text = data is null ? "" : Convert.ToString(data[field.Name]);
  680. if (input is DateTimePicker dtp)
  681. {
  682. var value = data is null ? "" : Convert.ToString(data[field.Name]);
  683. if (DateTime.TryParse(value, out var date))
  684. {
  685. dtp.Value = date;
  686. dtp.Checked = true;
  687. }
  688. else
  689. {
  690. dtp.Value = DateTime.Today;
  691. dtp.Checked = field.Name != "actual_return";
  692. }
  693. }
  694. input.Anchor = AnchorStyles.Left;
  695. _fields[field.Name] = input;
  696. panel.Controls.Add(input, 1, panel.RowCount++);
  697. }
  698. private ComboBox Combo(string sql, string fieldName, DataRow? data)
  699. {
  700. var combo = new ComboBox { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList };
  701. var table = _db.Query(sql);
  702. combo.DataSource = table;
  703. combo.ValueMember = table.Columns[0].ColumnName;
  704. combo.DisplayMember = table.Columns[1].ColumnName;
  705. if (data is not null && data[fieldName] != DBNull.Value)
  706. combo.SelectedValue = Convert.ToInt32(data[fieldName]);
  707. return combo;
  708. }
  709. private static ComboBox ComboValues(string[] values, string fieldName, DataRow? data)
  710. {
  711. var combo = new ComboBox { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList };
  712. combo.Items.AddRange(values);
  713. combo.SelectedItem = data is null ? values[0] : Convert.ToString(data[fieldName]);
  714. return combo;
  715. }
  716. private void Save()
  717. {
  718. try
  719. {
  720. ValidateRequired();
  721. var (dbTable, pk) = DbTable();
  722. var fields = Fields().Select(f => f.Name).ToArray();
  723. var values = fields.ToDictionary(f => "@" + f, ReadValue);
  724. if (_id is null)
  725. {
  726. var sql = $"INSERT INTO {dbTable}({string.Join(",", fields)}) VALUES({string.Join(",", values.Keys)})";
  727. _db.ExecuteNonQuery(sql, values);
  728. }
  729. else
  730. {
  731. values["@id"] = _id;
  732. var set = string.Join(",", fields.Select(f => $"{f}=@{f}"));
  733. _db.ExecuteNonQuery($"UPDATE {dbTable} SET {set} WHERE {pk}=@id", values);
  734. }
  735. DialogResult = DialogResult.OK;
  736. Close();
  737. }
  738. catch (Exception ex)
  739. {
  740. MessageBox.Show(ex.Message, "Ошибка сохранения", MessageBoxButtons.OK, MessageBoxIcon.Error);
  741. }
  742. }
  743. private void ValidateRequired()
  744. {
  745. foreach (var field in Fields())
  746. {
  747. if (_fields[field.Name] is TextBox tb && string.IsNullOrWhiteSpace(tb.Text) && field.Name is "full_name" or "license_plate" or "name")
  748. throw new InvalidOperationException($"Заполните поле «{field.Label}».");
  749. }
  750. }
  751. private object? ReadValue(string parameterName)
  752. {
  753. var name = parameterName.TrimStart('@');
  754. var control = _fields[name];
  755. return control switch
  756. {
  757. TextBox tb when name is "year" => int.TryParse(tb.Text, out var i) ? i : null,
  758. TextBox tb when name is "daily_rate" or "total_cost" => decimal.TryParse(tb.Text.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null,
  759. TextBox tb => string.IsNullOrWhiteSpace(tb.Text) ? null : tb.Text.Trim(),
  760. ComboBox cb => cb.SelectedValue is DataRowView ? cb.Text : cb.SelectedValue ?? cb.SelectedItem,
  761. DateTimePicker dtp => dtp.Checked ? dtp.Value.ToString("yyyy-MM-dd") : null,
  762. _ => null
  763. };
  764. }
  765. private (string Table, string Pk) DbTable() => _table switch
  766. {
  767. "clients" => ("client", "client_id"),
  768. "cars" => ("car", "car_id"),
  769. "brands" => ("brand", "brand_id"),
  770. "rentals" => ("rental", "rental_id"),
  771. _ => throw new InvalidOperationException("Неизвестная таблица.")
  772. };
  773. }