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>
234 lines
12 KiB
C#
234 lines
12 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|