From 9ee5561f3d26247f2c57101cf6640fce20397516 Mon Sep 17 00:00:00 2001 From: Magomed Date: Sat, 18 Jul 2026 13:34:48 +0300 Subject: [PATCH] Add invoice export folder setting and CSV export from context menu. Users can choose the export directory in auth settings; right-click on an invoice saves a UTF-8 CSV into that folder. Co-authored-by: Cursor --- src/ElectronicPharmacy/App.config | 3 + src/ElectronicPharmacy/Classes/AppConfig.cs | 11 + .../Classes/InvoiceExportService.cs | 194 ++++++++++++++++++ .../ElectronicPharmacy.csproj | 1 + .../HelpForms/HF_Registration.Designer.cs | 81 ++++++-- .../HelpForms/HF_Registration.cs | 24 +++ .../Properties/Settings.Designer.cs | 12 ++ .../Properties/Settings.settings | 3 + .../UserControls/UCInvoices.cs | 28 ++- 9 files changed, 329 insertions(+), 28 deletions(-) create mode 100644 src/ElectronicPharmacy/Classes/InvoiceExportService.cs diff --git a/src/ElectronicPharmacy/App.config b/src/ElectronicPharmacy/App.config index 6eae6c4..761ce75 100644 --- a/src/ElectronicPharmacy/App.config +++ b/src/ElectronicPharmacy/App.config @@ -36,6 +36,9 @@ + + + \ No newline at end of file diff --git a/src/ElectronicPharmacy/Classes/AppConfig.cs b/src/ElectronicPharmacy/Classes/AppConfig.cs index f606bcd..459c3dc 100644 --- a/src/ElectronicPharmacy/Classes/AppConfig.cs +++ b/src/ElectronicPharmacy/Classes/AppConfig.cs @@ -108,5 +108,16 @@ namespace Электронная_Фармация.Classes Settings.Default.LocationId = (locationId ?? string.Empty).Trim(); Settings.Default.Save(); } + + public static string InvoiceExportPath + { + get { return Settings.Default.InvoiceExportPath ?? string.Empty; } + } + + public static void SetInvoiceExportPath(string path) + { + Settings.Default.InvoiceExportPath = (path ?? string.Empty).Trim(); + Settings.Default.Save(); + } } } diff --git a/src/ElectronicPharmacy/Classes/InvoiceExportService.cs b/src/ElectronicPharmacy/Classes/InvoiceExportService.cs new file mode 100644 index 0000000..39025fe --- /dev/null +++ b/src/ElectronicPharmacy/Classes/InvoiceExportService.cs @@ -0,0 +1,194 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; +using System.Windows.Forms; +using System.Data.SQLite; + +namespace Электронная_Фармация.Classes +{ + /// + /// Exports a local invoice (header + items) to a CSV file in the configured folder. + /// + public sealed class InvoiceExportService + { + public string ExportInvoice(string invoiceId, IWin32Window owner = null) + { + if (string.IsNullOrWhiteSpace(invoiceId)) + { + throw new ArgumentException("Не указана накладная для экспорта.", nameof(invoiceId)); + } + + var folder = ResolveExportFolder(owner); + if (string.IsNullOrWhiteSpace(folder)) + { + throw new InvalidOperationException("Не указана папка для экспорта накладных."); + } + + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + InvoiceHeader header; + using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString)) + { + con.Open(); + header = LoadHeader(con, invoiceId); + var itemsSql = @" +select + ifnull([GoodCode], '') as [GoodCode], + ifnull([Good], '') as [Good], + ifnull([ProducerName], '') as [ProducerName], + ifnull([GoodsGount], 0) as [Qty], + ifnull([PriceSupplierWithNDS], ifnull([PriceSupplierWithoutNDS], 0)) as [Price], + ifnull([SumWithNDS], ifnull([SumWithoutNDS], 0)) as [Sum], + ifnull([BestBefore], '') as [BestBefore], + ifnull([Serial], '') as [Serial] +from [InvoiceItem] +where [idInvoice] = @invoiceId +order by [Good];"; + + var fileName = BuildFileName(header); + var filePath = Path.Combine(folder, fileName); + + var sb = new StringBuilder(); + sb.AppendLine("Номер накладной;Дата;Поставщик;Грузополучатель;Номер заказа;Сумма"); + sb.AppendLine(string.Join(";", + Csv(header.Number), + Csv(header.Date), + Csv(header.Supplier), + Csv(header.Consignee), + Csv(header.OrderNumber), + Csv(header.Sum))); + sb.AppendLine(); + sb.AppendLine("Код товара;Товар;Производитель;Количество;Цена;Сумма;Срок годности;Серия"); + + using (var cmd = new SQLiteCommand(itemsSql, con)) + { + cmd.Parameters.AddWithValue("@invoiceId", invoiceId); + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + sb.AppendLine(string.Join(";", + Csv(reader["GoodCode"]), + Csv(reader["Good"]), + Csv(reader["ProducerName"]), + Csv(reader["Qty"]), + Csv(reader["Price"]), + Csv(reader["Sum"]), + Csv(reader["BestBefore"]), + Csv(reader["Serial"]))); + } + } + } + + // UTF-8 BOM — Excel корректно открывает кириллицу. + File.WriteAllText(filePath, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + AppDebugLog.Info("InvoiceExport", $"Накладная {header.Number} экспортирована: {filePath}"); + return filePath; + } + } + + public static string ResolveExportFolder(IWin32Window owner = null) + { + var folder = AppConfig.InvoiceExportPath; + if (!string.IsNullOrWhiteSpace(folder) && Directory.Exists(folder)) + { + return folder; + } + + using (var dialog = new FolderBrowserDialog()) + { + dialog.Description = "Выберите папку для экспорта накладных"; + dialog.ShowNewFolderButton = true; + if (!string.IsNullOrWhiteSpace(folder) && Directory.Exists(folder)) + { + dialog.SelectedPath = folder; + } + + if (dialog.ShowDialog(owner) != DialogResult.OK || string.IsNullOrWhiteSpace(dialog.SelectedPath)) + { + return string.Empty; + } + + AppConfig.SetInvoiceExportPath(dialog.SelectedPath); + return dialog.SelectedPath; + } + } + + private static InvoiceHeader LoadHeader(SQLiteConnection con, string invoiceId) + { + using (var cmd = new SQLiteCommand(@" +select + ifnull([InvoiceNumber], '') as [Number], + ifnull([InvoiceDate], '') as [Date], + ifnull([SupplierName], '') as [Supplier], + ifnull([ConsigneesName], '') as [Consignee], + ifnull([SourceOrderNumber], '') as [OrderNumber], + ifnull([InvoiceSum], ifnull([SumWithNDS], 0)) as [Sum] +from [Invoice] +where [idInvoice] = @invoiceId +limit 1;", con)) + { + cmd.Parameters.AddWithValue("@invoiceId", invoiceId); + using (var reader = cmd.ExecuteReader()) + { + if (!reader.Read()) + { + throw new InvalidOperationException("Накладная не найдена."); + } + + return new InvoiceHeader + { + Number = reader["Number"]?.ToString() ?? string.Empty, + Date = reader["Date"]?.ToString() ?? string.Empty, + Supplier = reader["Supplier"]?.ToString() ?? string.Empty, + Consignee = reader["Consignee"]?.ToString() ?? string.Empty, + OrderNumber = reader["OrderNumber"]?.ToString() ?? string.Empty, + Sum = reader["Sum"]?.ToString() ?? "0" + }; + } + } + } + + private static string BuildFileName(InvoiceHeader header) + { + var number = SanitizeFileName(string.IsNullOrWhiteSpace(header.Number) ? "invoice" : header.Number); + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + return $"Накладная_{number}_{stamp}.csv"; + } + + private static string SanitizeFileName(string value) + { + foreach (var c in Path.GetInvalidFileNameChars()) + { + value = value.Replace(c, '_'); + } + + return value.Trim(); + } + + private static string Csv(object value) + { + var text = value?.ToString() ?? string.Empty; + if (text.IndexOfAny(new[] { ';', '"', '\r', '\n' }) >= 0) + { + return "\"" + text.Replace("\"", "\"\"") + "\""; + } + + return text; + } + + private sealed class InvoiceHeader + { + public string Number { get; set; } + public string Date { get; set; } + public string Supplier { get; set; } + public string Consignee { get; set; } + public string OrderNumber { get; set; } + public string Sum { get; set; } + } + } +} diff --git a/src/ElectronicPharmacy/ElectronicPharmacy.csproj b/src/ElectronicPharmacy/ElectronicPharmacy.csproj index aace520..fd4daf9 100644 --- a/src/ElectronicPharmacy/ElectronicPharmacy.csproj +++ b/src/ElectronicPharmacy/ElectronicPharmacy.csproj @@ -106,6 +106,7 @@ + diff --git a/src/ElectronicPharmacy/HelpForms/HF_Registration.Designer.cs b/src/ElectronicPharmacy/HelpForms/HF_Registration.Designer.cs index b9a93d7..3447438 100644 --- a/src/ElectronicPharmacy/HelpForms/HF_Registration.Designer.cs +++ b/src/ElectronicPharmacy/HelpForms/HF_Registration.Designer.cs @@ -40,6 +40,9 @@ this.label3 = new System.Windows.Forms.Label(); this.label5 = new System.Windows.Forms.Label(); this.txtApiUrl = new Elfisa.UI.Controls.ModernTextBox(); + this.labelExportPath = new System.Windows.Forms.Label(); + this.txtInvoiceExportPath = new Elfisa.UI.Controls.ModernTextBox(); + this.btnBrowseExportPath = new Elfisa.UI.Controls.ModernButton(); this.panel1.SuspendLayout(); this.SuspendLayout(); // @@ -79,9 +82,55 @@ this.txtPassword.Size = new System.Drawing.Size(375, 34); this.txtPassword.TabIndex = 3; // + // label5 + // + this.label5.AutoSize = true; + this.label5.Font = new System.Drawing.Font("Segoe UI", 12F); + this.label5.Location = new System.Drawing.Point(23, 225); + this.label5.Name = "label5"; + this.label5.Size = new System.Drawing.Size(103, 28); + this.label5.TabIndex = 11; + this.label5.Text = "URL API"; + // + // txtApiUrl + // + this.txtApiUrl.Font = new System.Drawing.Font("Segoe UI", 12F); + this.txtApiUrl.Location = new System.Drawing.Point(132, 225); + this.txtApiUrl.Name = "txtApiUrl"; + this.txtApiUrl.Size = new System.Drawing.Size(271, 34); + this.txtApiUrl.TabIndex = 12; + // + // labelExportPath + // + this.labelExportPath.AutoSize = true; + this.labelExportPath.Font = new System.Drawing.Font("Segoe UI", 12F); + this.labelExportPath.Location = new System.Drawing.Point(23, 272); + this.labelExportPath.Name = "labelExportPath"; + this.labelExportPath.Size = new System.Drawing.Size(280, 28); + this.labelExportPath.TabIndex = 13; + this.labelExportPath.Text = "Папка экспорта накладных"; + // + // txtInvoiceExportPath + // + this.txtInvoiceExportPath.Font = new System.Drawing.Font("Segoe UI", 10F); + this.txtInvoiceExportPath.Location = new System.Drawing.Point(28, 304); + this.txtInvoiceExportPath.Name = "txtInvoiceExportPath"; + this.txtInvoiceExportPath.Size = new System.Drawing.Size(320, 30); + this.txtInvoiceExportPath.TabIndex = 14; + // + // btnBrowseExportPath + // + this.btnBrowseExportPath.Font = new System.Drawing.Font("Segoe UI Semibold", 10F, System.Drawing.FontStyle.Bold); + this.btnBrowseExportPath.Location = new System.Drawing.Point(354, 304); + this.btnBrowseExportPath.Name = "btnBrowseExportPath"; + this.btnBrowseExportPath.Size = new System.Drawing.Size(49, 30); + this.btnBrowseExportPath.TabIndex = 15; + this.btnBrowseExportPath.Text = "..."; + this.btnBrowseExportPath.Click += new System.EventHandler(this.btnBrowseExportPath_Click); + // // btnConfirmRegistration // - this.btnConfirmRegistration.Location = new System.Drawing.Point(188, 300); + this.btnConfirmRegistration.Location = new System.Drawing.Point(188, 356); this.btnConfirmRegistration.Name = "btnConfirmRegistration"; this.btnConfirmRegistration.Size = new System.Drawing.Size(215, 43); this.btnConfirmRegistration.TabIndex = 5; @@ -90,7 +139,7 @@ // // btnCloseThis // - this.btnCloseThis.Location = new System.Drawing.Point(28, 300); + this.btnCloseThis.Location = new System.Drawing.Point(28, 356); this.btnCloseThis.Name = "btnCloseThis"; this.btnCloseThis.Size = new System.Drawing.Size(125, 43); this.btnCloseThis.TabIndex = 6; @@ -102,7 +151,7 @@ this.checkTokenGained.AutoCheck = false; this.checkTokenGained.AutoSize = true; this.checkTokenGained.Font = new System.Drawing.Font("Segoe UI", 12F); - this.checkTokenGained.Location = new System.Drawing.Point(28, 260); + this.checkTokenGained.Location = new System.Drawing.Point(28, 420); this.checkTokenGained.Name = "checkTokenGained"; this.checkTokenGained.Size = new System.Drawing.Size(171, 32); this.checkTokenGained.TabIndex = 7; @@ -133,29 +182,14 @@ this.label3.Text = "АВТОРИЗАЦИЯ"; this.label3.TextAlign = System.Drawing.ContentAlignment.MiddleRight; // - // label5 - // - this.label5.AutoSize = true; - this.label5.Font = new System.Drawing.Font("Segoe UI", 12F); - this.label5.Location = new System.Drawing.Point(23, 225); - this.label5.Name = "label5"; - this.label5.Size = new System.Drawing.Size(103, 28); - this.label5.TabIndex = 11; - this.label5.Text = "URL API"; - // - // txtApiUrl - // - this.txtApiUrl.Font = new System.Drawing.Font("Segoe UI", 12F); - this.txtApiUrl.Location = new System.Drawing.Point(132, 225); - this.txtApiUrl.Name = "txtApiUrl"; - this.txtApiUrl.Size = new System.Drawing.Size(271, 34); - this.txtApiUrl.TabIndex = 12; - // // HF_Registration // this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(436, 360); + this.ClientSize = new System.Drawing.Size(436, 420); + this.Controls.Add(this.btnBrowseExportPath); + this.Controls.Add(this.txtInvoiceExportPath); + this.Controls.Add(this.labelExportPath); this.Controls.Add(this.txtApiUrl); this.Controls.Add(this.label5); this.Controls.Add(this.panel1); @@ -192,5 +226,8 @@ private System.Windows.Forms.Label label3; private System.Windows.Forms.Label label5; private Elfisa.UI.Controls.ModernTextBox txtApiUrl; + private System.Windows.Forms.Label labelExportPath; + private Elfisa.UI.Controls.ModernTextBox txtInvoiceExportPath; + private Elfisa.UI.Controls.ModernButton btnBrowseExportPath; } } diff --git a/src/ElectronicPharmacy/HelpForms/HF_Registration.cs b/src/ElectronicPharmacy/HelpForms/HF_Registration.cs index 8069c1f..74cc942 100644 --- a/src/ElectronicPharmacy/HelpForms/HF_Registration.cs +++ b/src/ElectronicPharmacy/HelpForms/HF_Registration.cs @@ -25,6 +25,27 @@ namespace Электронная_Фармация.HelpForms Close(); } + private void btnBrowseExportPath_Click(object sender, EventArgs e) + { + using (var dialog = new FolderBrowserDialog()) + { + dialog.Description = "Выберите папку для экспорта накладных"; + dialog.ShowNewFolderButton = true; + if (!string.IsNullOrWhiteSpace(txtInvoiceExportPath.Text) + && System.IO.Directory.Exists(txtInvoiceExportPath.Text.Trim())) + { + dialog.SelectedPath = txtInvoiceExportPath.Text.Trim(); + } + + if (dialog.ShowDialog(this) == DialogResult.OK) + { + txtInvoiceExportPath.Text = dialog.SelectedPath; + AppConfig.SetInvoiceExportPath(dialog.SelectedPath); + ToastNotification.ShowSuccess("Папка экспорта накладных сохранена"); + } + } + } + private async void btnConfirmRegistration_Click(object sender, EventArgs e) { btnConfirmRegistration.Enabled = false; @@ -39,6 +60,7 @@ namespace Электронная_Фармация.HelpForms // на старый сохранённый/дефолтный адрес, а не на введённый пользователем. string apiUrl = txtApiUrl.Text.Trim(); Settings.Default.ApiBaseUrl = apiUrl; + AppConfig.SetInvoiceExportPath(txtInvoiceExportPath.Text); Settings.Default.Save(); LoginRequest loginRequest = new LoginRequest(); @@ -56,6 +78,7 @@ namespace Электронная_Фармация.HelpForms Settings.Default.stringPassword = strPassword; Settings.Default.stringToken = token; Settings.Default.ApiBaseUrl = client.BaseUrl; + AppConfig.SetInvoiceExportPath(txtInvoiceExportPath.Text); Settings.Default.Save(); AppDebugLog.Info("Auth", $"Авторизация успешна. token={AppDebugLog.MaskToken(token)}"); @@ -104,6 +127,7 @@ namespace Электронная_Фармация.HelpForms txtApiUrl.Text = string.IsNullOrWhiteSpace(Settings.Default.ApiBaseUrl) ? AppConfig.DefaultApiBaseUrl : Settings.Default.ApiBaseUrl; + txtInvoiceExportPath.Text = AppConfig.InvoiceExportPath; checkTokenGained.Checked = doWeHaveAToken.Length > 0; } diff --git a/src/ElectronicPharmacy/Properties/Settings.Designer.cs b/src/ElectronicPharmacy/Properties/Settings.Designer.cs index c582df8..61c34aa 100644 --- a/src/ElectronicPharmacy/Properties/Settings.Designer.cs +++ b/src/ElectronicPharmacy/Properties/Settings.Designer.cs @@ -119,5 +119,17 @@ namespace Электронная_Фармация.Properties { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("")] + public string InvoiceExportPath { + get { + return ((string)(this["InvoiceExportPath"])); + } + set { + this["InvoiceExportPath"] = value; + } + } + } } diff --git a/src/ElectronicPharmacy/Properties/Settings.settings b/src/ElectronicPharmacy/Properties/Settings.settings index b2c624d..b4863ca 100644 --- a/src/ElectronicPharmacy/Properties/Settings.settings +++ b/src/ElectronicPharmacy/Properties/Settings.settings @@ -26,5 +26,8 @@ False + + + \ No newline at end of file diff --git a/src/ElectronicPharmacy/UserControls/UCInvoices.cs b/src/ElectronicPharmacy/UserControls/UCInvoices.cs index 05fb61a..ba51fdc 100644 --- a/src/ElectronicPharmacy/UserControls/UCInvoices.cs +++ b/src/ElectronicPharmacy/UserControls/UCInvoices.cs @@ -406,7 +406,7 @@ where date(i.[InvoiceDate]) between date(@dateFrom) and date(@dateTo)"; return; } - _exportInvoiceMenuItem = new ToolStripMenuItem("Экспорт"); + _exportInvoiceMenuItem = new ToolStripMenuItem("Экспортировать в папку"); _exportInvoiceMenuItem.Click += (_, __) => ExportSelectedInvoice(); _invoiceMenu = new ContextMenuStrip(); @@ -547,11 +547,27 @@ where ii.idInvoice = @invoiceId;"; return; } - var invoiceNumber = dgvInvoice.SelectedRows[0].Cells["Номер"]?.Value?.ToString() ?? string.Empty; - ToastNotification.ShowCustom( - $"Экспорт накладной {invoiceNumber} будет добавлен после согласования формата.", - Color.SteelBlue, - Color.White); + var row = dgvInvoice.SelectedRows[0]; + var invoiceId = row.Cells["ИД накладной"]?.Value?.ToString() ?? string.Empty; + var invoiceNumber = row.Cells["Номер"]?.Value?.ToString() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(invoiceId)) + { + ToastNotification.ShowCustom("Не удалось определить накладную для экспорта.", Color.DarkOrange, Color.White); + return; + } + + try + { + var exportService = new InvoiceExportService(); + var filePath = exportService.ExportInvoice(invoiceId, FindForm()); + ToastNotification.ShowSuccess($"Накладная {invoiceNumber} сохранена:\n{filePath}"); + } + catch (Exception ex) + { + AppDebugLog.Error("Invoices", $"Ошибка экспорта накладной {invoiceNumber}", ex); + ToastNotification.ShowError($"Не удалось экспортировать накладную: {ex.Message}"); + } } private void txtInvoiceNumber_KeyPress(object sender, KeyPressEventArgs e)