elfisa-pharmacy/src/Elfisa.Core/ApiClient.cs
Exest 436bbb8bfb версия 2.0.3: запоминание входа, быстрая загрузка прайса, повтор при SSL
- Session: сохранение/восстановление сессии между запусками (JWT exp-проверка)
- LoadPrice: страница 20000 вместо 500 — прайс грузится одним запросом
- ApiClient: повтор запроса на транзиентных сбоях TLS (поиск/загрузка)
- download-страница: реальный размер, macOS помечен «скоро»

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 09:23:29 +03:00

219 lines
11 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.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Elfisa.Core;
/// <summary>
/// Клиент платформы. Портирован из WinForms-версии, оставлены методы,
/// нужные новому Avalonia-клиенту: логин, отчёт покупателя, ИИ-отчёт.
/// Остальные эндпоинты добавляются по мере портирования экранов.
/// </summary>
public sealed class ApiClient
{
private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = null };
private readonly HttpClient _http;
private readonly string _baseUrl;
private string? _token;
public ApiClient(string? baseUrl = null)
{
_baseUrl = (baseUrl ?? AppConfig.ApiBaseUrl).TrimEnd('/');
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
}
public string BaseUrl => _baseUrl;
public string? Token => _token;
public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token;
private string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
/// <summary>Логин. Возвращает (token, role) либо бросает с понятным сообщением.</summary>
public async Task<LoginResponse> LoginAsync(string username, string password)
{
var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json);
HttpResponseMessage resp;
try
{
resp = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
}).ConfigureAwait(false);
}
catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); }
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new ApiException("Неверный логин или пароль.");
if (resp.StatusCode == (HttpStatusCode)429)
throw new ApiException("Слишком много запросов. Подождите немного и повторите.");
if (!resp.IsSuccessStatusCode)
throw new ApiException(TryError(text) ?? $"Ошибка авторизации ({(int)resp.StatusCode}).");
var login = JsonSerializer.Deserialize<LoginResponse>(text, Json);
if (login is null || string.IsNullOrWhiteSpace(login.Token))
throw new ApiException("Сервер вернул пустой токен.");
_token = login.Token;
return login;
}
/// <summary>Отчёт покупателя за период (скоуп по JWT на сервере — только свои заказы).</summary>
public async Task<List<BuyerReportLineDto>> GetBuyerReportAsync(DateTime from, DateTime to)
{
EnsureAuth();
var path = $"/api/buyer/report?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
var text = await AuthorizedGetAsync(path, "формирование отчёта").ConfigureAwait(false);
var resp = JsonSerializer.Deserialize<BuyerReportResponse>(text, Json);
return resp?.Lines ?? new List<BuyerReportLineDto>();
}
/// <summary>Свободный запрос к ИИ -> готовый Word/Excel (сервис на Mac mini через /ai).</summary>
public async Task<AiReportResult> GenerateAiReportAsync(string prompt, string format = "docx")
{
EnsureAuth();
if (string.IsNullOrWhiteSpace(prompt)) throw new ApiException("Пустой запрос для ИИ.");
var aiUrl = new Uri(new Uri(AppConfig.AiBaseUrl.TrimEnd('/') + "/"), "generate").ToString();
var body = JsonSerializer.Serialize(new AiReportRequest { Prompt = prompt, Token = _token!, Format = format }, Json);
using var req = new HttpRequestMessage(HttpMethod.Post, aiUrl)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
HttpResponseMessage resp;
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
catch (Exception ex) { throw new ApiException($"ИИ-сервис недоступен: {ex.Message}", ex); }
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
throw new ApiException($"ИИ-сервис вернул {(int)resp.StatusCode}: {TryError(err) ?? err}");
}
var bytes = await resp.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
var name = resp.Content.Headers.ContentDisposition?.FileNameStar
?? resp.Content.Headers.ContentDisposition?.FileName
?? (format == "xlsx" ? "Отчёт.xlsx" : "Отчёт.docx");
return new AiReportResult { Content = bytes, FileName = name.Trim('"') };
}
/// <summary>Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов).</summary>
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string? q = null, int limit = 200, int offset = 0, string? priceListId = null)
{
EnsureAuth();
var path = $"/api/supplier-prices/summary?limit={limit}&offset={offset}";
if (!string.IsNullOrWhiteSpace(q) && q.Trim().Length >= 2)
path += "&q=" + Uri.EscapeDataString(q.Trim());
if (!string.IsNullOrWhiteSpace(priceListId))
path += "&price_list_id=" + Uri.EscapeDataString(priceListId);
var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false);
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
}
/// <summary>Прайс-листы, подключённые текущему покупателю (для выбора «Мои прайсы»).</summary>
public async Task<List<BuyerPriceListDto>> GetBuyerPriceListsAsync()
{
EnsureAuth();
var text = await AuthorizedGetAsync("/api/buyer/price-lists", "загрузку прайсов").ConfigureAwait(false);
try { return JsonSerializer.Deserialize<List<BuyerPriceListDto>>(text, Json) ?? new(); }
catch { return new(); }
}
/// <summary>Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика.</summary>
public async Task<BuyerOrderResponse> CreateOrderAsync(BuyerOrderCreateRequest request)
{
EnsureAuth();
if (request.Items.Count == 0) throw new ApiException("Корзина пуста.");
var body = JsonSerializer.Serialize(request, Json);
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/api/buyer/orders"))
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
HttpResponseMessage resp;
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
catch (Exception ex) { throw new ApiException($"Не удалось отправить заказ: {ex.Message}", ex); }
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка создания заказа ({(int)resp.StatusCode}).");
return JsonSerializer.Deserialize<BuyerOrderResponse>(text, Json) ?? new BuyerOrderResponse();
}
/// <summary>Резервирует следующий номер заказа EX-####### (как в WinForms до создания заказа).</summary>
public async Task<string?> NextGlobalSignAsync()
{
EnsureAuth();
var text = await AuthorizedGetAsync("/api/buyer/global-sign/next", "резервирование номера").ConfigureAwait(false);
try
{
using var doc = JsonDocument.Parse(text);
return doc.RootElement.TryGetProperty("global_sign", out var v) ? v.GetString() : null;
}
catch { return null; }
}
/// <summary>Адреса-грузополучатели покупателя.</summary>
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
{
EnsureAuth();
var text = await AuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей").ConfigureAwait(false);
try { return JsonSerializer.Deserialize<List<BuyerLocationDto>>(text, Json) ?? new(); }
catch { return new(); }
}
private async Task<string> AuthorizedGetAsync(string path, string op)
{
HttpResponseMessage resp;
try
{
resp = await SendWithRetryAsync(() =>
{
var r = new HttpRequestMessage(HttpMethod.Get, Url(path));
r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
return r;
}).ConfigureAwait(false);
}
catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); }
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка сервера ({(int)resp.StatusCode}) при {op}.");
return text;
}
/// <summary>Отправляет запрос с повтором на транзиентных сбоях (холодный TLS,
/// сброс соединения, hairpin-NAT) — чтобы поиск/загрузка не падали с первого раза.</summary>
private async Task<HttpResponseMessage> SendWithRetryAsync(Func<HttpRequestMessage> make, int retries = 2)
{
Exception? last = null;
for (int attempt = 0; attempt <= retries; attempt++)
{
using var req = make();
try { return await _http.SendAsync(req).ConfigureAwait(false); }
catch (HttpRequestException ex) { last = ex; } // сеть/TLS — повторяем
if (attempt < retries) await Task.Delay(250 * (attempt + 1)).ConfigureAwait(false);
}
throw last!;
}
private void EnsureAuth()
{
if (!IsAuthenticated) throw new ApiException("Требуется авторизация.");
}
private static string? TryError(string body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
try { return JsonSerializer.Deserialize<ErrorResponse>(body, Json)?.Error; }
catch { return null; }
}
}
public sealed class ApiException : Exception
{
public ApiException(string message, Exception? inner = null) : base(message, inner) { }
}