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:
parent
c5ff1ade94
commit
01b47cc0c8
|
|
@ -64,6 +64,40 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
Mandatory = bool.TryParse(cfg["Pos:Mandatory"], out var m) && m,
|
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")]
|
[HttpGet("sync")]
|
||||||
public async Task<ActionResult<PosSyncResponse>> Sync(
|
public async Task<ActionResult<PosSyncResponse>> Sync(
|
||||||
[FromQuery] DateTime? since,
|
[FromQuery] DateTime? since,
|
||||||
|
|
@ -312,6 +346,25 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
$"Недостаточный остаток для товара {x.ProductId}.", "productId");
|
$"Недостаточный остаток для товара {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)
|
if (src.CashShiftId is Guid shiftId)
|
||||||
|
|
@ -394,6 +447,24 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
UnitCost: l.UnitPrice,
|
UnitCost: l.UnitPrice,
|
||||||
OccurredAt: src.OccurredAt), ct);
|
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.Status = RetailSaleStatus.Posted;
|
||||||
sale.PostedAt = DateTime.UtcNow;
|
sale.PostedAt = DateTime.UtcNow;
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ public interface IServerApi
|
||||||
[Get("/api/pos/v1/version")]
|
[Get("/api/pos/v1/version")]
|
||||||
Task<PosVersionDto> VersionAsync(CancellationToken ct = default);
|
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")]
|
[Get("/api/me")]
|
||||||
Task<MeDto> MeAsync(CancellationToken ct = default);
|
Task<MeDto> MeAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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)
|
private ReceiptDoc BuildReceipt(Cart cart, decimal paidCash, decimal paidCard, decimal change, bool isReturn)
|
||||||
{
|
{
|
||||||
var t = cart.Totals();
|
var t = cart.Totals();
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
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
|
public partial class ReturnViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly AppServices? _svc;
|
private readonly AppServices? _svc;
|
||||||
[ObservableProperty] private string _receiptNumber = "";
|
[ObservableProperty] private string _receiptNumber = "";
|
||||||
[ObservableProperty] private string _message = "Отсканируйте QR чека или введите номер";
|
[ObservableProperty] private string _message = "Отсканируйте QR чека или введите номер";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
public ObservableCollection<SaleLineVm> Lines { get; } = new();
|
[ObservableProperty] private bool _found;
|
||||||
public decimal RefundTotal => Lines.Sum(l => l.LineTotal);
|
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() { }
|
||||||
public ReturnViewModel(AppServices svc) { _svc = svc; }
|
public ReturnViewModel(AppServices svc) { _svc = svc; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task Lookup()
|
private async Task Lookup()
|
||||||
{
|
{
|
||||||
// Возврат по чеку: поиск чека-основания по номеру/QR. Серверный разбор
|
var number = ReceiptNumber.Trim();
|
||||||
// чека выполняется при проведении возврата; здесь — заготовка для
|
if (number.Length == 0) { Message = "Введите номер чека или отсканируйте QR"; return; }
|
||||||
// интерактивного выбора позиций (полный UI выбора — вне MVP-задачи).
|
if (_svc?.Api is null) { Message = "Нет связи с сервером"; return; }
|
||||||
if (string.IsNullOrWhiteSpace(ReceiptNumber)) { Message = "Введите номер чека или отсканируйте QR"; return Task.CompletedTask; }
|
Busy = true; Found = false; Lines.Clear();
|
||||||
Message = $"Чек {ReceiptNumber}: отметьте возвращаемые позиции и подтвердите возврат";
|
try
|
||||||
return Task.CompletedTask;
|
{
|
||||||
|
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()
|
public static ReturnViewModel Sample()
|
||||||
{
|
{
|
||||||
var vm = new ReturnViewModel { ReceiptNumber = "Ч-2026-000128", Message = "Чек найден — отметьте возвращаемые позиции" };
|
var vm = new ReturnViewModel { ReceiptNumber = "ПР-2026-000128", Message = "Чек найден — отметьте позиции к возврату", Found = true };
|
||||||
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", Price = 450m, Quantity = 1 });
|
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 SaleLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный»", Unit = "шт", Price = 280m, Quantity = 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;
|
return vm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,31 +6,50 @@
|
||||||
<Grid Background="#0f172a" RowDefinitions="Auto,*,Auto" Margin="16">
|
<Grid Background="#0f172a" RowDefinitions="Auto,*,Auto" Margin="16">
|
||||||
<StackPanel Grid.Row="0" Spacing="8">
|
<StackPanel Grid.Row="0" Spacing="8">
|
||||||
<TextBlock Text="Возврат по чеку" FontSize="22" FontWeight="Bold" Foreground="#f8fafc"/>
|
<TextBlock Text="Возврат по чеку" FontSize="22" FontWeight="Bold" Foreground="#f8fafc"/>
|
||||||
<Grid ColumnDefinitions="*,160">
|
<Grid ColumnDefinitions="*,170">
|
||||||
<TextBox Classes="pos" Watermark="Номер чека или скан QR" Text="{Binding ReceiptNumber}"/>
|
<TextBox Classes="pos" Watermark="Номер чека или скан QR (Enter)" Text="{Binding ReceiptNumber}">
|
||||||
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}" IsDefault="True"/>
|
<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>
|
</Grid>
|
||||||
<TextBlock Text="{Binding Message}" Foreground="#60a5fa"/>
|
<TextBlock Text="{Binding Message}" Foreground="#60a5fa" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Margin="0,10">
|
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Margin="0,10">
|
||||||
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
|
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:SaleLineVm">
|
<DataTemplate x:DataType="vm:ReturnLineVm">
|
||||||
<Grid ColumnDefinitions="Auto,*,120,120" Height="46">
|
<Grid ColumnDefinitions="*,190,110,120" MinHeight="58">
|
||||||
<CheckBox IsChecked="True" VerticalAlignment="Center"/>
|
<StackPanel VerticalAlignment="Center" Margin="4,6">
|
||||||
<TextBlock Grid.Column="1" Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" VerticalAlignment="Center"/>
|
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" TextWrapping="Wrap"/>
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Quantity, StringFormat='{}{0:0.###}'}" Foreground="#e5e7eb" HorizontalAlignment="Right" VerticalAlignment="Center"/>
|
<TextBlock Text="{Binding AvailText}" Foreground="#94a3b8" FontSize="12.5"/>
|
||||||
<TextBlock Grid.Column="3" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fff" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
</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>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Text="К возврату" Foreground="#cbd5e1" FontSize="18" VerticalAlignment="Center"/>
|
<TextBlock Text="К возврату" Foreground="#cbd5e1" FontSize="18" VerticalAlignment="Center"/>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16">
|
||||||
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="24" FontWeight="Bold" VerticalAlignment="Center"/>
|
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="26" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||||
<Button Classes="pos danger" Height="56" Content="Оформить возврат"/>
|
<Button Classes="pos danger" Height="56" Width="230" Content="Оформить возврат" Command="{Binding SubmitCommand}" IsEnabled="{Binding Found}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -183,3 +183,25 @@ public record PosSaleFailedDto
|
||||||
public required string Error { get; init; }
|
public required string Error { get; init; }
|
||||||
public string? Field { 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,66 @@ private static async Task<string> OpenShiftAsync(ApiActor api, string retailPoin
|
||||||
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
|
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()
|
private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new()
|
||||||
{
|
{
|
||||||
ClientSaleId = Guid.NewGuid(),
|
ClientSaleId = Guid.NewGuid(),
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,7 @@ public Task<PosSaleBatchResponse> AcceptSalesAsync(PosSaleBatchDto batch, Cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<PosVersionDto> VersionAsync(CancellationToken ct = default) => throw new NotImplementedException();
|
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<PosSyncResponse> SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException();
|
||||||
public Task<MeDto> MeAsync(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<RetailPointDto>> RetailPointsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue