elfisa-pharmacy/src/Elfisa.Core/ApiClient.cs
Exest 77dfb08ef4 Avalonia: Прайс-лист, Накладные, Справочник (5/7 разделов рабочие)
- Прайс-лист: поиск + грид на рабочем /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>
2026-08-08 10:20:36 +03:00

151 lines
7.7 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);
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) { }
}