food-market/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs
nns 2ed9f04cb2 feat(migration): документный мигратор МойСклад (приёмки) + externalId на документах
Part 2 (документы) — проверенный шаблон на приёмках:
- Phase41: MoySkladExternalId у counterparties/stores/units/retail_points/employees и
  документов (supplies/enters/losses/inventories/supplier_returns/retail_sales/
  cash_shifts/money_transactions) + индексы. Свойства на доменных сущностях.
- MoySkladClient.StreamDocumentsAsync: документы в хронологии (order=moment,asc), expand
  позиций/связей (limit=100), обработка 429/сбоев.
- MoySkladDocumentImportService.ImportSuppliesAsync: идемпотентно по msId, резюмируемо
  (skip перенесённых), moment МСК→UTC, суммы из тиынов, поставщик по externalId контрагента,
  товары по externalId, проведение → движения остатков (StockService). Журнал ошибок.
- Импорт контрагентов теперь пишет MoySkladExternalId (нужно для ссылок в документах).
- Эндпоинт POST /api/admin/moysklad/import-supplies (фон, job). Остальные типы — по шаблону.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:53:52 +05:00

234 lines
12 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.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
public record MoySkladApiResult<T>(bool Success, T? Value, int? StatusCode, string? Error)
{
public static MoySkladApiResult<T> Ok(T value) => new(true, value, 200, null);
public static MoySkladApiResult<T> 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 <base64(login:password)> — пароль пользователя кабинета
// - Bearer <access_token> — токен из «Сервис → Настройки → 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;
}
public async Task<MoySkladApiResult<MsOrganization>> 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<MsOrganization>.Fail((int)res.StatusCode, body);
}
var list = await res.Content.ReadFromJsonAsync<MsListResponse<MsOrganization>>(Json, ct);
var org = list?.Rows.FirstOrDefault();
return org is null
? MoySkladApiResult<MsOrganization>.Fail(200, "Empty organization list returned by MoySklad.")
: MoySkladApiResult<MsOrganization>.Ok(org);
}
// MoySklad list endpoints по умолчанию возвращают только активных (archived=false).
// Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода:
// сначала активные (default), затем архивные — и отдаём всё одним потоком.
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct,
bool includeArchived = true)
{
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
if (!includeArchived) yield break;
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
}
public async Task<List<MsUom>> GetAllUomsAsync(string token, CancellationToken ct)
{
var all = new List<MsUom>();
await foreach (var u in StreamPagedAsync<MsUom>(token, "entity/uom", archivedOnly: false, ct)) all.Add(u);
return all;
}
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: false, ct)) yield return c;
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
}
/// <summary>Base64(login:password) выглядит как base64url-ish строка
/// без пробелов, при декодировании содержит ':' между логином и
/// паролем. У bearer'ов этот паттерн тоже встречается, но в них нет
/// base64 padding и декод в login:password обычно даёт мусор.</summary>
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<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{
var all = new List<MsProductFolder>();
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f);
await foreach (var f in StreamPagedAsync<MsProductFolder>(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f);
return all;
}
/// <summary>Документы постранично в ХРОНОЛОГИИ (order=moment,asc), с expand
/// связей/позиций (limit=100 — expand работает только при ≤100). Обрабатывает 429
/// (ждёт X-RateLimit-Retry-TimeInterval) и сетевые сбои (повтор с backoff).
/// Опциональный momentFromMsk — фильтр updated>= (для дельты, МСК).</summary>
public async IAsyncEnumerable<T> StreamDocumentsAsync<T>(
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;
var filter = updatedFromMsk is null ? "" : $"&filter=updated>={Uri.EscapeDataString(updatedFromMsk)}";
var exp = string.IsNullOrEmpty(expand) ? "" : $"&expand={expand}";
while (true)
{
MsListResponse<T>? 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<MsListResponse<T>>(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<T> StreamPagedAsync<T>(
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<T>? 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<MsListResponse<T>>(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;
}
}
}