Прогресс загрузки прайса 0–100% и экспорт накладных (#1)

## Summary
- Реальный прогресс загрузки прайса 0–100%: по байтам при скачивании,
затем по позициям при обработке и сохранении в БД
- Настройка папки экспорта накладных и CSV-экспорт из контекстного меню

## Test plan
- [ ] Запустить обновление прайса и убедиться, что полоска идёт от 0 до
100 с текстом «сколько / сколько»
- [ ] В авторизации задать папку экспорта накладных
- [ ] Экспортировать накладную в CSV через контекстное меню
This commit is contained in:
Motylek13 2026-07-18 15:47:31 +03:00 committed by GitHub
commit 999cfe4121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 618 additions and 55 deletions

View File

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

View File

@ -98,7 +98,13 @@ namespace Электронная_Фармация.Classes
public bool IsAuthenticated => !string.IsNullOrEmpty(_token); public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string supplierId = null, string regionId = null, string q = null, int? limit = null, int? offset = null) public async Task<PriceSummaryResponse> GetPriceSummaryAsync(
string supplierId = null,
string regionId = null,
string q = null,
int? limit = null,
int? offset = null,
IProgress<HttpTransferProgress> transferProgress = null)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
@ -130,7 +136,10 @@ namespace Электронная_Фармация.Classes
path += "?" + string.Join("&", queryParams); path += "?" + string.Join("&", queryParams);
} }
var responseBody = await SendAuthorizedGetAsync(path, "загрузку прайса"); var responseBody = transferProgress == null
? await SendAuthorizedGetAsync(path, "загрузку прайса")
: await SendAuthorizedGetWithProgressAsync(path, "загрузку прайса", transferProgress);
try try
{ {
var summary = JsonSerializer.Deserialize<PriceSummaryResponse>(responseBody, JsonOptions); var summary = JsonSerializer.Deserialize<PriceSummaryResponse>(responseBody, JsonOptions);
@ -174,6 +183,14 @@ namespace Электронная_Фармация.Classes
} }
private async Task<string> SendAuthorizedGetAsync(string path, string operationName) private async Task<string> SendAuthorizedGetAsync(string path, string operationName)
{
return await SendAuthorizedGetWithProgressAsync(path, operationName, null);
}
private async Task<string> SendAuthorizedGetWithProgressAsync(
string path,
string operationName,
IProgress<HttpTransferProgress> transferProgress)
{ {
var fullUrl = BuildFullUrl(path); var fullUrl = BuildFullUrl(path);
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
@ -185,8 +202,8 @@ namespace Электронная_Фармация.Classes
using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, fullUrl)) using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, fullUrl))
{ {
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
var response = await _httpClient.SendAsync(httpRequest); var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await ReadContentWithProgressAsync(response, transferProgress);
stopwatch.Stop(); stopwatch.Stop();
AppDebugLog.ApiResponse("GET", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody); AppDebugLog.ApiResponse("GET", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
@ -243,6 +260,29 @@ namespace Электронная_Фармация.Classes
} }
} }
private static async Task<string> ReadContentWithProgressAsync(
HttpResponseMessage response,
IProgress<HttpTransferProgress> transferProgress)
{
var totalBytes = response.Content.Headers.ContentLength;
using (var stream = await response.Content.ReadAsStreamAsync())
using (var memory = new System.IO.MemoryStream())
{
var buffer = new byte[81920];
long received = 0;
int read;
while ((read = await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
{
memory.Write(buffer, 0, read);
received += read;
transferProgress?.Report(new HttpTransferProgress(received, totalBytes));
}
transferProgress?.Report(new HttpTransferProgress(received, totalBytes ?? received));
return Encoding.UTF8.GetString(memory.ToArray());
}
}
private string ParseLoginResponse(HttpResponseMessage response, string responseBody) private string ParseLoginResponse(HttpResponseMessage response, string responseBody)
{ {
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
@ -333,4 +373,29 @@ namespace Электронная_Фармация.Classes
public string InvoiceSum { get; set; } public string InvoiceSum { get; set; }
public string RefuseSum { get; set; } public string RefuseSum { get; set; }
} }
public sealed class HttpTransferProgress
{
public HttpTransferProgress(long bytesReceived, long? totalBytes)
{
BytesReceived = bytesReceived;
TotalBytes = totalBytes;
}
public long BytesReceived { get; }
public long? TotalBytes { get; }
public int Percent
{
get
{
if (!TotalBytes.HasValue || TotalBytes.Value <= 0)
{
return 0;
}
return (int)Math.Max(0, Math.Min(100, (100.0 * BytesReceived) / TotalBytes.Value));
}
}
}
} }

