using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace Elfisa.Core; /// /// Клиент платформы. Портирован из WinForms-версии, оставлены методы, /// нужные новому Avalonia-клиенту: логин, отчёт покупателя, ИИ-отчёт. /// Остальные эндпоинты добавляются по мере портирования экранов. /// 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(); /// Логин. Возвращает (token, role) либо бросает с понятным сообщением. public async Task 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(text, Json); if (login is null || string.IsNullOrWhiteSpace(login.Token)) throw new ApiException("Сервер вернул пустой токен."); _token = login.Token; return login; } /// Отчёт покупателя за период (скоуп по JWT на сервере — только свои заказы). public async Task> 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(text, Json); return resp?.Lines ?? new List(); } /// Свободный запрос к ИИ -> готовый Word/Excel (сервис на Mac mini через /ai). public async Task 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('"') }; } /// Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов). public async Task 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(text, Json) ?? new PriceSummaryResponse(); } /// Прайс-листы, подключённые текущему покупателю (для выбора «Мои прайсы»). public async Task> GetBuyerPriceListsAsync() { EnsureAuth(); var text = await AuthorizedGetAsync("/api/buyer/price-lists", "загрузку прайсов").ConfigureAwait(false); try { return JsonSerializer.Deserialize>(text, Json) ?? new(); } catch { return new(); } } /// Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика. public async Task 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(text, Json) ?? new BuyerOrderResponse(); } /// Резервирует следующий номер заказа EX-####### (как в WinForms до создания заказа). public async Task 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; } } /// Адреса-грузополучатели покупателя. public async Task> GetBuyerLocationsAsync() { EnsureAuth(); var text = await AuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей").ConfigureAwait(false); try { return JsonSerializer.Deserialize>(text, Json) ?? new(); } catch { return new(); } } private async Task 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; } /// Отправляет запрос с повтором на транзиентных сбоях (холодный TLS, /// сброс соединения, hairpin-NAT) — чтобы поиск/загрузка не падали с первого раза. private async Task SendWithRetryAsync(Func 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(body, Json)?.Error; } catch { return null; } } } public sealed class ApiException : Exception { public ApiException(string message, Exception? inner = null) : base(message, inner) { } }