food-market/tests/food-market.IntegrationTests/PosSyncTests.cs
nns 210e8171d3
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
feat(money): автооткрытие смены на POS-синке + уведомление о сменах >24ч (п.1)
POS: чек с точки без открытой смены — сервер автооткрывает смену (Cash-счёт точки,
размен 0) и принимает чек (как в вебе). Фоновый ShiftWatchJob (раз в час) уведомляет
кассира и админов о сменах, открытых дольше 24ч (персистентное уведомление + SignalR,
дедуп раз в сутки). Тест POS-автооткрытия.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 07:11:29 +05:00

414 lines
23 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 Oversell_on_pos_is_accepted_and_creates_negative_alert()
{
// Решение владельца 08.10: продажа в минус РАЗРЕШЕНА. Касса проводит чек
// даже сверх остатка, остаток уходит в минус, создаётся предупреждение.
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, продаём 3 → уйдём в −3.
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().HaveCount(1, "продажа в минус разрешена");
resp.Failed.Should().BeEmpty();
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(-3m);
var cnt = await api.GetJsonAsync("/api/negative-stock/count");
cnt.GetProperty("total").GetInt32().Should().BeGreaterThanOrEqualTo(1, "создано предупреждение о минусе");
}
[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,
},
},
};
[Fact]
public async Task Pos_sale_without_shift_auto_opens_shift_on_sync()
{
// Паритет п.1 (POS-синк): чек с точки без открытой смены → смена
// автооткрывается на сервере (как в вебе), чек принимается.
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"posauto-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 50m);
var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "POS-точка");
await api.CreateMoneyAccountAsync("Касса точки", 1 /*Cash*/, refs.CurrencyId, refs.StoreId, 0m);
var sale = MakeSale(product, 2m, 100m) with { RetailPointId = Guid.Parse(retailPoint) };
var batch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sale } };
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.EnsureSuccessStatusCode();
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
resp!.Accepted.Should().HaveCount(1, "смена должна автооткрыться и чек принят");
resp.Failed.Should().BeEmpty();
// На точке теперь открыта смена.
var current = await api.GetJsonAsync($"/api/money/shifts/current?retailPointId={retailPoint}");
current.GetProperty("open").GetBoolean().Should().BeTrue();
}
}