food-market/tests/food-market.IntegrationTests/PosSyncTests.cs
nns 606b81b4d9 feat(pos-api): привязка чеков кассы к смене/точке + возвраты + версия
- PosSaleDto: CashShiftId/RetailPointId/IsReturn/ReferenceSaleId (nullable,
  обратносовместимо с v1). PosController проставляет их в RetailSale, резолвит
  склад по точке продаж, проверяет что смена открыта. Без этого чеки кассы не
  попадали в Z-отчёт/материализацию денег смены (MoneyService агрегирует продажи
  по CashShiftId).
- Возврат покупателю (IsReturn): движение +остаток (CustomerReturn), без
  pre-flight на остаток; наличные к выдаче, учитывается в смене.
- GET /api/pos/v1/version (anon): версия + ссылки на сборки из конфигурации Pos
  (автообновление кассы + ссылка в админке).
- Интеграционные тесты: продажа в смене попадает в expected cash при закрытии;
  возврат возвращает остаток (9/9 PosSyncTests зелёные, Testcontainers).

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

322 lines
16 KiB
C#
Raw 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()!;
}
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,
},
},
};
}