- Сервер: /api/me отдаёт retailPointIds (точки, к которым привязан сотрудник). - Касса: DeviceRegistration (чистая логика, 5 unit-тестов) — первый запуск: админ выбирает точку из списка активных (сохраняется на устройстве, kv); кассир на незарегистрированном устройстве → «обратитесь к администратору»; кассир, не привязанный к точке устройства → «вы не привязаны к этой кассе»; админ работает на любой точке. Смена точки — кнопка-сброс (новую выберет только админ). Экран входа: шаг выбора точки (ComboBox активных точек). - Заменяет прежний «берётся первая активная» (ошибка при двух точках). - Тесты: unit (5 решений гейта) + integration (/api/me retailPointIds кассира). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
439 lines
19 KiB
C#
439 lines
19 KiB
C#
using FluentAssertions;
|
||
using foodmarket.Pos.Core.Auth;
|
||
using foodmarket.Pos.Core.Data;
|
||
using foodmarket.Pos.Core.Logic;
|
||
using foodmarket.Pos.Core.Models;
|
||
using foodmarket.Pos.Core.Sync;
|
||
using foodmarket.Shared.Pos.V1;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.UnitTests;
|
||
|
||
public class PosBarcodeTests
|
||
{
|
||
[Fact]
|
||
public void Normal_barcode_is_not_weighted()
|
||
{
|
||
var r = BarcodeParser.Parse("4870204010101");
|
||
r.IsWeighted.Should().BeFalse();
|
||
r.Lookup.Should().Be("4870204010101");
|
||
}
|
||
|
||
[Fact]
|
||
public void Weight_prefix_20_extracts_plu_and_kg()
|
||
{
|
||
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг
|
||
var r = BarcodeParser.Parse("2112345015007");
|
||
r.IsWeighted.Should().BeTrue();
|
||
r.Lookup.Should().Be("12345");
|
||
r.EmbeddedWeight.Should().Be(1.500m);
|
||
r.EmbeddedPrice.Should().BeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public void Price_prefix_25_extracts_plu_and_price()
|
||
{
|
||
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸
|
||
var r = BarcodeParser.Parse("2554321123456");
|
||
r.IsWeighted.Should().BeTrue();
|
||
r.Lookup.Should().Be("54321");
|
||
r.EmbeddedPrice.Should().Be(123.45m);
|
||
r.EmbeddedWeight.Should().BeNull();
|
||
}
|
||
}
|
||
|
||
public class PosCartTests
|
||
{
|
||
private static PosProduct Piece(decimal price = 500m) => new()
|
||
{ Id = Guid.NewGuid(), Name = "Шт товар", Barcodes = new[] { "111" }, UnitCode = "шт", Packaging = PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price };
|
||
private static PosProduct Weighed(decimal price = 1000m) => new()
|
||
{ Id = Guid.NewGuid(), Name = "Вес товар", Barcodes = new[] { "222" }, UnitCode = "кг", Packaging = PosPackaging.Weight, VatPercent = 12m, VatEnabled = true, Price = price };
|
||
|
||
[Fact]
|
||
public void Piece_scans_increment_quantity()
|
||
{
|
||
var c = new Cart(); var p = Piece();
|
||
c.AddProduct(p); c.AddProduct(p); c.AddProduct(p);
|
||
c.Lines.Should().HaveCount(1);
|
||
c.Lines[0].Quantity.Should().Be(3);
|
||
c.Totals().Total.Should().Be(1500m);
|
||
}
|
||
|
||
[Fact]
|
||
public void Weighed_requires_weight_and_is_separate_line()
|
||
{
|
||
var c = new Cart(); var w = Weighed(1000m);
|
||
c.AddProduct(w, 1.5m);
|
||
c.AddProduct(w, 0.25m);
|
||
c.Lines.Should().HaveCount(2);
|
||
c.Totals().Total.Should().Be(1500m + 250m);
|
||
}
|
||
|
||
[Fact]
|
||
public void Line_and_cart_discounts_apply_and_clamp()
|
||
{
|
||
var c = new Cart(); var p = Piece(500m);
|
||
var line = c.AddProduct(p, 4m); // 2000
|
||
c.SetLineDiscount(line, 300m); // 1700
|
||
c.SetCartDiscount(200m); // 1500
|
||
c.Totals().Total.Should().Be(1500m);
|
||
// перебор скидки обрезается
|
||
c.SetLineDiscount(line, 999999m);
|
||
c.Lines[0].Discount.Should().Be(2000m);
|
||
}
|
||
|
||
[Fact]
|
||
public void Remove_and_clear()
|
||
{
|
||
var c = new Cart(); var line = c.AddProduct(Piece());
|
||
c.Remove(line); c.IsEmpty.Should().BeTrue();
|
||
}
|
||
}
|
||
|
||
public class PosPaymentShiftTests
|
||
{
|
||
[Fact]
|
||
public void Cash_overpay_gives_change()
|
||
{
|
||
var r = PaymentMath.Compute(1500m, paidCash: 2000m, paidCard: 0m);
|
||
r.Sufficient.Should().BeTrue();
|
||
r.Change.Should().Be(500m);
|
||
}
|
||
|
||
[Fact]
|
||
public void Mixed_exact_no_change()
|
||
{
|
||
var r = PaymentMath.Compute(1500m, paidCash: 500m, paidCard: 1000m);
|
||
r.Sufficient.Should().BeTrue();
|
||
r.Change.Should().Be(0m);
|
||
}
|
||
|
||
[Fact]
|
||
public void Underpay_is_insufficient_with_shortfall()
|
||
{
|
||
var r = PaymentMath.Compute(1500m, paidCash: 1000m, paidCard: 0m);
|
||
r.Sufficient.Should().BeFalse();
|
||
r.Shortfall.Should().Be(500m);
|
||
}
|
||
|
||
[Fact]
|
||
public void Shift_expected_and_difference()
|
||
{
|
||
var exp = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m);
|
||
exp.Should().Be(14275m);
|
||
ShiftMath.CashDifference(14775m, exp).Should().Be(500m);
|
||
}
|
||
}
|
||
|
||
public class PosStoreTests
|
||
{
|
||
private static PosSyncResponse Sync(params (Guid id, string name, string bc, decimal price, int pack)[] items) => new()
|
||
{
|
||
ServerTime = DateTime.UtcNow,
|
||
Products = items.Select(i => new ProductSyncDto
|
||
{
|
||
Id = i.id, Name = i.name, Barcodes = new[] { i.bc }, UnitCode = i.pack == 2 ? "кг" : "шт",
|
||
Packaging = i.pack, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
|
||
}).ToList(),
|
||
Prices = items.Select(i => new PriceSyncDto
|
||
{
|
||
ProductId = i.id, PriceTypeId = Guid.NewGuid(), PriceTypeName = "Розн", IsSystem = true,
|
||
Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow,
|
||
}).ToList(),
|
||
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
|
||
};
|
||
|
||
[Fact]
|
||
public void Catalog_find_by_barcode_and_name()
|
||
{
|
||
using var s = PosStore.InMemory();
|
||
var id = Guid.NewGuid();
|
||
s.ApplySync(Sync((id, "Молоко 2.5%", "4870001112223", 450m, 1)));
|
||
s.ProductCount().Should().Be(1);
|
||
s.FindByBarcode("4870001112223")!.Name.Should().Be("Молоко 2.5%");
|
||
s.FindByBarcode("4870001112223")!.Price.Should().Be(450m);
|
||
s.SearchByName("молок").Should().ContainSingle();
|
||
}
|
||
|
||
[Fact]
|
||
public void Queue_enqueue_pending_marksent()
|
||
{
|
||
using var s = PosStore.InMemory();
|
||
var sale = new PosSaleDto
|
||
{
|
||
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = 100m, PaidCard = 0m,
|
||
Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 100m, Discount = 0m } },
|
||
};
|
||
s.EnqueueSale(sale);
|
||
s.EnqueueSale(sale); // повтор не дублирует (PK ClientSaleId)
|
||
s.PendingCount().Should().Be(1);
|
||
s.MarkSent(sale.ClientSaleId);
|
||
s.PendingCount().Should().Be(0);
|
||
}
|
||
}
|
||
|
||
public class PosSyncKeyTests
|
||
{
|
||
[Fact]
|
||
public void Batch_key_is_stable_for_same_set_regardless_of_order()
|
||
{
|
||
var a = Guid.NewGuid(); var b = Guid.NewGuid();
|
||
SyncService.StableBatchKey(new[] { a, b }).Should().Be(SyncService.StableBatchKey(new[] { b, a }));
|
||
}
|
||
|
||
[Fact]
|
||
public void Batch_key_differs_for_different_sets()
|
||
{
|
||
SyncService.StableBatchKey(new[] { Guid.NewGuid() })
|
||
.Should().NotBe(SyncService.StableBatchKey(new[] { Guid.NewGuid() }));
|
||
}
|
||
}
|
||
|
||
/// <summary>Офлайн-сценарий задачи (п.8): нет сети → кассир пробивает 5 чеков →
|
||
/// появляется сеть → синхронизация → ровно 5 продаж на «сервере», без дублей
|
||
/// даже при повторном пуше. Прогоняется против фейкового IServerApi, который
|
||
/// повторяет серверную идемпотентность по ClientSaleId.</summary>
|
||
public class PosOfflineFlowTests
|
||
{
|
||
/// <summary>Фейковый сервер: хранит принятые продажи по ClientSaleId.
|
||
/// Повторный приём той же продажи не создаёт дубля (как реальный сервер).</summary>
|
||
private sealed class FakeServer : IServerApi
|
||
{
|
||
public readonly Dictionary<Guid, Guid> ServerByClient = new();
|
||
public int BatchCalls;
|
||
public bool Online;
|
||
|
||
public Task<PosSaleBatchResponse> AcceptSalesAsync(PosSaleBatchDto batch, CancellationToken ct = default)
|
||
{
|
||
if (!Online) throw new HttpRequestException("нет сети");
|
||
BatchCalls++;
|
||
bool anyNew = false;
|
||
var accepted = new List<PosSaleAcceptedDto>();
|
||
foreach (var s in batch.Sales)
|
||
{
|
||
if (!ServerByClient.TryGetValue(s.ClientSaleId, out var sid))
|
||
{ sid = Guid.NewGuid(); ServerByClient[s.ClientSaleId] = sid; anyNew = true; }
|
||
accepted.Add(new PosSaleAcceptedDto { ClientSaleId = s.ClientSaleId, ServerSaleId = sid, ServerSaleNumber = $"ПР-{sid:N}".Substring(0, 10) });
|
||
}
|
||
return Task.FromResult(new PosSaleBatchResponse
|
||
{
|
||
IdempotencyKey = batch.IdempotencyKey, Accepted = accepted,
|
||
Failed = Array.Empty<PosSaleFailedDto>(), ReplayedFromCache = !anyNew,
|
||
});
|
||
}
|
||
|
||
public Task<PosVersionDto> VersionAsync(CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<PosReturnLookupDto> ReturnLookupAsync(string number, CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<PosSyncResponse> SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<MeDto> MeAsync(CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<PagedDto<RetailPointDto>> RetailPointsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<PagedDto<MoneyAccountDto>> MoneyAccountsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<Refit.ApiResponse<CurrentShiftDto>> CurrentShiftAsync(Guid retailPointId, CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<OpenShiftResultDto> OpenShiftAsync(OpenShiftBody body, CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<CloseShiftResultDto> CloseShiftAsync(Guid id, CloseShiftBody body, CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task DrawerAsync(Guid id, DrawerBody body, CancellationToken ct = default) => throw new NotImplementedException();
|
||
public Task<ShiftReportDto> ShiftReportAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
|
||
}
|
||
|
||
private static PosSaleDto Sale() => new()
|
||
{
|
||
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow,
|
||
Payment = 0, PaidCash = 500m, PaidCard = 0m,
|
||
Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 500m, Discount = 0m } },
|
||
};
|
||
|
||
[Fact]
|
||
public async Task No_network_then_five_sales_then_sync_yields_exactly_five_no_duplicates()
|
||
{
|
||
using var store = PosStore.InMemory();
|
||
var server = new FakeServer { Online = false };
|
||
var sync = new SyncService(store, server);
|
||
|
||
// 1) Сети нет — попытка пуша кидает, очередь не теряется.
|
||
store.EnqueueSale(Sale());
|
||
await Assert.ThrowsAnyAsync<Exception>(() => sync.PushSalesAsync());
|
||
server.ServerByClient.Should().BeEmpty("офлайн — на сервер ничего не ушло");
|
||
|
||
// 2) Кассир пробивает ещё 4 чека офлайн — всего 5 в очереди.
|
||
for (int i = 0; i < 4; i++) store.EnqueueSale(Sale());
|
||
store.PendingCount().Should().Be(5);
|
||
|
||
// 3) Появилась сеть — синхронизация.
|
||
server.Online = true;
|
||
var push = await sync.PushSalesAsync();
|
||
push.Accepted.Should().Be(5);
|
||
push.Failed.Should().Be(0);
|
||
store.PendingCount().Should().Be(0, "все отправленные помечены sent");
|
||
server.ServerByClient.Should().HaveCount(5, "на сервере ровно 5 чеков");
|
||
|
||
// 4) Повторный пуш (например, двойной клик/ретрай) не плодит дублей.
|
||
var replay = await sync.PushSalesAsync();
|
||
replay.Accepted.Should().Be(0, "очередь уже пуста — слать нечего");
|
||
server.ServerByClient.Should().HaveCount(5, "по-прежнему ровно 5");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Resend_of_same_queue_is_idempotent_on_server()
|
||
{
|
||
using var store = PosStore.InMemory();
|
||
var server = new FakeServer { Online = true };
|
||
var sync = new SyncService(store, server);
|
||
for (int i = 0; i < 5; i++) store.EnqueueSale(Sale());
|
||
|
||
// Эмулируем: пуш прошёл на сервере, но ответ не дошёл (касса не пометила sent).
|
||
var pending = store.PendingSales();
|
||
await server.AcceptSalesAsync(new PosSaleBatchDto { IdempotencyKey = SyncService.StableBatchKey(pending.Select(s => s.ClientSaleId)), Sales = pending });
|
||
server.ServerByClient.Should().HaveCount(5);
|
||
|
||
// Касса повторяет весь набор — сервер распознаёт по ClientSaleId, дублей нет.
|
||
var push = await sync.PushSalesAsync();
|
||
push.Accepted.Should().Be(5);
|
||
server.ServerByClient.Should().HaveCount(5, "повтор того же набора не создал новых чеков");
|
||
store.PendingCount().Should().Be(0);
|
||
}
|
||
}
|
||
|
||
/// <summary>Сборка серверного чека из корзины: распределение скидки на чек по
|
||
/// строкам (сервер понимает только построчную скидку), способ оплаты, привязка
|
||
/// к смене/точке, возвраты.</summary>
|
||
public class PosCheckoutTests
|
||
{
|
||
private static PosProduct P(decimal price, bool weighed = false) => new()
|
||
{
|
||
Id = Guid.NewGuid(), Name = "Товар", Barcodes = new[] { "1" }, UnitCode = weighed ? "кг" : "шт",
|
||
Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price,
|
||
};
|
||
|
||
[Fact]
|
||
public void Cart_discount_is_distributed_across_lines_and_sums_exactly()
|
||
{
|
||
var cart = new Cart();
|
||
cart.AddProduct(P(100m), 1); // 100
|
||
cart.AddProduct(P(200m), 1); // 200
|
||
cart.AddProduct(P(700m), 1); // 700 → сумма 1000
|
||
cart.SetCartDiscount(100m); // 10% на чек
|
||
|
||
var sale = Checkout.BuildSale(cart, paidCash: 900m, paidCard: 0m,
|
||
new CheckoutContext(null, null, null));
|
||
|
||
// Сумма построчных скидок точно равна скидке на чек.
|
||
sale.Lines.Sum(l => l.Discount).Should().Be(100m);
|
||
// Итог сервера (Σ qty*price − Σ disc) совпадает с итогом кассы.
|
||
var serverTotal = sale.Lines.Sum(l => l.Quantity * l.UnitPrice - l.Discount);
|
||
serverTotal.Should().Be(cart.Totals().Total);
|
||
}
|
||
|
||
[Fact]
|
||
public void Payment_method_is_inferred_from_cash_and_card()
|
||
{
|
||
var cart = new Cart(); cart.AddProduct(P(500m), 1);
|
||
Checkout.BuildSale(cart, 500m, 0m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Cash);
|
||
Checkout.BuildSale(cart, 0m, 500m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Card);
|
||
Checkout.BuildSale(cart, 200m, 300m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Mixed);
|
||
}
|
||
|
||
[Fact]
|
||
public void Shift_and_point_context_is_carried_into_sale()
|
||
{
|
||
var cart = new Cart(); cart.AddProduct(P(500m), 1);
|
||
var rp = Guid.NewGuid(); var shift = Guid.NewGuid(); var cashier = Guid.NewGuid();
|
||
var sale = Checkout.BuildSale(cart, 500m, 0m, new(cashier, rp, shift));
|
||
sale.RetailPointId.Should().Be(rp);
|
||
sale.CashShiftId.Should().Be(shift);
|
||
sale.CashierUserId.Should().Be(cashier);
|
||
sale.IsReturn.Should().BeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public void Return_sets_flag_and_reference()
|
||
{
|
||
var cart = new Cart(); cart.AddProduct(P(500m), 1);
|
||
var refSale = Guid.NewGuid();
|
||
var sale = Checkout.BuildSale(cart, 0m, 0m, new(null, null, null), isReturn: true, referenceSaleId: refSale);
|
||
sale.IsReturn.Should().BeTrue();
|
||
sale.ReferenceSaleId.Should().Be(refSale);
|
||
}
|
||
|
||
[Fact]
|
||
public void Empty_cart_throws()
|
||
{
|
||
var cart = new Cart();
|
||
var act = () => Checkout.BuildSale(cart, 0m, 0m, new(null, null, null));
|
||
act.Should().Throw<InvalidOperationException>();
|
||
}
|
||
}
|
||
|
||
public class PosUpdateCheckTests
|
||
{
|
||
[Theory]
|
||
[InlineData("2026.10.7", "2026.10.8", true)]
|
||
[InlineData("2026.10.8", "2026.10.8", false)]
|
||
[InlineData("2026.10.8", "2026.10.7", false)]
|
||
[InlineData("1.9.0", "1.10.0", true)]
|
||
[InlineData("2026.9.30", "2026.10.1", true)]
|
||
[InlineData(null, "2026.10.1", true)]
|
||
[InlineData("2026.10.1", null, false)]
|
||
[InlineData("1.2.3", "1.2.3-beta", false)]
|
||
public void Version_comparison(string? local, string? remote, bool expected)
|
||
=> UpdateCheck.IsNewer(local, remote).Should().Be(expected);
|
||
}
|
||
|
||
public class PosParkedCartTests
|
||
{
|
||
private static PosProduct P(decimal price = 300m) => new()
|
||
{ Id = Guid.NewGuid(), Name = "Товар", Barcodes = new[] { "1" }, UnitCode = "шт", Packaging = PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price };
|
||
|
||
[Fact]
|
||
public void Snapshot_is_independent_copy()
|
||
{
|
||
var cart = new Cart();
|
||
var line = cart.AddProduct(P(300m), 2);
|
||
cart.SetCartDiscount(100m);
|
||
var snap = cart.Snapshot();
|
||
|
||
// Меняем исходную корзину — снимок не должен измениться.
|
||
cart.SetQuantity(line, 5m);
|
||
cart.Clear();
|
||
snap.Lines.Should().HaveCount(1);
|
||
snap.Lines[0].Quantity.Should().Be(2m);
|
||
snap.CartDiscount.Should().Be(100m);
|
||
|
||
// Восстановление возвращает отложенный чек.
|
||
cart.Restore(snap);
|
||
cart.Lines.Should().HaveCount(1);
|
||
cart.Lines[0].Quantity.Should().Be(2m);
|
||
cart.Totals().Total.Should().Be(2 * 300m - 100m);
|
||
}
|
||
}
|
||
|
||
public class PosDeviceRegistrationTests
|
||
{
|
||
private static readonly Guid A = Guid.NewGuid();
|
||
private static readonly Guid B = Guid.NewGuid();
|
||
|
||
[Fact]
|
||
public void Admin_on_fresh_device_is_asked_to_select()
|
||
=> DeviceRegistration.Evaluate(null, canManagePoints: true, Array.Empty<Guid>())
|
||
.Should().Be(DeviceRegistration.Decision.NeedSelection);
|
||
|
||
[Fact]
|
||
public void Cashier_on_fresh_device_is_not_registered()
|
||
=> DeviceRegistration.Evaluate(null, canManagePoints: false, new[] { A })
|
||
.Should().Be(DeviceRegistration.Decision.NotRegistered);
|
||
|
||
[Fact]
|
||
public void Admin_always_proceeds_on_registered_device()
|
||
=> DeviceRegistration.Evaluate(A, canManagePoints: true, Array.Empty<Guid>())
|
||
.Should().Be(DeviceRegistration.Decision.Proceed);
|
||
|
||
[Fact]
|
||
public void Cashier_assigned_to_device_point_proceeds()
|
||
=> DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { A, B })
|
||
.Should().Be(DeviceRegistration.Decision.Proceed);
|
||
|
||
[Fact]
|
||
public void Cashier_not_assigned_to_device_point_is_blocked()
|
||
=> DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { B })
|
||
.Should().Be(DeviceRegistration.Decision.NotAssigned);
|
||
}
|