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>
This commit is contained in:
nns 2026-10-08 09:16:48 +05:00
parent c5ff1ade94
commit 01b47cc0c8
8 changed files with 339 additions and 24 deletions

View file

@ -64,6 +64,40 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
Mandatory = bool.TryParse(cfg["Pos:Mandatory"], out var m) && m,
});
/// <summary>Чек-основание для возврата: поиск проведённого (не-возвратного)
/// чека по номеру/QR + позиции с количеством, доступным к возврату
/// (продано − уже возвращено). Для экрана возврата на кассе.</summary>
[HttpGet("sales/by-number/{number}")]
public async Task<ActionResult<PosReturnLookupDto>> SaleByNumber(string number, CancellationToken ct)
{
var n = (number ?? "").Trim();
if (n.Length == 0) return NotFound();
var sale = await _db.RetailSales.AsNoTracking()
.Where(s => s.Number == n && !s.IsReturn && s.Status == RetailSaleStatus.Posted)
.Select(s => new { s.Id, s.Number, s.Date, s.Payment })
.FirstOrDefaultAsync(ct);
if (sale is null)
return NotFound(new { error = "Чек не найден или не проведён.", field = "number" });
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id
where l.RetailSaleId == sale.Id
orderby l.SortOrder
select new PosReturnLineDto
{
ProductId = l.ProductId, Name = p.Name, UnitCode = u.Code,
UnitPrice = l.UnitPrice, SoldQty = l.Quantity,
ReturnedQty = l.QtyReturned, ReturnableQty = l.Quantity - l.QtyReturned,
}).ToListAsync(ct);
return Ok(new PosReturnLookupDto
{
SaleId = sale.Id, Number = sale.Number, Date = sale.Date,
Payment = (int)sale.Payment, Lines = lines,
});
}
[HttpGet("sync")]
public async Task<ActionResult<PosSyncResponse>> Sync(
[FromQuery] DateTime? since,
@ -312,6 +346,25 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
$"Недостаточный остаток для товара {x.ProductId}.", "productId");
}
}
else if (src.ReferenceSaleId is Guid refIdCheck)
{
// Возврат по чеку: нельзя вернуть больше, чем продано минус уже
// возвращено (QtyReturned на исходных строках). Защита от over-return.
var refLines = await _db.RetailSaleLines.AsNoTracking()
.Where(l => l.RetailSaleId == refIdCheck).ToListAsync(ct);
if (refLines.Count == 0)
throw new PosSaleRejectedException("Исходный чек не найден.", "referenceSaleId");
var remainingByProduct = refLines.GroupBy(l => l.ProductId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity) - g.Sum(x => x.QtyReturned));
foreach (var g in src.Lines.GroupBy(l => l.ProductId))
{
if (!remainingByProduct.TryGetValue(g.Key, out var remaining))
throw new PosSaleRejectedException($"Товар {g.Key} не из исходного чека.", "productId");
if (g.Sum(x => x.Quantity) > remaining)
throw new PosSaleRejectedException(
$"Нельзя вернуть больше, чем доступно (к возврату {remaining}).", "quantity");
}
}
// Привязка к смене: если указана — проверяем, что она открыта и наша.
if (src.CashShiftId is Guid shiftId)
@ -394,6 +447,24 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
UnitCost: l.UnitPrice,
OccurredAt: src.OccurredAt), ct);
}
// Возврат по чеку: инкрементим QtyReturned на исходных строках (защита
// от повторного возврата той же позиции). Насыщаем по строкам продукта.
if (src.IsReturn && src.ReferenceSaleId is Guid refIdInc)
{
var refLines = await _db.RetailSaleLines
.Where(l => l.RetailSaleId == refIdInc).ToListAsync(ct);
var takenByProduct = src.Lines.GroupBy(x => x.ProductId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
foreach (var rl in refLines.OrderBy(l => l.SortOrder))
{
if (!takenByProduct.TryGetValue(rl.ProductId, out var taken) || taken <= 0) continue;
var take = Math.Min(taken, rl.Quantity - rl.QtyReturned);
if (take <= 0) continue;
rl.QtyReturned += take;
takenByProduct[rl.ProductId] = taken - take;
}
}
sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow;
try

View file

@ -18,6 +18,9 @@ public interface IServerApi
[Get("/api/pos/v1/version")]
Task<PosVersionDto> VersionAsync(CancellationToken ct = default);
[Get("/api/pos/v1/sales/by-number/{number}")]
Task<PosReturnLookupDto> ReturnLookupAsync(string number, CancellationToken ct = default);
[Get("/api/me")]
Task<MeDto> MeAsync(CancellationToken ct = default);

View file

@ -56,6 +56,70 @@ public sealed record CommitResult(Guid ClientSaleId, bool PushedNow, int Pending
return new CommitResult(sale.ClientSaleId, pushedNow, _svc.Store.PendingCount(), printed, printErr);
}
/// <summary>Оформить возврат по чеку: собрать return-чек из выбранных позиций
/// (IsReturn + ReferenceSaleId), записать в очередь, напечатать, попытаться
/// выгрузить. Деньги — к выдаче (refundCash/refundCard как в исходном чеке).</summary>
public async Task<CommitResult> CommitReturnAsync(
IReadOnlyList<PosSaleLineDto> lines, Guid referenceSaleId,
decimal refundCash, decimal refundCard,
IReadOnlyList<(string Name, string Unit)> display, CancellationToken ct = default)
{
if (lines.Count == 0) throw new InvalidOperationException("Не выбрано ни одной позиции для возврата.");
var method = (refundCard > 0m && refundCash > 0m) ? PosPaymentMethod.Mixed
: refundCard > 0m ? PosPaymentMethod.Card : PosPaymentMethod.Cash;
var sale = new PosSaleDto
{
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow,
CashierUserId = _svc.Session?.CashierUserId,
RetailPointId = CurrentShift?.RetailPointId ?? _svc.Session?.RetailPointId,
CashShiftId = CurrentShift?.Id,
IsReturn = true, ReferenceSaleId = referenceSaleId,
Payment = (int)method, PaidCash = refundCash, PaidCard = refundCard,
Lines = lines,
};
_svc.Store.EnqueueSale(sale);
bool printed = false; string? printErr = null;
try { await _svc.Printer.PrintAsync(BuildReturnReceipt(lines, display, refundCash, refundCard), ct); printed = true; }
catch (Exception ex) { printErr = ex.Message; }
bool pushedNow = false;
if (_svc.Sync is not null)
{
try { var r = await _svc.Sync.PushSalesAsync(ct); pushedNow = r.Accepted > 0; }
catch { /* офлайн */ }
}
return new CommitResult(sale.ClientSaleId, pushedNow, _svc.Store.PendingCount(), printed, printErr);
}
private ReceiptDoc BuildReturnReceipt(IReadOnlyList<PosSaleLineDto> lines,
IReadOnlyList<(string Name, string Unit)> display, decimal refundCash, decimal refundCard)
{
var doc = new ReceiptDoc { Title = "ВОЗВРАТ" };
doc.Add("Food Market", center: true, bold: true);
if (CurrentShift?.Number is { } sn) doc.Add($"Смена {sn}", center: true);
if (_svc.Session?.CashierName is { } c) doc.Add($"Кассир: {c}");
doc.Add(DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss"));
doc.Separator();
decimal total = 0m;
for (int i = 0; i < lines.Count; i++)
{
var l = lines[i];
var name = i < display.Count ? display[i].Name : l.ProductId.ToString();
var unit = i < display.Count ? display[i].Unit : "шт";
var lineTotal = decimal.Round(l.Quantity * l.UnitPrice, 2);
total += lineTotal;
doc.Add(name);
doc.Add($" {l.Quantity:0.###} {unit} x {l.UnitPrice:N0}", $"{lineTotal:N0}");
}
doc.Separator();
doc.Add("К ВОЗВРАТУ", $"{total:N0} ₸", bold: true, big: true);
if (refundCash > 0m) doc.Add("Наличными", $"{refundCash:N0}");
if (refundCard > 0m) doc.Add("На карту", $"{refundCard:N0}");
doc.Separator();
return doc;
}
private ReceiptDoc BuildReceipt(Cart cart, decimal paidCash, decimal paidCard, decimal change, bool isReturn)
{
var t = cart.Totals();

View file

@ -529,34 +529,109 @@ private async Task DrawerAsync(int direction, decimal amount, string reason)
public static ShiftViewModel SampleClosed() { var s = new ShiftViewModel { IsOpen = false }; return s; }
}
/// <summary>Строка возврата: сколько продано/уже возвращено и сколько кассир
/// выбирает к возврату (0..ReturnableQty).</summary>
public partial class ReturnLineVm : ObservableObject
{
public required Guid ProductId { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _unit = "шт";
[ObservableProperty] private decimal _unitPrice;
[ObservableProperty] private decimal _soldQty;
[ObservableProperty] private decimal _returnableQty;
[ObservableProperty] private decimal _qty;
public decimal LineRefund => decimal.Round(UnitPrice * Qty, 2);
public string QtyText => Qty == decimal.Truncate(Qty) && Unit == "шт" ? Qty.ToString("0") : Qty.ToString("0.###");
public string AvailText => $"доступно к возврату: {(ReturnableQty == decimal.Truncate(ReturnableQty) ? ReturnableQty.ToString("0") : ReturnableQty.ToString("0.###"))} {Unit}";
partial void OnQtyChanged(decimal value) { OnPropertyChanged(nameof(LineRefund)); OnPropertyChanged(nameof(QtyText)); }
}
/// <summary>Возврат по чеку: найти чек (номер/скан QR), отметить позиции и
/// количество (не больше проданного минус уже возвращённого), сумма к выдаче,
/// способ (нал/карта как в исходном чеке).</summary>
public partial class ReturnViewModel : ViewModelBase
{
private readonly AppServices? _svc;
[ObservableProperty] private string _receiptNumber = "";
[ObservableProperty] private string _message = "Отсканируйте QR чека или введите номер";
[ObservableProperty] private bool _busy;
public ObservableCollection<SaleLineVm> Lines { get; } = new();
public decimal RefundTotal => Lines.Sum(l => l.LineTotal);
[ObservableProperty] private bool _found;
private Guid? _saleId;
private int _payment; // способ оплаты исходного чека (0=нал,1=карта,…)
public ObservableCollection<ReturnLineVm> Lines { get; } = new();
public decimal RefundTotal => Lines.Sum(l => l.LineRefund);
public Action<CheckoutService.CommitResult>? ReturnCommitted;
public ReturnViewModel() { }
public ReturnViewModel(AppServices svc) { _svc = svc; }
[RelayCommand]
private Task Lookup()
private async Task Lookup()
{
// Возврат по чеку: поиск чека-основания по номеру/QR. Серверный разбор
// чека выполняется при проведении возврата; здесь — заготовка для
// интерактивного выбора позиций (полный UI выбора — вне MVP-задачи).
if (string.IsNullOrWhiteSpace(ReceiptNumber)) { Message = "Введите номер чека или отсканируйте QR"; return Task.CompletedTask; }
Message = $"Чек {ReceiptNumber}: отметьте возвращаемые позиции и подтвердите возврат";
return Task.CompletedTask;
var number = ReceiptNumber.Trim();
if (number.Length == 0) { Message = "Введите номер чека или отсканируйте QR"; return; }
if (_svc?.Api is null) { Message = "Нет связи с сервером"; return; }
Busy = true; Found = false; Lines.Clear();
try
{
var r = await _svc.Api.ReturnLookupAsync(number);
_saleId = r.SaleId; _payment = r.Payment;
foreach (var l in r.Lines.Where(x => x.ReturnableQty > 0))
Lines.Add(new ReturnLineVm
{
ProductId = l.ProductId, Name = l.Name, Unit = l.UnitCode,
UnitPrice = l.UnitPrice, SoldQty = l.SoldQty, ReturnableQty = l.ReturnableQty, Qty = 0m,
});
OnPropertyChanged(nameof(RefundTotal));
Found = Lines.Count > 0;
Message = Found
? $"Чек {r.Number} найден — отметьте позиции к возврату"
: $"Чек {r.Number}: все позиции уже возвращены";
}
catch (Refit.ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{ Message = "Чек не найден или не проведён"; }
catch (Exception ex) { Message = "Ошибка поиска: " + ex.Message; }
finally { Busy = false; }
}
[RelayCommand] private void Inc(ReturnLineVm l) { if (l.Qty < l.ReturnableQty) { l.Qty = Math.Min(l.ReturnableQty, l.Qty + 1m); OnPropertyChanged(nameof(RefundTotal)); } }
[RelayCommand] private void Dec(ReturnLineVm l) { if (l.Qty > 0) { l.Qty = Math.Max(0m, l.Qty - 1m); OnPropertyChanged(nameof(RefundTotal)); } }
[RelayCommand] private void Full(ReturnLineVm l) { l.Qty = l.ReturnableQty; OnPropertyChanged(nameof(RefundTotal)); }
[RelayCommand]
private async Task Submit()
{
if (_svc is null || _saleId is null) { Message = "Сначала найдите чек"; return; }
var chosen = Lines.Where(l => l.Qty > 0m).ToList();
if (chosen.Count == 0) { Message = "Отметьте хотя бы одну позицию к возврату"; return; }
Busy = true;
try
{
var lines = chosen.Select(l => new foodmarket.Shared.Pos.V1.PosSaleLineDto
{ ProductId = l.ProductId, Quantity = l.Qty, UnitPrice = l.UnitPrice, Discount = 0m, VatPercent = 0m }).ToList();
var display = chosen.Select(l => (l.Name, l.Unit)).ToList();
var refund = RefundTotal;
var toCard = _payment == 1; // карта как в исходном чеке, иначе наличные
var res = await _svc.Checkout.CommitReturnAsync(lines, _saleId.Value,
refundCash: toCard ? 0m : refund, refundCard: toCard ? refund : 0m, display);
Message = res.PushedNow
? $"Возврат оформлен — к выдаче {refund:N0} ₸"
: $"Возврат в очереди — к выдаче {refund:N0} ₸ (отправится при связи)";
Lines.Clear(); Found = false; _saleId = null; ReceiptNumber = "";
OnPropertyChanged(nameof(RefundTotal));
ReturnCommitted?.Invoke(res);
}
catch (Exception ex) { Message = "Не удалось оформить возврат: " + ex.Message; }
finally { Busy = false; }
}
public static ReturnViewModel Sample()
{
var vm = new ReturnViewModel { ReceiptNumber = "Ч-2026-000128", Message = "Чек найден — отметьте возвращаемые позиции" };
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", Price = 450m, Quantity = 1 });
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный»", Unit = "шт", Price = 280m, Quantity = 1 });
var vm = new ReturnViewModel { ReceiptNumber = "ПР-2026-000128", Message = "Чек найден — отметьте позиции к возврату", Found = true };
vm.Lines.Add(new ReturnLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", UnitPrice = 450m, SoldQty = 2, ReturnableQty = 2, Qty = 1 });
vm.Lines.Add(new ReturnLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный» нарезной", Unit = "шт", UnitPrice = 280m, SoldQty = 1, ReturnableQty = 1, Qty = 1 });
vm.Lines.Add(new ReturnLineVm { ProductId = Guid.NewGuid(), Name = "Яблоки «Апорт» (вес)", Unit = "кг", UnitPrice = 690m, SoldQty = 1.235m, ReturnableQty = 1.235m, Qty = 0 });
return vm;
}
}

View file

@ -6,31 +6,50 @@
<Grid Background="#0f172a" RowDefinitions="Auto,*,Auto" Margin="16">
<StackPanel Grid.Row="0" Spacing="8">
<TextBlock Text="Возврат по чеку" FontSize="22" FontWeight="Bold" Foreground="#f8fafc"/>
<Grid ColumnDefinitions="*,160">
<TextBox Classes="pos" Watermark="Номер чека или скан QR" Text="{Binding ReceiptNumber}"/>
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}" IsDefault="True"/>
<Grid ColumnDefinitions="*,170">
<TextBox Classes="pos" Watermark="Номер чека или скан QR (Enter)" Text="{Binding ReceiptNumber}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding LookupCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}"/>
</Grid>
<TextBlock Text="{Binding Message}" Foreground="#60a5fa"/>
<TextBlock Text="{Binding Message}" Foreground="#60a5fa" TextWrapping="Wrap"/>
</StackPanel>
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Margin="0,10">
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SaleLineVm">
<Grid ColumnDefinitions="Auto,*,120,120" Height="46">
<CheckBox IsChecked="True" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Quantity, StringFormat='{}{0:0.###}'}" Foreground="#e5e7eb" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fff" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
<DataTemplate x:DataType="vm:ReturnLineVm">
<Grid ColumnDefinitions="*,190,110,120" MinHeight="58">
<StackPanel VerticalAlignment="Center" Margin="4,6">
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" TextWrapping="Wrap"/>
<TextBlock Text="{Binding AvailText}" Foreground="#94a3b8" FontSize="12.5"/>
</StackPanel>
<!-- Степпер количества к возврату -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="pos" Width="44" Height="40" Padding="0" FontSize="20" Content="−"
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).DecCommand}" CommandParameter="{Binding}"/>
<Border Background="#0b1220" CornerRadius="6" Width="58" Height="40">
<TextBlock Text="{Binding QtyText}" Foreground="#f8fafc" FontSize="17" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Button Classes="pos" Width="44" Height="40" Padding="0" FontSize="20" Content="+"
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).IncCommand}" CommandParameter="{Binding}"/>
</StackPanel>
<Button Grid.Column="2" Classes="pos" Height="40" FontSize="13" Margin="6,0,0,0" Content="Всё"
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).FullCommand}" CommandParameter="{Binding}"/>
<TextBlock Grid.Column="3" Text="{Binding LineRefund, StringFormat='{}{0:N0} ₸'}" Foreground="#ffffff" FontWeight="SemiBold" FontSize="17" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Text="К возврату" Foreground="#cbd5e1" FontSize="18" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16">
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="24" FontWeight="Bold" VerticalAlignment="Center"/>
<Button Classes="pos danger" Height="56" Content="Оформить возврат"/>
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="26" FontWeight="Bold" VerticalAlignment="Center"/>
<Button Classes="pos danger" Height="56" Width="230" Content="Оформить возврат" Command="{Binding SubmitCommand}" IsEnabled="{Binding Found}"/>
</StackPanel>
</Grid>
</Grid>

View file

@ -183,3 +183,25 @@ public record PosSaleFailedDto
public required string Error { get; init; }
public string? Field { get; init; }
}
/// <summary>Чек-основание для возврата по номеру/QR: шапка + позиции с
/// количеством, доступным к возврату (продано − уже возвращено).</summary>
public record PosReturnLookupDto
{
public required Guid SaleId { get; init; }
public required string Number { get; init; }
public required DateTime Date { get; init; }
public required int Payment { get; init; } // PaymentMethod исходного чека
public required IReadOnlyList<PosReturnLineDto> Lines { get; init; }
}
public record PosReturnLineDto
{
public required Guid ProductId { get; init; }
public required string Name { get; init; }
public required string UnitCode { get; init; }
public required decimal UnitPrice { get; init; }
public required decimal SoldQty { get; init; }
public required decimal ReturnedQty { get; init; }
public required decimal ReturnableQty { get; init; } // SoldQty − ReturnedQty
}

View file

@ -304,6 +304,66 @@ private static async Task<string> OpenShiftAsync(ApiActor api, string retailPoin
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(),

View file

@ -222,6 +222,7 @@ public Task<PosSaleBatchResponse> AcceptSalesAsync(PosSaleBatchDto batch, Cancel
}
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();