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