elfisa-pharmacy/src/ElectronicPharmacy/Classes/ApiClient.cs
Exest e35e67849f v1.0.4: адаптация desktop к текущему buyer API
Перевести desktop-клиент на работу с текущими buyer orders и локальным SQLite-кешем, чтобы отправка заказов и синхронизация документов работали с уже развернутым сервером без старых MSSQL-зависимостей.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 11:41:00 +03:00

664 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Электроннаяармация.Classes
{
public class ApiClient
{
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = null
};
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private string _token;
public ApiClient(string baseUrl = null)
{
_baseUrl = AppConfig.NormalizeApiBaseUrl(baseUrl ?? AppConfig.ApiBaseUrl);
_httpClient = new HttpClient
{
// Крупный прайс тянем одним запросом — даём серверу время посчитать и отдать.
Timeout = TimeSpan.FromSeconds(120)
};
AppDebugLog.Info("ApiClient", $"Создан клиент. BaseUrl={_baseUrl}, Timeout={_httpClient.Timeout.TotalSeconds}s");
}
public string BaseUrl => _baseUrl;
public async Task<string> LoginAsync(string username, string password)
{
var request = new LoginRequest
{
Username = username,
Password = password
};
var path = "/auth/login";
var fullUrl = BuildFullUrl(path);
var requestJson = JsonSerializer.Serialize(request, JsonOptions);
var stopwatch = Stopwatch.StartNew();
AppDebugLog.ApiRequest("POST", fullUrl, requestJson, $"username={username}");
try
{
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, fullUrl))
{
httpRequest.Content = new StringContent(requestJson, Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
stopwatch.Stop();
AppDebugLog.ApiResponse("POST", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
return ParseLoginResponse(response, responseBody);
}
}
catch (HttpRequestException ex) when (!ex.Message.StartsWith("Ошибка HTTP"))
{
stopwatch.Stop();
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw WrapNetworkError(ex, "авторизацию");
}
catch (TaskCanceledException ex)
{
stopwatch.Stop();
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw WrapNetworkError(ex, "авторизацию");
}
catch (Exception ex)
{
stopwatch.Stop();
if (ex is UnauthorizedAccessException || ex is HttpRequestException)
{
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw;
}
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw new HttpRequestException($"Ошибка при авторизации ({_baseUrl}): {ex.Message}", ex);
}
}
public void SetToken(string token)
{
_token = string.IsNullOrWhiteSpace(token) ? null : token;
AppDebugLog.Info("ApiClient", $"Токен установлен: {AppDebugLog.MaskToken(_token)}");
}
public string Token => _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,
IProgress<HttpTransferProgress> transferProgress = null)
{
EnsureAuthenticated();
var queryParams = new List<string>();
if (!string.IsNullOrEmpty(supplierId))
{
queryParams.Add($"supplier_id={Uri.EscapeDataString(supplierId)}");
}
if (!string.IsNullOrEmpty(regionId))
{
queryParams.Add($"region_id={Uri.EscapeDataString(regionId)}");
}
if (!string.IsNullOrWhiteSpace(q))
{
queryParams.Add($"q={Uri.EscapeDataString(q)}");
}
if (limit.HasValue)
{
queryParams.Add($"limit={limit.Value}");
}
if (offset.HasValue)
{
queryParams.Add($"offset={offset.Value}");
}
var path = "/api/supplier-prices/summary";
if (queryParams.Count > 0)
{
path += "?" + string.Join("&", queryParams);
}
var responseBody = transferProgress == null
? await SendAuthorizedGetAsync(path, "загрузку прайса")
: await SendAuthorizedGetWithProgressAsync(path, "загрузку прайса", transferProgress);
try
{
var summary = JsonSerializer.Deserialize<PriceSummaryResponse>(responseBody, JsonOptions);
var count = summary?.Summary?.Count ?? 0;
AppDebugLog.Info("ApiClient", $"Прайс получен: {count} позиций");
return summary;
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать ответ прайса", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке прайса: {ex.Message}", ex);
}
}
public async Task<List<InvoiceApiItem>> GetInvoicesAsync()
{
var orders = await GetBuyerOrdersAsync(status: "Placed", limit: 200);
var result = new List<InvoiceApiItem>();
foreach (var order in orders)
{
var detail = await GetBuyerOrderByIdAsync(order.OrderID);
if (detail == null)
{
continue;
}
result.Add(InvoiceApiItem.FromBuyerOrder(detail));
}
AppDebugLog.Info("ApiClient", $"Накладные (через buyer/orders) получены: {result.Count} шт.");
return result;
}
public async Task<List<BuyerOrderListItemDto>> GetBuyerOrdersAsync(string status = null, int limit = 200, int offset = 0)
{
EnsureAuthenticated();
var query = new List<string>();
if (!string.IsNullOrWhiteSpace(status))
{
query.Add($"status={Uri.EscapeDataString(status)}");
}
if (limit > 0)
{
query.Add($"limit={limit}");
}
if (offset > 0)
{
query.Add($"offset={offset}");
}
var path = "/api/buyer/orders";
if (query.Count > 0)
{
path += "?" + string.Join("&", query);
}
var responseBody = await SendAuthorizedGetAsync(path, "загрузку заказов");
try
{
var response = JsonSerializer.Deserialize<BuyerOrdersResponse>(responseBody, JsonOptions);
return response?.Orders ?? new List<BuyerOrderListItemDto>();
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать список заказов", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке заказов: {ex.Message}", ex);
}
}
public async Task<BuyerOrderDetailDto> GetBuyerOrderByIdAsync(string orderId)
{
EnsureAuthenticated();
if (string.IsNullOrWhiteSpace(orderId))
{
throw new ArgumentException("Не указан ID заказа.", nameof(orderId));
}
var responseBody = await SendAuthorizedGetAsync($"/api/buyer/orders/{Uri.EscapeDataString(orderId)}", "загрузку заказа");
try
{
return JsonSerializer.Deserialize<BuyerOrderDetailDto>(responseBody, JsonOptions);
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", $"Не удалось разобрать заказ {orderId}", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке заказа: {ex.Message}", ex);
}
}
public async Task<BuyerOrderDetailDto> PlaceBuyerOrderAsync(string orderId)
{
EnsureAuthenticated();
if (string.IsNullOrWhiteSpace(orderId))
{
throw new ArgumentException("Не указан ID заказа.", nameof(orderId));
}
var responseBody = await SendAuthorizedPostAsync($"/api/buyer/orders/{Uri.EscapeDataString(orderId)}/place", string.Empty, "оформление заказа");
try
{
return JsonSerializer.Deserialize<BuyerOrderDetailDto>(responseBody, JsonOptions);
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", $"Не удалось разобрать ответ оформления заказа {orderId}", ex);
throw new HttpRequestException($"Некорректный ответ сервера при оформлении заказа: {ex.Message}", ex);
}
}
private void EnsureAuthenticated()
{
if (!IsAuthenticated)
{
var ex = new InvalidOperationException("Необходимо выполнить авторизацию перед запросом данных");
AppDebugLog.Error("ApiClient", "Запрос без токена авторизации", ex);
throw ex;
}
}
private async Task<string> SendAuthorizedGetAsync(string path, string operationName)
{
return await SendAuthorizedGetWithProgressAsync(path, operationName, null);
}
private async Task<string> SendAuthorizedPostAsync(string path, string requestBody, string operationName)
{
var fullUrl = BuildFullUrl(path);
var stopwatch = Stopwatch.StartNew();
AppDebugLog.ApiRequest("POST", fullUrl, requestBody, $"token={AppDebugLog.MaskToken(_token)}");
try
{
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, fullUrl))
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
httpRequest.Content = new StringContent(requestBody ?? string.Empty, Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
stopwatch.Stop();
AppDebugLog.ApiResponse("POST", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
if (response.IsSuccessStatusCode)
{
return responseBody;
}
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_token = null;
var error = TryDeserializeError(responseBody);
var message = $"Сессия истекла: {error?.Error ?? "Необходимо войти заново"}";
AppDebugLog.ApiHttpError("POST", fullUrl, (int)response.StatusCode, responseBody, operationName);
throw new UnauthorizedAccessException(message);
}
var httpError = TryDeserializeError(responseBody);
var httpMessage = $"Ошибка HTTP {response.StatusCode}: {httpError?.Error ?? responseBody}";
AppDebugLog.ApiHttpError("POST", fullUrl, (int)response.StatusCode, responseBody, operationName);
throw new HttpRequestException(httpMessage);
}
}
catch (UnauthorizedAccessException)
{
throw;
}
catch (HttpRequestException ex) when (!ex.Message.StartsWith("Ошибка HTTP"))
{
stopwatch.Stop();
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw WrapNetworkError(ex, operationName);
}
catch (TaskCanceledException ex)
{
stopwatch.Stop();
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw WrapNetworkError(ex, operationName);
}
}
private async Task<string> SendAuthorizedGetWithProgressAsync(
string path,
string operationName,
IProgress<HttpTransferProgress> transferProgress)
{
var fullUrl = BuildFullUrl(path);
var stopwatch = Stopwatch.StartNew();
AppDebugLog.ApiRequest("GET", fullUrl, extra: $"token={AppDebugLog.MaskToken(_token)}");
try
{
using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, fullUrl))
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
var responseBody = await ReadContentWithProgressAsync(response, transferProgress);
stopwatch.Stop();
AppDebugLog.ApiResponse("GET", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
if (response.IsSuccessStatusCode)
{
return responseBody;
}
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_token = null;
var error = TryDeserializeError(responseBody);
var message = $"Сессия истекла: {error?.Error ?? "Необходимо войти заново"}";
AppDebugLog.ApiHttpError("GET", fullUrl, (int)response.StatusCode, responseBody, operationName);
var unauthorized = new UnauthorizedAccessException(message);
AppDebugLog.Error("ApiClient", message, unauthorized);
throw unauthorized;
}
var httpError = TryDeserializeError(responseBody);
var httpMessage = $"Ошибка HTTP {response.StatusCode}: {httpError?.Error ?? responseBody}";
AppDebugLog.ApiHttpError("GET", fullUrl, (int)response.StatusCode, responseBody, operationName);
var httpEx = new HttpRequestException(httpMessage);
AppDebugLog.Error("ApiClient", httpMessage, httpEx);
throw httpEx;
}
}
catch (HttpRequestException ex) when (!ex.Message.StartsWith("Ошибка HTTP"))
{
stopwatch.Stop();
AppDebugLog.ApiFailure("GET", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw WrapNetworkError(ex, operationName);
}
catch (TaskCanceledException ex)
{
stopwatch.Stop();
AppDebugLog.ApiFailure("GET", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw WrapNetworkError(ex, operationName);
}
catch (UnauthorizedAccessException)
{
throw;
}
catch (HttpRequestException)
{
throw;
}
catch (Exception ex)
{
stopwatch.Stop();
AppDebugLog.ApiFailure("GET", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw;
}
}
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)
{
if (response.IsSuccessStatusCode)
{
try
{
var loginResponse = JsonSerializer.Deserialize<LoginResponse>(responseBody, JsonOptions);
if (loginResponse == null || string.IsNullOrWhiteSpace(loginResponse.Token))
{
var emptyTokenEx = new HttpRequestException($"Сервер {_baseUrl} вернул пустой токен авторизации");
AppDebugLog.Error("ApiClient", "Пустой токен в ответе логина", emptyTokenEx);
throw emptyTokenEx;
}
_token = loginResponse.Token;
AppDebugLog.Info("ApiClient", $"Авторизация успешна. token={AppDebugLog.MaskToken(_token)}");
return _token;
}
catch (HttpRequestException)
{
throw;
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать ответ логина", ex);
throw new HttpRequestException($"Некорректный ответ сервера при авторизации: {ex.Message}", ex);
}
}
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
var error = TryDeserializeError(responseBody);
var message = $"Ошибка авторизации: {error?.Error ?? "Неверные учетные данные"}";
AppDebugLog.ApiHttpError("POST", BuildFullUrl("/auth/login"), (int)response.StatusCode, responseBody, "login");
var unauthorized = new UnauthorizedAccessException(message);
AppDebugLog.Error("ApiClient", message, unauthorized);
throw unauthorized;
}
var httpError = TryDeserializeError(responseBody);
var httpMessage = $"Ошибка HTTP {response.StatusCode}: {httpError?.Error ?? responseBody}";
AppDebugLog.ApiHttpError("POST", BuildFullUrl("/auth/login"), (int)response.StatusCode, responseBody, "login");
var httpEx = new HttpRequestException(httpMessage);
AppDebugLog.Error("ApiClient", httpMessage, httpEx);
throw httpEx;
}
private string BuildFullUrl(string path)
{
return new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
}
private static ErrorResponse TryDeserializeError(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
try
{
return JsonSerializer.Deserialize<ErrorResponse>(body, JsonOptions);
}
catch
{
return null;
}
}
private HttpRequestException WrapNetworkError(Exception ex, string operation)
{
var details = ex.InnerException != null && ex.InnerException.Message != ex.Message
? $" ({ex.InnerException.Message})"
: string.Empty;
return new HttpRequestException(
$"Не удалось выполнить {operation}. Сервер: {_baseUrl}{details}. {ex.Message}",
ex);
}
}
public sealed class InvoiceApiItem
{
public string InvoiceNumber { get; set; }
public string InvoiceDate { get; set; }
public string SupplierName { get; set; }
public string ConsigneeName { get; set; }
public string InvoiceSum { get; set; }
public string RefuseSum { get; set; }
public string SourceOrderId { get; set; }
public List<InvoiceApiOrderItem> Items { get; set; } = new List<InvoiceApiOrderItem>();
public static InvoiceApiItem FromBuyerOrder(BuyerOrderDetailDto detail)
{
var supplierNames = detail.Items == null
? string.Empty
: string.Join(", ", detail.Items
.Select(i => i.Supplier)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct());
var invoiceDate = detail.PlacedAt ?? detail.CreatedAt;
var invoice = new InvoiceApiItem
{
InvoiceNumber = string.IsNullOrWhiteSpace(detail.LocalOrderNumber)
? detail.OrderID
: detail.LocalOrderNumber,
InvoiceDate = invoiceDate?.ToString("yyyy-MM-dd") ?? string.Empty,
SupplierName = supplierNames,
ConsigneeName = detail.LocationAddress ?? string.Empty,
InvoiceSum = (detail.TotalAmount ?? 0m).ToString(System.Globalization.CultureInfo.InvariantCulture),
RefuseSum = "0",
SourceOrderId = detail.OrderID
};
if (detail.Items != null)
{
foreach (var item in detail.Items)
{
invoice.Items.Add(new InvoiceApiOrderItem
{
GoodCode = item.ItemCode ?? string.Empty,
GoodName = item.Name ?? string.Empty,
SupplierName = item.Supplier ?? string.Empty,
Qty = item.Qty,
Price = item.UnitPrice,
Total = item.Total
});
}
}
return invoice;
}
}
public sealed class InvoiceApiOrderItem
{
public string GoodCode { get; set; }
public string GoodName { get; set; }
public string SupplierName { get; set; }
public decimal Qty { get; set; }
public decimal Price { get; set; }
public decimal Total { get; set; }
}
public sealed class BuyerOrdersResponse
{
[JsonPropertyName("orders")]
public List<BuyerOrderListItemDto> Orders { get; set; }
}
public sealed class BuyerOrderListItemDto
{
[JsonPropertyName("order_id")]
public string OrderID { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
}
public sealed class BuyerOrderDetailDto
{
[JsonPropertyName("order_id")]
public string OrderID { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("total_amount")]
public decimal? TotalAmount { get; set; }
[JsonPropertyName("created_at")]
public DateTime? CreatedAt { get; set; }
[JsonPropertyName("placed_at")]
public DateTime? PlacedAt { get; set; }
[JsonPropertyName("comment")]
public string Comment { get; set; }
[JsonPropertyName("location_address")]
public string LocationAddress { get; set; }
[JsonPropertyName("items")]
public List<BuyerOrderItemDetailDto> Items { get; set; }
[JsonIgnore]
public string LocalOrderNumber { get; set; }
}
public sealed class BuyerOrderItemDetailDto
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("supplier")]
public string Supplier { get; set; }
[JsonPropertyName("qty")]
public decimal Qty { get; set; }
[JsonPropertyName("unit_price")]
public decimal UnitPrice { get; set; }
[JsonPropertyName("total")]
public decimal Total { get; set; }
[JsonPropertyName("item_code")]
public string ItemCode { 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));
}
}
}
}