food-market/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs
nurdotnet 50e3676d71 phase2a: stock foundation (Stock + StockMovement) + MoySklad counterparty import
Domain:
- foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store)
  with Quantity, ReservedQuantity, computed Available. Unique index on tenant+
  product+store.
- foodmarket.Domain.Inventory.StockMovement — append-only journal with signed
  quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale,
  WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn,
  WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number),
  OccurredAt, CreatedBy, Notes.

Application:
- IStockService.ApplyMovementAsync draft — appends movement row + upserts
  materialized Stock row in the same unit of work. Callers control SaveChanges
  so a posting doc can bundle all lines atomically.

Infrastructure:
- StockService implementation over AppDbContext.
- InventoryConfigurations EF mapping (precision 18,4 on quantities/costs;
  indexes for product+time, store+time, document lookup).
- Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements).

API (GET, read-only for now):
- /api/inventory/stock — filter by store, product, includeZero; joins product +
  unit + store names; server-side pagination.
- /api/inventory/movements — journal filtered by store/product/date range;
  movement type as string enum for UI labels.
- Both [Authorize] (any authenticated user).

MoySklad:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- MoySkladClient.StreamCounterpartiesAsync — paginated like products.
- MoySkladImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier /
  customer / both), companyType → LegalEntity/Individual; dedup by Name;
  defensive trim on all string fields; per-item try/catch; batches of 500.
- /api/admin/moysklad/import-counterparties endpoint (Admin policy).

Web:
- /inventory/stock list page (store filter, include-zero toggle, search; shows
  quantity/reserved/available with red-on-negative, grey-on-zero accents).
- /inventory/movements list page (store filter; colored quantity +/-, Russian
  labels for each movement type).
- MoySklad import page restructured: single token test + two import buttons
  (Товары, Контрагенты) + reusable ImportResult panel that handles both.
- Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History.

Uses the ListPageShell pattern introduced in d3aa13d — sticky top bar, sticky
table header, only the body scrolls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:51:07 +05:00

123 lines
5.4 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);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", 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);
}
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
var offset = 0;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProduct>>(Json, ct);
if (page is null || page.Rows.Count == 0) yield break;
foreach (var p in page.Rows) yield return p;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
}
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
var offset = 0;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/counterparty?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsCounterparty>>(Json, ct);
if (page is null || page.Rows.Count == 0) yield break;
foreach (var c in page.Rows) yield return c;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
}
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{
var all = new List<MsProductFolder>();
var offset = 0;
const int pageSize = 1000;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProductFolder>>(Json, ct);
if (page is null || page.Rows.Count == 0) break;
all.AddRange(page.Rows);
if (page.Rows.Count < pageSize) break;
offset += pageSize;
}
return all;
}
}