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) { }
}