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
РЕЕСТР ТОВАРОВ (глобальный системный шаблон карточек):
- Домен 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>
289 lines
15 KiB
C#
289 lines
15 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;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|
||
}
|