food-market/tests/food-market.IntegrationTests/PosSyncTests.cs
nns 01b47cc0c8 feat(pos): возврат по чеку — экран выбора позиций + защита от over-return
- Сервер: GET /api/pos/v1/sales/by-number/{number} — чек-основание с позициями
  и доступным к возврату (продано − уже возвращено, по QtyReturned). POS-путь
  возврата теперь валидирует over-return (нельзя вернуть больше остатка) и
  инкрементит QtyReturned на исходных строках (как RetailSalesController).
- Контракт: PosReturnLookupDto/PosReturnLineDto; IServerApi.ReturnLookupAsync.
- Касса: экран возврата — найти чек по номеру/скану QR, степпер количества по
  каждой позиции (0..доступно, кнопка «Всё»), сумма к выдаче, способ (нал/карта
  как в исходном чеке); CheckoutService.CommitReturnAsync пишет возврат в офлайн-
  очередь + печать + выгрузка.
- Тесты: integration — lookup by-number, возврат 2 из 3, over-return в Failed,
  довозврат 1, QtyReturned/остатки корректны; 404 на неизвестный номер. 11/11 PosSyncTests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:16:48 +05:00

382 lines
20 KiB
C#
Raw Permalink 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.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);
}
/// <summary>Чек с кассы, пробитый в открытую смену, должен попасть в Z-отчёт
/// смены (MoneyService агрегирует продажи по CashShiftId) — именно эту
/// привязку касса теперь шлёт в RetailPointId/CashShiftId.</summary>
[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<PosSaleBatchResponse>())!.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<JsonElement>();
body.GetProperty("expectedCash").GetDecimal().Should().Be(6_000m, "продажа кассы учтена в смене");
body.GetProperty("difference").GetDecimal().Should().Be(0m);
}
/// <summary>Возврат покупателю (IsReturn) возвращает товар на склад (+остаток),
/// а не списывает его.</summary>
[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<PosSaleBatchResponse>())!.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<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
}
private static async Task<string> 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()!;
}
/// <summary>Возврат по чеку через кассу: lookup by-number отдаёт доступное к
/// возврату; POS-возврат уважает остаток (нельзя вернуть больше проданного
/// минус уже возвращённого) и инкрементит QtyReturned.</summary>
[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<PosSaleBatchResponse>();
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<PosReturnLookupDto>($"/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<PosSaleBatchResponse>())!.Accepted.Should().HaveCount(1);
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m);
// Теперь доступно к возврату 1.
var look2 = await api.Http.GetFromJsonAsync<PosReturnLookupDto>($"/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<PosSaleBatchResponse>();
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<PosSaleBatchResponse>())!.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,
},
},
};
}