View File

@ -108,5 +108,16 @@ namespace Электронная_Фармация.Classes
Settings.Default.LocationId = (locationId ?? string.Empty).Trim(); Settings.Default.LocationId = (locationId ?? string.Empty).Trim();
Settings.Default.Save(); 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\AppDebugLog.cs" />
<Compile Include="Classes\DataSender.cs" /> <Compile Include="Classes\DataSender.cs" />
<Compile Include="Classes\ErrorResponse.cs" /> <Compile Include="Classes\ErrorResponse.cs" />
<Compile Include="Classes\InvoiceExportService.cs" />
<Compile Include="Classes\InvoiceRequestService.cs" /> <Compile Include="Classes\InvoiceRequestService.cs" />
<Compile Include="Classes\InvoiceSyncService.cs" /> <Compile Include="Classes\InvoiceSyncService.cs" />
<Compile Include="Classes\LoginRequest.cs" /> <Compile Include="Classes\LoginRequest.cs" />

View File

@ -32,17 +32,49 @@
this.dataGridView1 = new Elfisa.UI.Controls.ModernDataGridView(); this.dataGridView1 = new Elfisa.UI.Controls.ModernDataGridView();
this.DataSetPriceList = new System.Data.DataSet(); this.DataSetPriceList = new System.Data.DataSet();
this.btnCloseThis = new Elfisa.UI.Controls.ModernButton(); this.btnCloseThis = new Elfisa.UI.Controls.ModernButton();
this.lblTitle = new System.Windows.Forms.Label();
this.lblProgress = new System.Windows.Forms.Label();
this.progressDownload = new System.Windows.Forms.ProgressBar();
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.DataSetPriceList)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.DataSetPriceList)).BeginInit();
this.SuspendLayout(); this.SuspendLayout();
// //
// lblTitle
//
this.lblTitle.AutoSize = true;
this.lblTitle.Font = new System.Drawing.Font("Segoe UI Semibold", 12F, System.Drawing.FontStyle.Bold);
this.lblTitle.Location = new System.Drawing.Point(22, 14);
this.lblTitle.Name = "lblTitle";
this.lblTitle.Size = new System.Drawing.Size(220, 28);
this.lblTitle.TabIndex = 5;
this.lblTitle.Text = "Загрузка прайс-листа";
//
// progressDownload
//
this.progressDownload.Location = new System.Drawing.Point(25, 52);
this.progressDownload.Name = "progressDownload";
this.progressDownload.Size = new System.Drawing.Size(630, 22);
this.progressDownload.Style = System.Windows.Forms.ProgressBarStyle.Continuous;
this.progressDownload.Maximum = 100;
this.progressDownload.TabIndex = 6;
//
// lblProgress
//
this.lblProgress.AutoEllipsis = true;
this.lblProgress.Font = new System.Drawing.Font("Segoe UI", 10F);
this.lblProgress.Location = new System.Drawing.Point(25, 82);
this.lblProgress.Name = "lblProgress";
this.lblProgress.Size = new System.Drawing.Size(630, 24);
this.lblProgress.TabIndex = 7;
this.lblProgress.Text = "Подготовка...";
//
// rtxtDebug // rtxtDebug
// //
this.rtxtDebug.BackColor = System.Drawing.SystemColors.Control; this.rtxtDebug.BackColor = System.Drawing.SystemColors.Control;
this.rtxtDebug.BorderStyle = System.Windows.Forms.BorderStyle.None; this.rtxtDebug.BorderStyle = System.Windows.Forms.BorderStyle.None;
this.rtxtDebug.Location = new System.Drawing.Point(25, 12); this.rtxtDebug.Location = new System.Drawing.Point(25, 116);
this.rtxtDebug.Name = "rtxtDebug"; this.rtxtDebug.Name = "rtxtDebug";
this.rtxtDebug.Size = new System.Drawing.Size(630, 319); this.rtxtDebug.Size = new System.Drawing.Size(630, 215);
this.rtxtDebug.TabIndex = 0; this.rtxtDebug.TabIndex = 0;
this.rtxtDebug.Text = ""; this.rtxtDebug.Text = "";
// //
@ -79,6 +111,9 @@
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.BackColor = System.Drawing.SystemColors.Control; this.BackColor = System.Drawing.SystemColors.Control;
this.ClientSize = new System.Drawing.Size(684, 396); this.ClientSize = new System.Drawing.Size(684, 396);
this.Controls.Add(this.lblProgress);
this.Controls.Add(this.progressDownload);
this.Controls.Add(this.lblTitle);
this.Controls.Add(this.btnCloseThis); this.Controls.Add(this.btnCloseThis);
this.Controls.Add(this.dataGridView1); this.Controls.Add(this.dataGridView1);
this.Controls.Add(this.rtxtDebug); this.Controls.Add(this.rtxtDebug);
@ -93,7 +128,7 @@
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.DataSetPriceList)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.DataSetPriceList)).EndInit();
this.ResumeLayout(false); this.ResumeLayout(false);
this.PerformLayout();
} }
#endregion #endregion
@ -102,5 +137,8 @@
private Elfisa.UI.Controls.ModernDataGridView dataGridView1; private Elfisa.UI.Controls.ModernDataGridView dataGridView1;
private System.Data.DataSet DataSetPriceList; private System.Data.DataSet DataSetPriceList;
private Elfisa.UI.Controls.ModernButton btnCloseThis; private Elfisa.UI.Controls.ModernButton btnCloseThis;
private System.Windows.Forms.Label lblTitle;
private System.Windows.Forms.Label lblProgress;
private System.Windows.Forms.ProgressBar progressDownload;
} }
} }

