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 <cursoragent@cursor.com>
This commit is contained in:
Magomed 2026-07-18 13:34:48 +03:00
parent 5e24cc95b0
commit 9ee5561f3d
9 changed files with 329 additions and 28 deletions

View File

@ -36,6 +36,9 @@
<setting name="stringToken" serializeAs="String">
<value />
</setting>
<setting name="InvoiceExportPath" serializeAs="String">
<value />
</setting>
</Электронная_Фармация.Properties.Settings>
</userSettings>
</configuration>

View File

@ -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();
}
}
}

View File

@ -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
{
/// <summary>
/// Exports a local invoice (header + items) to a CSV file in the configured folder.
/// </summary>
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; }
}
}
}

View File

@ -106,6 +106,7 @@
<Compile Include="Classes\AppDebugLog.cs" />
<Compile Include="Classes\DataSender.cs" />
<Compile Include="Classes\ErrorResponse.cs" />
<Compile Include="Classes\InvoiceExportService.cs" />
<Compile Include="Classes\InvoiceRequestService.cs" />
<Compile Include="Classes\InvoiceSyncService.cs" />
<Compile Include="Classes\LoginRequest.cs" />

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}
}
}

View File

@ -26,5 +26,8 @@
<Setting Name="UseDarkTheme" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
<Setting Name="InvoiceExportPath" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
</Settings>
</SettingsFile>

View File

@ -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)