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()).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(); 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(); 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()).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(); 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); } /// Ключевой тест задачи: повтор того же батча → no duplicates, /// тот же ответ, остаток не уменьшается дважды. [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()).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(); 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(); 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); } /// Per-sale ClientSaleId идемпотентность: если ту же продажу /// прислали в РАЗНЫХ батчах (с разными idempotency-key'ями), сервер всё /// равно вернёт ссылку на ранее созданный чек, не создавая дубль. [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()).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(); var r2 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch2)).Content.ReadFromJsonAsync(); 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(); 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(); syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA); } /// Чек с кассы, пробитый в открытую смену, должен попасть в Z-отчёт /// смены (MoneyService агрегирует продажи по CashShiftId) — именно эту /// привязку касса теперь шлёт в RetailPointId/CashShiftId. [Fact] public async Task Pos_sale_in_shift_counts_in_close_expected_cash() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-shift-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode()); await SeedStockAsync(api, refs, p1, 10m); var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}"); var cash = await api.CreateMoneyAccountAsync("Касса точки", 1, refs.CurrencyId, refs.StoreId, 0m); var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 5_000m); // Касса пробивает чек на 2×500 наличными, привязывая к смене и точке. var batch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, qty: 2m, price: 500m) with { RetailPointId = Guid.Parse(retailPoint), CashShiftId = Guid.Parse(shiftId), } }, }; using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); post.IsSuccessStatusCode.Should().BeTrue($"{(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); (await post.Content.ReadFromJsonAsync())!.Accepted.Should().HaveCount(1); // Закрываем смену: expected = 5 000 (размен) + 1 000 (продажа нал) = 6 000. using var close = await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 6_000m, notes = (string?)null }); close.EnsureSuccessStatusCode(); var body = await close.Content.ReadFromJsonAsync(); body.GetProperty("expectedCash").GetDecimal().Should().Be(6_000m, "продажа кассы учтена в смене"); body.GetProperty("difference").GetDecimal().Should().Be(0m); } /// Возврат покупателю (IsReturn) возвращает товар на склад (+остаток), /// а не списывает его. [Fact] public async Task Pos_return_adds_stock_back() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-ret-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 300m, RandomBarcode()); await SeedStockAsync(api, refs, p1, 10m); var batch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, qty: 3m, price: 300m) with { IsReturn = true } }, }; using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); post.IsSuccessStatusCode.Should().BeTrue($"{(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}"); (await post.Content.ReadFromJsonAsync())!.Accepted.Should().HaveCount(1); // Был 10, возврат 3 → 13 (возврат добавил остаток). (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(13m); } private static async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty) { 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, quantity = qty, unitCost = 50m } }, }); enter.EnsureSuccessStatusCode(); var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); } private static async Task OpenShiftAsync(ApiActor api, string retailPointId, string cashAccountId, decimal openingFloat) { var resp = await api.Http.PostAsJsonAsync("/api/money/shifts/open", new { retailPointId, cashAccountId, acquiringAccountId = (string?)null, openingFloat, }); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"OpenShift failed {(int)resp.StatusCode}: {body}"); return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!; } /// Возврат по чеку через кассу: lookup by-number отдаёт доступное к /// возврату; POS-возврат уважает остаток (нельзя вернуть больше проданного /// минус уже возвращённого) и инкрементит QtyReturned. [Fact] public async Task Pos_return_by_receipt_respects_returnable_quantity() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-retfull-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); await SeedStockAsync(api, refs, p1, 10m); // Продажа 3 шт через кассу. var saleBatch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, 3m, 100m) } }; var saleResp = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", saleBatch)).Content.ReadFromJsonAsync(); saleResp!.Accepted.Should().HaveCount(1); var refId = saleResp.Accepted[0].ServerSaleId; var number = saleResp.Accepted[0].ServerSaleNumber; (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(7m); // Lookup по номеру → доступно к возврату 3. var look = await api.Http.GetFromJsonAsync($"/api/pos/v1/sales/by-number/{number}"); look!.Lines.Should().ContainSingle(); look.Lines[0].SoldQty.Should().Be(3m); look.Lines[0].ReturnedQty.Should().Be(0m); look.Lines[0].ReturnableQty.Should().Be(3m); // Возврат 2 шт → принят, остаток +2 = 9. var ret1 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, 2m, 100m) with { IsReturn = true, ReferenceSaleId = refId } } }; (await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", ret1)).Content.ReadFromJsonAsync())!.Accepted.Should().HaveCount(1); (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m); // Теперь доступно к возврату 1. var look2 = await api.Http.GetFromJsonAsync($"/api/pos/v1/sales/by-number/{number}"); look2!.Lines[0].ReturnedQty.Should().Be(2m); look2.Lines[0].ReturnableQty.Should().Be(1m); // Попытка вернуть ещё 2 (over-return) → в Failed, остаток не меняется. var retOver = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, 2m, 100m) with { IsReturn = true, ReferenceSaleId = refId } } }; var overResp = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", retOver)).Content.ReadFromJsonAsync(); overResp!.Accepted.Should().BeEmpty(); overResp.Failed.Should().ContainSingle(); overResp.Failed[0].Error.Should().Contain("больше, чем доступно"); (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m); // Возврат оставшейся 1 → принят, остаток 10. var ret2 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, 1m, 100m) with { IsReturn = true, ReferenceSaleId = refId } } }; (await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", ret2)).Content.ReadFromJsonAsync())!.Accepted.Should().HaveCount(1); (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(10m); } [Fact] public async Task Pos_return_lookup_unknown_number_is_404() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-ret404-{Guid.NewGuid():N}"); using var resp = await api.Http.GetAsync("/api/pos/v1/sales/by-number/ПР-2000-999999"); resp.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound); } 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, }, }, }; }