Endpoints: - GET /api/pos/v1/sync?since=ISO&storeId=Guid - выгрузка изменений (Products / Prices / Stocks / Counterparties) после reference time; Stocks - всегда полный снимок на момент ответа (POS нужен актуальный остаток на полке). - POST /api/pos/v1/sales - батч продаж с idempotency. Двойная идемпотентность: 1. Batch-level: PosBatchAck (новая таблица, unique idx по OrgId+Key) - повтор того же батча возвращает кешированный ответ. При параллельном race ловим 23505 на уникальном индексе и тоже возвращаем кеш. 2. Per-sale: ClientSaleId записывается в RetailSale.Notes как prefix "pos:GUID32". Перед созданием продажи проверяем что такой маркер ещё не встречался - если есть, возвращаем существующую продажу. Это спасает и при разных batch-ключах с пересекающимися ClientSaleId. Pre-flight: проверка остатка ДО создания черновика - sale, которая не влезает в полку, попадает в Failed, остальные в батче проводятся. Domain: PosBatchAck (TenantEntity), миграция Phase7a_PosBatchAcks (jsonb для ResponseJson, unique idx). Контракты v1 из food-market.shared. Тесты: 7 интеграционных - полная sync, дельта по since, POST батч списывает stock, replay того же батча no-duplicates, ClientSaleId через разные batch-keys тоже no-duplicates, недостача попадает в Failed, tenant-изоляция. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
238 lines
12 KiB
C#
238 lines
12 KiB
C#
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
using FluentAssertions;
|
||
using foodmarket.IntegrationTests.Support;
|
||
using foodmarket.Shared.Pos.V1;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.IntegrationTests;
|
||
|
||
[Collection(ApiCollection.Name)]
|
||
public class PosSyncTests
|
||
{
|
||
private readonly ApiFactory _factory;
|
||
public PosSyncTests(ApiFactory factory) => _factory = factory;
|
||
private static string RandomBarcode()
|
||
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||
|
||
[Fact]
|
||
public async Task Sync_returns_products_prices_stocks()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"pos-sync-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 250m, RandomBarcode());
|
||
|
||
// Подкормим остаток через Enter, чтобы было что синхронизировать.
|
||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
notes = "seed", lines = new[] { new { productId = p1, quantity = 5m, unitCost = 100m } },
|
||
});
|
||
enter.EnsureSuccessStatusCode();
|
||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||
|
||
// POS sync без since → полная выгрузка.
|
||
using var resp = await api.Http.GetAsync("/api/pos/v1/sync");
|
||
resp.EnsureSuccessStatusCode();
|
||
var sync = await resp.Content.ReadFromJsonAsync<PosSyncResponse>();
|
||
sync!.Products.Should().Contain(p => p.Id.ToString() == p1);
|
||
sync.Prices.Should().Contain(pr => pr.ProductId.ToString() == p1 && pr.Amount == 250m);
|
||
sync.Stocks.Should().Contain(s => s.ProductId.ToString() == p1 && s.Quantity == 5m);
|
||
sync.ServerTime.Should().BeBefore(DateTime.UtcNow.AddSeconds(5));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Sync_with_since_returns_only_delta()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"pos-delta-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var pOld = await api.CreateProductAsync(refs, $"OLD-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
// Маркер времени между двумя продуктами.
|
||
await Task.Delay(1100);
|
||
var marker = DateTime.UtcNow;
|
||
await Task.Delay(1100);
|
||
|
||
var pNew = await api.CreateProductAsync(refs, $"NEW-{Guid.NewGuid():N}", 200m, RandomBarcode());
|
||
|
||
using var resp = await api.Http.GetAsync($"/api/pos/v1/sync?since={Uri.EscapeDataString(marker.ToString("o"))}");
|
||
resp.EnsureSuccessStatusCode();
|
||
var sync = await resp.Content.ReadFromJsonAsync<PosSyncResponse>();
|
||
sync!.Products.Should().Contain(p => p.Id.ToString() == pNew);
|
||
sync.Products.Should().NotContain(p => p.Id.ToString() == pOld);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Post_sales_batch_creates_retail_sales_and_decrements_stock()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"pos-sale-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
notes = "seed", lines = new[] { new { productId = p1, quantity = 20m, unitCost = 50m } },
|
||
});
|
||
enter.EnsureSuccessStatusCode();
|
||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(20m);
|
||
|
||
var batch = new PosSaleBatchDto
|
||
{
|
||
IdempotencyKey = Guid.NewGuid(),
|
||
Sales = new[]
|
||
{
|
||
MakeSale(p1, qty: 2m, price: 100m),
|
||
MakeSale(p1, qty: 3m, price: 100m),
|
||
MakeSale(p1, qty: 1m, price: 100m),
|
||
MakeSale(p1, qty: 4m, price: 100m),
|
||
MakeSale(p1, qty: 1m, price: 100m),
|
||
},
|
||
};
|
||
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
|
||
post.IsSuccessStatusCode.Should().BeTrue($"POST вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
|
||
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||
resp!.Accepted.Should().HaveCount(5);
|
||
resp.Failed.Should().BeEmpty();
|
||
resp.ReplayedFromCache.Should().BeFalse();
|
||
|
||
// 20 − (2+3+1+4+1) = 9 на складе.
|
||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m);
|
||
}
|
||
|
||
/// <summary>Ключевой тест задачи: повтор того же батча → no duplicates,
|
||
/// тот же ответ, остаток не уменьшается дважды.</summary>
|
||
[Fact]
|
||
public async Task Replay_same_batch_returns_cached_response_no_duplicates()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"pos-idem-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
notes = "seed", lines = new[] { new { productId = p1, quantity = 10m, unitCost = 50m } },
|
||
});
|
||
enter.EnsureSuccessStatusCode();
|
||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||
|
||
var batch = new PosSaleBatchDto
|
||
{
|
||
IdempotencyKey = Guid.NewGuid(),
|
||
Sales = new[]
|
||
{
|
||
MakeSale(p1, qty: 3m, price: 100m),
|
||
MakeSale(p1, qty: 2m, price: 100m),
|
||
},
|
||
};
|
||
|
||
using var first = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
|
||
first.EnsureSuccessStatusCode();
|
||
var r1 = await first.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||
r1!.Accepted.Should().HaveCount(2);
|
||
r1.ReplayedFromCache.Should().BeFalse();
|
||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m);
|
||
|
||
using var second = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
|
||
second.EnsureSuccessStatusCode();
|
||
var r2 = await second.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||
r2!.Accepted.Should().HaveCount(2);
|
||
r2.ReplayedFromCache.Should().BeTrue();
|
||
// ServerSaleId должны совпасть — это тот же чек, что и в первом ответе.
|
||
r2.Accepted.Select(a => a.ServerSaleId).Should().BeEquivalentTo(r1.Accepted.Select(a => a.ServerSaleId));
|
||
|
||
// Stock не списался дважды.
|
||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m);
|
||
}
|
||
|
||
/// <summary>Per-sale ClientSaleId идемпотентность: если ту же продажу
|
||
/// прислали в РАЗНЫХ батчах (с разными idempotency-key'ями), сервер всё
|
||
/// равно вернёт ссылку на ранее созданный чек, не создавая дубль.</summary>
|
||
[Fact]
|
||
public async Task ClientSaleId_idempotency_across_batches()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"pos-csid-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
notes = "seed", lines = new[] { new { productId = p1, quantity = 10m, unitCost = 50m } },
|
||
});
|
||
enter.EnsureSuccessStatusCode();
|
||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||
|
||
var sharedSale = MakeSale(p1, qty: 4m, price: 100m);
|
||
var batch1 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } };
|
||
var batch2 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } };
|
||
|
||
var r1 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch1)).Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||
var r2 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch2)).Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||
r1!.Accepted.Should().HaveCount(1);
|
||
r2!.Accepted.Should().HaveCount(1);
|
||
r2.Accepted[0].ServerSaleId.Should().Be(r1.Accepted[0].ServerSaleId);
|
||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(6m, "товар списался 1 раз");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Insufficient_stock_goes_to_failed_not_accepted()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"pos-short-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
// Не подкармливаем — остаток 0.
|
||
|
||
var batch = new PosSaleBatchDto
|
||
{
|
||
IdempotencyKey = Guid.NewGuid(),
|
||
Sales = new[] { MakeSale(p1, qty: 3m, price: 100m) },
|
||
};
|
||
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
|
||
post.EnsureSuccessStatusCode();
|
||
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||
resp!.Accepted.Should().BeEmpty();
|
||
resp.Failed.Should().HaveCount(1);
|
||
resp.Failed[0].Error.Should().Contain("Недостаточный остаток");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Tenant_isolation_pos_sync()
|
||
{
|
||
var a = new ApiActor(_factory.CreateClient());
|
||
var b = new ApiActor(_factory.CreateClient());
|
||
await a.SignupAndLoginAsync($"pos-iso-a-{Guid.NewGuid():N}");
|
||
await b.SignupAndLoginAsync($"pos-iso-b-{Guid.NewGuid():N}");
|
||
var refsA = await a.LoadRefsAsync();
|
||
var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
var syncB = await (await b.Http.GetAsync("/api/pos/v1/sync")).Content.ReadFromJsonAsync<PosSyncResponse>();
|
||
syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA);
|
||
}
|
||
|
||
private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new()
|
||
{
|
||
ClientSaleId = Guid.NewGuid(),
|
||
OccurredAt = DateTime.UtcNow,
|
||
Payment = 0, PaidCash = qty * price, PaidCard = 0m,
|
||
Lines = new[]
|
||
{
|
||
new PosSaleLineDto
|
||
{
|
||
ProductId = Guid.Parse(productId), Quantity = qty,
|
||
UnitPrice = price, Discount = 0m, VatPercent = 12m,
|
||
},
|
||
},
|
||
};
|
||
}
|