food-market/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs
nns e39e4fe1d5
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(registry+pricing): «Реестр товаров» + схема цен (закупочная/себестоимость/продажная)
РЕЕСТР ТОВАРОВ (глобальный системный шаблон карточек):
- Домен ProductRegistry{Group,Card,Barcode} (Entity, без OrganizationId),
  миграция Phase47; штрихкод уникален в реестре (ключ новизны).
- Наполнение «из МойСклад заново» (команда суперадмина, повторяемая): читает
  entity/product (архивные/модификации/комплекты/услуги — считает и пропускает),
  группы из productFolder (путь), штрихкоды всех типов; buyPrice→закупочная+
  себестоимость, salePrices→продажная; пересоздаёт только MoySklad-карточки,
  Shop-карточки не трогает. МойСклад только чтение (пагинация 1000, 429-бэкофф).
- Фоновое пополнение из каталогов магазинов-участников (recurring, флаг в
  PlatformSettings, по умолчанию ВЫКЛ): новые штрихкоды → карточки; фильтр
  мусора (неверная контр. цифра EAN-13/8, пустое имя); существующие не трогает.
- Копия реестра в каталог нового магазина при регистрации — фоновым заданием
  (Hangfire) сразу после signup; баннер «Каталог загружается…», повторно не
  копирует. Остатков нет (0).
- Флаг организации ContributesToRegistry (демо/автотест — ВЫКЛ миграцией).
- SuperAdmin раздел «Реестр товаров»: поиск/правка/удаление карточек, тоггл
  фонового пополнения (причина+аудит), кнопка наполнения из МойСклад, флаги
  участия орг. Только SuperAdmin.

СХЕМА ЦЕН (решение владельца 12.10): три цены в карточке.
- Закупочная: ReferencePrice→PurchasePrice (миграция Phase48 RENAME COLUMN,
  данные сохранены). Каждая проведённая приёмка пишет закупочную = цена строки;
  unpost возвращает из предыдущей проведённой приёмки. API отдаёт purchasePrice
  и временно referencePrice (алиас для iOS).
- Себестоимость (Cost): только чтение, скользящее среднее (как было).
- Продажная (бывш. «розничная»): меняется приёмкой только при ручной правке в
  строке. Системный тип цены «Розничная цена»→«Продажная цена» (миграция+сидер),
  подписи в UI переименованы.
- Убраны: автозаполнение эталонной при первой приёмке, ReferencePriceRefreshJob
  (30-дневная перезапись), понятие «эталонная» в UI.

Тесты: SupplyPricingSchemeTests (приёмка без правок → продажная не меняется,
закупочная = цена строки, себестоимость средняя; правка продажной в строке;
unpost → закупочная вернулась) — 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 19:19:47 +05:00

289 lines
15 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;
}
/// <summary>report/money/byaccount — текущие остатки по деньгам (наличные + банк. счета),
/// для сверки балансов счетов при переносе. Только чтение. null при ошибке/запрете.</summary>
public async Task<List<MsMoneyByAccountRow>?> 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<MsListResponse<MsMoneyByAccountRow>>(Json, ct);
return list?.Rows;
}
/// <summary>report/stock/all — текущие остатки по товарам (msId → количество).
/// НЕ отдаёт нулевые. Постранично (limit 1000). Для выравнивания остатков при переносе.</summary>
public async Task<Dictionary<string, decimal>> GetStockAllAsync(string token, CancellationToken ct)
{
var res = new Dictionary<string, decimal>();
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<MsListResponse<MsStockRow>>(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<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;
}
/// <summary>Сколько сущностей данного типа в МойСклад (meta.size). Для
/// отчёта «сколько модификаций/комплектов/услуг пропущено» (мы их не берём).
/// Только чтение; null при ошибке/запрете.</summary>
public async Task<int?> 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<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;
// Весь filter-expression надо экранировать целиком (включая ">="), иначе МойСклад 400.
var filter = updatedFromMsk is null ? "" : $"&filter={Uri.EscapeDataString($"updated>={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;
}
}
}