food-market/tests/food-market.UnitTests/PosCoreTests.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

407 lines
18 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 FluentAssertions;
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);
}
}