View File

@ -12,6 +12,11 @@ namespace Электронная_Фармация.HelpForms
{ {
public partial class HF_DownloadDataFromServer : Form public partial class HF_DownloadDataFromServer : Form
{ {
// Доли общего прогресса 0..100.
private const int DownloadPhaseEnd = 45;
private const int BuildPhaseEnd = 55;
private const int SavePhaseEnd = 100;
public HF_DownloadDataFromServer() public HF_DownloadDataFromServer()
{ {
InitializeComponent(); InitializeComponent();
@ -20,6 +25,7 @@ namespace Электронная_Фармация.HelpForms
private readonly string _login = Settings.Default.stringLogin ?? string.Empty; private readonly string _login = Settings.Default.stringLogin ?? string.Empty;
private readonly string _password = Settings.Default.stringPassword ?? string.Empty; private readonly string _password = Settings.Default.stringPassword ?? string.Empty;
private readonly string _connectionStringToLocalDb = AppConfig.SqliteConnectionString; private readonly string _connectionStringToLocalDb = AppConfig.SqliteConnectionString;
private bool _isDownloading;
private async void HF_DownloadDataFromServer_Load(object sender, EventArgs e) private async void HF_DownloadDataFromServer_Load(object sender, EventArgs e)
{ {
@ -28,6 +34,8 @@ namespace Электронная_Фармация.HelpForms
dataGridView1.Visible = false; dataGridView1.Visible = false;
rtxtDebug.Visible = true; rtxtDebug.Visible = true;
rtxtDebug.ReadOnly = true; rtxtDebug.ReadOnly = true;
_isDownloading = true;
SetOverallProgress(0, "Подключение к серверу...");
var client = new ApiClient(); var client = new ApiClient();
AppendStatus($"Сервер: {client.BaseUrl}"); AppendStatus($"Сервер: {client.BaseUrl}");
@ -36,39 +44,67 @@ namespace Электронная_Фармация.HelpForms
try try
{ {
SetOverallProgress(1, "Авторизация...");
await EnsureAuthenticatedAsync(client); await EnsureAuthenticatedAsync(client);
AppendStatus("Загружаю сводный прайс (постранично, весь)..."); SetOverallProgress(3, "Загрузка прайса с сервера...");
AppendStatus("Загружаю сводный прайс...");
var allSuppliersSummary = await GetFullPriceSummaryPagedAsync(client); var allSuppliersSummary = await GetFullPriceSummaryPagedAsync(client);
AppendStatus($"Получено {allSuppliersSummary.Summary?.Count ?? 0} позиций"); int totalItems = allSuppliersSummary.Summary?.Count ?? 0;
AppendStatus($"Получено {totalItems} позиций");
SetOverallProgress(DownloadPhaseEnd, $"Получено {totalItems} / {totalItems} позиций (100%)");
var tablePrice = BuildPriceTable(allSuppliersSummary); SetOverallProgress(DownloadPhaseEnd, "Подготовка таблицы...");
SavePriceTable(tablePrice); var buildProgress = new Progress<CountProgress>(p =>
{
int phasePercent = MapPhasePercent(p.Current, p.Total, DownloadPhaseEnd, BuildPhaseEnd);
SetOverallProgress(
phasePercent,
$"Обработка: {p.Current} / {p.Total} ({PercentOf(p.Current, p.Total)}%)");
});
var tablePrice = await Task.Run(() => BuildPriceTable(allSuppliersSummary, buildProgress));
SetOverallProgress(BuildPhaseEnd, "Сохранение в локальную базу...");
var saveProgress = new Progress<CountProgress>(p =>
{
int phasePercent = MapPhasePercent(p.Current, p.Total, BuildPhaseEnd, SavePhaseEnd);
SetOverallProgress(
phasePercent,
$"Сохранение: {p.Current} / {p.Total} ({PercentOf(p.Current, p.Total)}%)");
});
await Task.Run(() => SavePriceTable(tablePrice, saveProgress));
SetOverallProgress(100, $"Готово: {tablePrice.Rows.Count} / {tablePrice.Rows.Count} (100%)");
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
AppendStatus($"Готово. Сохранено {tablePrice.Rows.Count} позиций в локальную базу."); AppendStatus($"Готово. Сохранено {tablePrice.Rows.Count} позиций в локальную базу.");
ToastNotification.ShowSuccess($"Прайс обновлён: {tablePrice.Rows.Count} позиций"); ToastNotification.ShowSuccess($"Прайс обновлён: {tablePrice.Rows.Count} позиций");
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
SetOverallProgress(0, "Ошибка авторизации");
AppendStatus($"Ошибка авторизации: {ex.Message}"); AppendStatus($"Ошибка авторизации: {ex.Message}");
AppDebugLog.Error("Download", "Ошибка авторизации при загрузке прайса", ex); AppDebugLog.Error("Download", "Ошибка авторизации при загрузке прайса", ex);
ToastNotification.ShowError($"Ошибка авторизации: {ex.Message}"); ToastNotification.ShowError($"Ошибка авторизации: {ex.Message}");
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
SetOverallProgress(0, "Ошибка сети");
AppendStatus($"Ошибка сети: {ex.Message}"); AppendStatus($"Ошибка сети: {ex.Message}");
AppDebugLog.Error("Download", "Ошибка сети при загрузке прайса", ex); AppDebugLog.Error("Download", "Ошибка сети при загрузке прайса", ex);
ToastNotification.ShowError($"Ошибка сети: {ex.Message}"); ToastNotification.ShowError($"Ошибка сети: {ex.Message}");
} }
catch (Exception ex) catch (Exception ex)
{ {
SetOverallProgress(0, "Ошибка загрузки");
AppendStatus($"Ошибка загрузки прайса: {ex.Message}"); AppendStatus($"Ошибка загрузки прайса: {ex.Message}");
AppDebugLog.Error("Download", "Неожиданная ошибка при загрузке прайса", ex); AppDebugLog.Error("Download", "Неожиданная ошибка при загрузке прайса", ex);
ToastNotification.ShowError($"Ошибка загрузки прайса: {ex.Message}"); ToastNotification.ShowError($"Ошибка загрузки прайса: {ex.Message}");
} }
finally
btnCloseThis.Enabled = true; {
_isDownloading = false;
btnCloseThis.Enabled = true;
}
} }
private async Task EnsureAuthenticatedAsync(ApiClient client) private async Task EnsureAuthenticatedAsync(ApiClient client)
@ -98,39 +134,51 @@ namespace Электронная_Фармация.HelpForms
AppendStatus("Авторизация успешно пройдена"); AppendStatus("Авторизация успешно пройдена");
} }
// Тянем весь прайс постранично: сервер отдаёт максимум 5000 за запрос,
// поэтому идём offset'ом, пока страница не окажется неполной.
private async Task<PriceSummaryResponse> GetFullPriceSummaryPagedAsync(ApiClient client) private async Task<PriceSummaryResponse> GetFullPriceSummaryPagedAsync(ApiClient client)
{ {
var regionId = string.IsNullOrWhiteSpace(Settings.Default.RegionId) var regionId = string.IsNullOrWhiteSpace(Settings.Default.RegionId)
? null ? null
: Settings.Default.RegionId; : Settings.Default.RegionId;
// Тянем весь прайс одним запросом: постраничный OFFSET на тяжёлом // Один крупный запрос (OFFSET на больших страницах валит сервер).
// сводном запросе валит сервер на глубоких страницах. Один запрос =
// один расчёт выборки. Страховка-цикл остаётся на случай, если позиций
// окажется больше pageSize.
const int pageSize = 200000; const int pageSize = 200000;
const int maxPages = 100; const int maxPages = 100;
var combined = new PriceSummaryResponse { Summary = new List<PriceSummaryItem>() }; var combined = new PriceSummaryResponse { Summary = new List<PriceSummaryItem>() };
var transferProgress = new Progress<HttpTransferProgress>(ReportTransferProgress);
for (int page = 0; page < maxPages; page++) for (int page = 0; page < maxPages; page++)
{ {
int offset = page * pageSize; int offset = page * pageSize;
SetOverallProgress(
Math.Min(DownloadPhaseEnd - 1, 3 + page),
page == 0 ? "Скачивание прайса с сервера..." : $"Скачивание страницы {page + 1}...");
PriceSummaryResponse chunk; PriceSummaryResponse chunk;
try try
{ {
chunk = await client.GetPriceSummaryAsync(supplierId: null, regionId: regionId, limit: pageSize, offset: offset); chunk = await client.GetPriceSummaryAsync(
supplierId: null,
regionId: regionId,
limit: pageSize,
offset: offset,
transferProgress: transferProgress);
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
AppendStatus("Токен недействителен, повторная авторизация..."); AppendStatus("Токен недействителен, повторная авторизация...");
SetOverallProgress(2, "Повторная авторизация...");
Settings.Default.stringToken = string.Empty; Settings.Default.stringToken = string.Empty;
Settings.Default.Save(); Settings.Default.Save();
client.SetToken(null); client.SetToken(null);
await LoginAndSaveTokenAsync(client); await LoginAndSaveTokenAsync(client);
chunk = await client.GetPriceSummaryAsync(supplierId: null, regionId: regionId, limit: pageSize, offset: offset); SetOverallProgress(3, "Повторная загрузка прайса...");
chunk = await client.GetPriceSummaryAsync(
supplierId: null,
regionId: regionId,
limit: pageSize,
offset: offset,
transferProgress: transferProgress);
} }
int got = chunk?.Summary?.Count ?? 0; int got = chunk?.Summary?.Count ?? 0;
@ -142,14 +190,33 @@ namespace Электронная_Фармация.HelpForms
if (got < pageSize) if (got < pageSize)
{ {
break; // последняя (неполная) страница — дальше данных нет break;
} }
} }
return combined; return combined;
} }
private static DataTable BuildPriceTable(PriceSummaryResponse allSuppliersSummary) private void ReportTransferProgress(HttpTransferProgress transfer)
{
if (transfer.TotalBytes.HasValue && transfer.TotalBytes.Value > 0)
{
int phasePercent = MapPhasePercent(transfer.BytesReceived, transfer.TotalBytes.Value, 3, DownloadPhaseEnd);
SetOverallProgress(
phasePercent,
$"Скачивание: {FormatBytes(transfer.BytesReceived)} / {FormatBytes(transfer.TotalBytes.Value)} ({transfer.Percent}%)");
}
else
{
SetOverallProgress(
Math.Min(DownloadPhaseEnd - 1, 5),
$"Скачивание: получено {FormatBytes(transfer.BytesReceived)}...");
}
}
private static DataTable BuildPriceTable(
PriceSummaryResponse allSuppliersSummary,
IProgress<CountProgress> progress = null)
{ {
var tablePrice = new DataTable(); var tablePrice = new DataTable();
tablePrice.Columns.Add("supplier_price_id"); tablePrice.Columns.Add("supplier_price_id");
@ -192,6 +259,8 @@ namespace Электронная_Фармация.HelpForms
return tablePrice; return tablePrice;
} }
int total = allSuppliersSummary.Summary.Count;
int current = 0;
foreach (var item in allSuppliersSummary.Summary) foreach (var item in allSuppliersSummary.Summary)
{ {
var row = tablePrice.NewRow(); var row = tablePrice.NewRow();
@ -210,12 +279,18 @@ namespace Электронная_Фармация.HelpForms
row["TradeName"] = string.IsNullOrWhiteSpace(item.TradeName) ? (object)DBNull.Value : item.TradeName.Trim(); row["TradeName"] = string.IsNullOrWhiteSpace(item.TradeName) ? (object)DBNull.Value : item.TradeName.Trim();
row["Dosage"] = string.IsNullOrWhiteSpace(item.Dosage) ? (object)DBNull.Value : item.Dosage.Trim(); row["Dosage"] = string.IsNullOrWhiteSpace(item.Dosage) ? (object)DBNull.Value : item.Dosage.Trim();
tablePrice.Rows.Add(row); tablePrice.Rows.Add(row);
current++;
if (progress != null && (current % 500 == 0 || current == total))
{
progress.Report(new CountProgress(current, total));
}
} }
return tablePrice; return tablePrice;
} }
private void SavePriceTable(DataTable tablePrice) private void SavePriceTable(DataTable tablePrice, IProgress<CountProgress> progress = null)
{ {
const string commandToDelete = "delete from [PriceList]"; const string commandToDelete = "delete from [PriceList]";
const string commandToDeleteTempOrder = "delete from [TempOrderItems]"; const string commandToDeleteTempOrder = "delete from [TempOrderItems]";
@ -253,6 +328,7 @@ VALUES
@Dosage @Dosage
)"; )";
int total = tablePrice.Rows.Count;
using (var sqliteCon = new SQLiteConnection(_connectionStringToLocalDb)) using (var sqliteCon = new SQLiteConnection(_connectionStringToLocalDb))
{ {
sqliteCon.Open(); sqliteCon.Open();
@ -266,6 +342,7 @@ VALUES
using (var transaction = sqliteCon.BeginTransaction()) using (var transaction = sqliteCon.BeginTransaction())
{ {
int saved = 0;
foreach (DataRow row in tablePrice.Rows) foreach (DataRow row in tablePrice.Rows)
{ {
using (var cmd = new SQLiteCommand(commandToInsert, sqliteCon, transaction)) using (var cmd = new SQLiteCommand(commandToInsert, sqliteCon, transaction))
@ -286,6 +363,12 @@ VALUES
cmd.Parameters.AddWithValue("@Dosage", row.Table.Columns.Contains("Dosage") ? row["Dosage"] : DBNull.Value); cmd.Parameters.AddWithValue("@Dosage", row.Table.Columns.Contains("Dosage") ? row["Dosage"] : DBNull.Value);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
saved++;
if (progress != null && (saved % 250 == 0 || saved == total))
{
progress.Report(new CountProgress(saved, total));
}
} }
transaction.Commit(); transaction.Commit();
@ -293,6 +376,68 @@ VALUES
} }
} }
private static int MapPhasePercent(long current, long total, int phaseStart, int phaseEnd)
{
if (total <= 0)
{
return phaseStart;
}
double ratio = Math.Max(0.0, Math.Min(1.0, (double)current / total));
return phaseStart + (int)((phaseEnd - phaseStart) * ratio);
}
private static int PercentOf(long current, long total)
{
if (total <= 0)
{
return 0;
}
return (int)Math.Max(0, Math.Min(100, (100.0 * current) / total));
}
private static string FormatBytes(long bytes)
{
if (bytes < 1024)
{
return $"{bytes} Б";
}
if (bytes < 1024 * 1024)
{
return $"{bytes / 1024.0:0.0} КБ";
}
return $"{bytes / (1024.0 * 1024.0):0.00} МБ";
}
private void SetOverallProgress(int percent, string text)
{
if (IsDisposed)
{
return;
}
if (InvokeRequired)
{
BeginInvoke(new Action<int, string>(SetOverallProgress), percent, text);
return;
}
if (progressDownload.Style != ProgressBarStyle.Continuous)
{
progressDownload.Style = ProgressBarStyle.Continuous;
}
progressDownload.Minimum = 0;
progressDownload.Maximum = 100;
progressDownload.Value = Math.Max(0, Math.Min(100, percent));
lblProgress.Text = $"{Math.Max(0, Math.Min(100, percent))}% — {text}";
lblProgress.Refresh();
progressDownload.Refresh();
}
private void AppendStatus(string message) private void AppendStatus(string message)
{ {
if (InvokeRequired) if (InvokeRequired)
@ -310,13 +455,27 @@ VALUES
private void HF_DownloadDataFromServer_FormClosing(object sender, FormClosingEventArgs e) private void HF_DownloadDataFromServer_FormClosing(object sender, FormClosingEventArgs e)
{ {
Dispose(); if (_isDownloading)
Close(); {
e.Cancel = true;
}
} }
private void btnCloseThis_Click(object sender, EventArgs e) private void btnCloseThis_Click(object sender, EventArgs e)
{ {
Close(); Close();
} }
private sealed class CountProgress
{
public CountProgress(int current, int total)
{
Current = current;
Total = total;
}
public int Current { get; }
public int Total { get; }
}
} }
} }

View File

@ -40,6 +40,9 @@
this.label3 = new System.Windows.Forms.Label(); this.label3 = new System.Windows.Forms.Label();
this.label5 = new System.Windows.Forms.Label(); this.label5 = new System.Windows.Forms.Label();
this.txtApiUrl = new Elfisa.UI.Controls.ModernTextBox(); 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.panel1.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
// //
@ -79,9 +82,55 @@
this.txtPassword.Size = new System.Drawing.Size(375, 34); this.txtPassword.Size = new System.Drawing.Size(375, 34);
this.txtPassword.TabIndex = 3; 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 // 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.Name = "btnConfirmRegistration";
this.btnConfirmRegistration.Size = new System.Drawing.Size(215, 43); this.btnConfirmRegistration.Size = new System.Drawing.Size(215, 43);
this.btnConfirmRegistration.TabIndex = 5; this.btnConfirmRegistration.TabIndex = 5;
@ -90,7 +139,7 @@
// //
// btnCloseThis // 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.Name = "btnCloseThis";
this.btnCloseThis.Size = new System.Drawing.Size(125, 43); this.btnCloseThis.Size = new System.Drawing.Size(125, 43);
this.btnCloseThis.TabIndex = 6; this.btnCloseThis.TabIndex = 6;
@ -102,7 +151,7 @@
this.checkTokenGained.AutoCheck = false; this.checkTokenGained.AutoCheck = false;
this.checkTokenGained.AutoSize = true; this.checkTokenGained.AutoSize = true;
this.checkTokenGained.Font = new System.Drawing.Font("Segoe UI", 12F); 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.Name = "checkTokenGained";
this.checkTokenGained.Size = new System.Drawing.Size(171, 32); this.checkTokenGained.Size = new System.Drawing.Size(171, 32);
this.checkTokenGained.TabIndex = 7; this.checkTokenGained.TabIndex = 7;
@ -133,29 +182,14 @@
this.label3.Text = "АВТОРИЗАЦИЯ"; this.label3.Text = "АВТОРИЗАЦИЯ";
this.label3.TextAlign = System.Drawing.ContentAlignment.MiddleRight; 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 // HF_Registration
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; 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.txtApiUrl);
this.Controls.Add(this.label5); this.Controls.Add(this.label5);
this.Controls.Add(this.panel1); this.Controls.Add(this.panel1);
@ -192,5 +226,8 @@
private System.Windows.Forms.Label label3; private System.Windows.Forms.Label label3;
private System.Windows.Forms.Label label5; private System.Windows.Forms.Label label5;
private Elfisa.UI.Controls.ModernTextBox txtApiUrl; 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(); 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) private async void btnConfirmRegistration_Click(object sender, EventArgs e)
{ {
btnConfirmRegistration.Enabled = false; btnConfirmRegistration.Enabled = false;
@ -39,6 +60,7 @@ namespace Электронная_Фармация.HelpForms
// на старый сохранённый/дефолтный адрес, а не на введённый пользователем. // на старый сохранённый/дефолтный адрес, а не на введённый пользователем.
string apiUrl = txtApiUrl.Text.Trim(); string apiUrl = txtApiUrl.Text.Trim();
Settings.Default.ApiBaseUrl = apiUrl; Settings.Default.ApiBaseUrl = apiUrl;
AppConfig.SetInvoiceExportPath(txtInvoiceExportPath.Text);
Settings.Default.Save(); Settings.Default.Save();
LoginRequest loginRequest = new LoginRequest(); LoginRequest loginRequest = new LoginRequest();
@ -56,6 +78,7 @@ namespace Электронная_Фармация.HelpForms
Settings.Default.stringPassword = strPassword; Settings.Default.stringPassword = strPassword;
Settings.Default.stringToken = token; Settings.Default.stringToken = token;
Settings.Default.ApiBaseUrl = client.BaseUrl; Settings.Default.ApiBaseUrl = client.BaseUrl;
AppConfig.SetInvoiceExportPath(txtInvoiceExportPath.Text);
Settings.Default.Save(); Settings.Default.Save();
AppDebugLog.Info("Auth", $"Авторизация успешна. token={AppDebugLog.MaskToken(token)}"); AppDebugLog.Info("Auth", $"Авторизация успешна. token={AppDebugLog.MaskToken(token)}");
@ -104,6 +127,7 @@ namespace Электронная_Фармация.HelpForms
txtApiUrl.Text = string.IsNullOrWhiteSpace(Settings.Default.ApiBaseUrl) txtApiUrl.Text = string.IsNullOrWhiteSpace(Settings.Default.ApiBaseUrl)
? AppConfig.DefaultApiBaseUrl ? AppConfig.DefaultApiBaseUrl
: Settings.Default.ApiBaseUrl; : Settings.Default.ApiBaseUrl;
txtInvoiceExportPath.Text = AppConfig.InvoiceExportPath;
checkTokenGained.Checked = doWeHaveAToken.Length > 0; 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"> <Setting Name="UseDarkTheme" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value> <Value Profile="(Default)">False</Value>
</Setting> </Setting>
<Setting Name="InvoiceExportPath" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
</Settings> </Settings>
</SettingsFile> </SettingsFile>

View File

@ -406,7 +406,7 @@ where date(i.[InvoiceDate]) between date(@dateFrom) and date(@dateTo)";
return; return;
} }
_exportInvoiceMenuItem = new ToolStripMenuItem("Экспорт"); _exportInvoiceMenuItem = new ToolStripMenuItem("Экспортировать в папку");
_exportInvoiceMenuItem.Click += (_, __) => ExportSelectedInvoice(); _exportInvoiceMenuItem.Click += (_, __) => ExportSelectedInvoice();
_invoiceMenu = new ContextMenuStrip(); _invoiceMenu = new ContextMenuStrip();
@ -547,11 +547,27 @@ where ii.idInvoice = @invoiceId;";
return; return;
} }
var invoiceNumber = dgvInvoice.SelectedRows[0].Cells["Номер"]?.Value?.ToString() ?? string.Empty; var row = dgvInvoice.SelectedRows[0];
ToastNotification.ShowCustom( var invoiceId = row.Cells["ИД накладной"]?.Value?.ToString() ?? string.Empty;
$"Экспорт накладной {invoiceNumber} будет добавлен после согласования формата.", var invoiceNumber = row.Cells["Номер"]?.Value?.ToString() ?? string.Empty;
Color.SteelBlue,
Color.White); 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) private void txtInvoiceNumber_KeyPress(object sender, KeyPressEventArgs e)