- Прайс-лист: поиск + грид на рабочем /api/supplier-prices/summary (наименование, форма, производитель, поставщик, цена) - Накладные: реестр приходных документов (из buyer/report) - Справочник: профиль покупателя + грузополучатели (buyer/locations) - Core: GetPriceSummaryAsync, GetBuyerLocationsAsync + DTO - README: статус разделов + примечание про buyer/orders 500 (фикс застейджен) Рабочие: Прайс-лист, Заказы, Накладные, Отчёты, Справочник. Осталось: Отправить (корзина+DBF), Отказы (нужен эндпоинт), автообновление. Собирается; рантайм новых 3 разделов не проверен (клиент за игрой — окна не поднимал). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
151 lines
7.7 KiB
C#
151 lines
7.7 KiB
C#
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);
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
|
||
{
|
||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||
};
|
||
HttpResponseMessage resp;
|
||
try { resp = await _http.SendAsync(req).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)
|
||
{
|
||
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());
|
||
var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false);
|
||
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
using var req = new HttpRequestMessage(HttpMethod.Get, Url(path));
|
||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
||
HttpResponseMessage resp;
|
||
try { resp = await _http.SendAsync(req).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;
|
||
}
|
||
|
||
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) { }
|
||
}
|