using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; namespace foodmarket.Infrastructure.Integrations.MoySklad; public record MoySkladApiResult(bool Success, T? Value, int? StatusCode, string? Error) { public static MoySkladApiResult Ok(T value) => new(true, value, 200, null); public static MoySkladApiResult Fail(int status, string? error) => new(false, default, status, error); } // Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request // — we never persist it. public class MoySkladClient { // Trailing slash is critical: otherwise HttpClient drops the last path segment // when resolving relative URIs (RFC 3986 §5.3), so "entity/product" would hit // "/api/remap/entity/product" instead of "/api/remap/1.2/entity/product". private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2/"; private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private readonly HttpClient _http; public MoySkladClient(HttpClient http) { _http = http; _http.BaseAddress ??= new Uri(BaseUrl); _http.Timeout = TimeSpan.FromSeconds(90); } private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token) { var req = new HttpRequestMessage(method, pathAndQuery); // МойСклад принимает оба варианта аутентификации: // - Basic — пароль пользователя кабинета // - Bearer — токен из «Сервис → Настройки → API» // Авто-детект: если токен выглядит как base64 (один раз распарсился // в login:password) — используем Basic, иначе Bearer. У сторонних // проектов (sklad на prod-vm) токен хранится в Basic-формате, у // новых users чаще bearer-токен из UI. var scheme = LooksLikeBasicB64(token) ? "Basic" : "Bearer"; req.Headers.Authorization = new AuthenticationHeaderValue(scheme, token); // MoySklad requires the exact literal "application/json;charset=utf-8" (no space // after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to // "application/json; charset=utf-8" which MoySklad rejects with code 1062. req.Headers.TryAddWithoutValidation("Accept", "application/json;charset=utf-8"); // MoySklad's nginx edge returns 415 for requests without a User-Agent, and we want // auto-decompression (Accept-Encoding is added automatically by HttpClient when // AutomaticDecompression is set on the primary handler — see Program.cs). if (!req.Headers.UserAgent.Any()) { req.Headers.TryAddWithoutValidation("User-Agent", "food-market/0.1 (+https://github.com/nurdotnet/food-market)"); } return req; } /// report/money/byaccount — текущие остатки по деньгам (наличные + банк. счета), /// для сверки балансов счетов при переносе. Только чтение. null при ошибке/запрете. public async Task?> GetMoneyByAccountAsync(string token, CancellationToken ct) { using var req = Build(HttpMethod.Get, "report/money/byaccount", token); using var res = await _http.SendAsync(req, ct); if (!res.IsSuccessStatusCode) return null; var list = await res.Content.ReadFromJsonAsync>(Json, ct); return list?.Rows; } /// report/stock/all — текущие остатки по товарам (msId → количество). /// НЕ отдаёт нулевые. Постранично (limit 1000). Для выравнивания остатков при переносе. public async Task> GetStockAllAsync(string token, CancellationToken ct) { var res = new Dictionary(); var offset = 0; while (true) { using var req = Build(HttpMethod.Get, $"report/stock/all?limit=1000&offset={offset}", token); using var r = await _http.SendAsync(req, ct); if (!r.IsSuccessStatusCode) break; var page = await r.Content.ReadFromJsonAsync>(Json, ct); var rows = page?.Rows ?? new(); foreach (var row in rows) { var href = row.Meta?.Href; if (string.IsNullOrEmpty(href)) continue; var id = href.Split('?')[0].TrimEnd('/').Split('/').LastOrDefault(); if (string.IsNullOrEmpty(id)) continue; res[id] = res.GetValueOrDefault(id) + row.Stock; } offset += rows.Count; if (rows.Count < 1000 || offset >= (page?.Meta?.Size ?? 0)) break; } return res; } public async Task> WhoAmIAsync(string token, CancellationToken ct) { using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token); using var res = await _http.SendAsync(req, ct); if (!res.IsSuccessStatusCode) { var body = await res.Content.ReadAsStringAsync(ct); return MoySkladApiResult.Fail((int)res.StatusCode, body); } var list = await res.Content.ReadFromJsonAsync>(Json, ct); var org = list?.Rows.FirstOrDefault(); return org is null ? MoySkladApiResult.Fail(200, "Empty organization list returned by MoySklad.") : MoySkladApiResult.Ok(org); } // MoySklad list endpoints по умолчанию возвращают только активных (archived=false). // Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода: // сначала активные (default), затем архивные — и отдаём всё одним потоком. public async IAsyncEnumerable StreamProductsAsync( string token, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct, bool includeArchived = true) { await foreach (var p in StreamPagedAsync(token, "entity/product", archivedOnly: false, ct)) yield return p; if (!includeArchived) yield break; await foreach (var p in StreamPagedAsync(token, "entity/product", archivedOnly: true, ct)) yield return p; } /// Сколько сущностей данного типа в МойСклад (meta.size). Для /// отчёта «сколько модификаций/комплектов/услуг пропущено» (мы их не берём). /// Только чтение; null при ошибке/запрете. public async Task CountEntityAsync(string token, string entity, CancellationToken ct) { using var req = Build(HttpMethod.Get, $"entity/{entity}?limit=1", token); using var res = await _http.SendAsync(req, ct); if (!res.IsSuccessStatusCode) return null; await using var stream = await res.Content.ReadAsStreamAsync(ct); using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct); if (doc.RootElement.TryGetProperty("meta", out var meta) && meta.TryGetProperty("size", out var size) && size.TryGetInt32(out var n)) return n; return null; } public async Task> GetAllUomsAsync(string token, CancellationToken ct) { var all = new List(); await foreach (var u in StreamPagedAsync(token, "entity/uom", archivedOnly: false, ct)) all.Add(u); return all; } public async IAsyncEnumerable StreamCounterpartiesAsync( string token, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { await foreach (var c in StreamPagedAsync(token, "entity/counterparty", archivedOnly: false, ct)) yield return c; await foreach (var c in StreamPagedAsync(token, "entity/counterparty", archivedOnly: true, ct)) yield return c; } /// Base64(login:password) выглядит как base64url-ish строка /// без пробелов, при декодировании содержит ':' между логином и /// паролем. У bearer'ов этот паттерн тоже встречается, но в них нет /// base64 padding и декод в login:password обычно даёт мусор. private static bool LooksLikeBasicB64(string token) { if (string.IsNullOrEmpty(token)) return false; // Длина 36 чаще всего — base64(login:password) ~ 24..40 символов. // Проверяем валидный base64 + наличие ':' после декода. try { var padded = token; // Padding, если потеряли. var remainder = padded.Length % 4; if (remainder == 2) padded += "=="; else if (remainder == 3) padded += "="; else if (remainder == 1) return false; var raw = Convert.FromBase64String(padded); var text = System.Text.Encoding.UTF8.GetString(raw); return text.Contains(':') && !text.Any(c => c is < (char)0x20 and not '\t'); } catch { return false; } } public async Task> GetAllFoldersAsync(string token, CancellationToken ct) { var all = new List(); await foreach (var f in StreamPagedAsync(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f); await foreach (var f in StreamPagedAsync(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f); return all; } /// Документы постранично в ХРОНОЛОГИИ (order=moment,asc), с expand /// связей/позиций (limit=100 — expand работает только при ≤100). Обрабатывает 429 /// (ждёт X-RateLimit-Retry-TimeInterval) и сетевые сбои (повтор с backoff). /// Опциональный momentFromMsk — фильтр updated>= (для дельты, МСК). public async IAsyncEnumerable StreamDocumentsAsync( string token, string path, string expand, int startOffset, string? updatedFromMsk, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { const int pageSize = 100; const int maxAttempts = 6; var offset = startOffset; // Весь filter-expression надо экранировать целиком (включая ">="), иначе МойСклад 400. var filter = updatedFromMsk is null ? "" : $"&filter={Uri.EscapeDataString($"updated>={updatedFromMsk}")}"; var exp = string.IsNullOrEmpty(expand) ? "" : $"&expand={expand}"; while (true) { MsListResponse? page = null; Exception? lastErr = null; for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}&order=moment,asc{filter}{exp}", token); using var res = await _http.SendAsync(req, ct); if ((int)res.StatusCode == 429) { var ms = int.TryParse(res.Headers.TryGetValues("X-RateLimit-Retry-TimeInterval", out var v) ? System.Linq.Enumerable.FirstOrDefault(v) : null, out var w) ? w : 2000; await Task.Delay(ms + 300, ct); continue; } res.EnsureSuccessStatusCode(); page = await res.Content.ReadFromJsonAsync>(Json, ct); lastErr = null; break; } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException) { lastErr = ex; if (attempt == maxAttempts) break; await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct); } } if (lastErr is not null) throw new InvalidOperationException($"MoySklad docs paging failed at {path} offset={offset}: {lastErr.Message}", lastErr); if (page is null || page.Rows.Count == 0) yield break; foreach (var row in page.Rows) yield return row; if (page.Rows.Count < pageSize) yield break; offset += pageSize; } } private async IAsyncEnumerable StreamPagedAsync( string token, string path, bool archivedOnly, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { const int pageSize = 1000; const int maxAttempts = 5; var offset = 0; var filterSuffix = archivedOnly ? "&filter=archived=true" : ""; while (true) { MsListResponse? page = null; Exception? lastErr = null; for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}{filterSuffix}", token); using var res = await _http.SendAsync(req, ct); res.EnsureSuccessStatusCode(); page = await res.Content.ReadFromJsonAsync>(Json, ct); lastErr = null; break; } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException) { lastErr = ex; if (attempt == maxAttempts) break; // Exponential-ish backoff: 2s, 4s, 8s, 16s. await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct); } } if (lastErr is not null) { // Re-throw after retries so the caller sees a real failure instead of silent halt. throw new InvalidOperationException( $"MoySklad paging failed at {path} offset={offset} after {maxAttempts} attempts: {lastErr.Message}", lastErr); } if (page is null || page.Rows.Count == 0) yield break; foreach (var row in page.Rows) yield return row; if (page.Rows.Count < pageSize) yield break; offset += pageSize; } } }