diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index 83504b7..fb87d00 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -64,6 +64,40 @@ public ActionResult Version([FromServices] IConfiguration cfg) Mandatory = bool.TryParse(cfg["Pos:Mandatory"], out var m) && m, }); + /// Чек-основание для возврата: поиск проведённого (не-возвратного) + /// чека по номеру/QR + позиции с количеством, доступным к возврату + /// (продано − уже возвращено). Для экрана возврата на кассе. + [HttpGet("sales/by-number/{number}")] + public async Task> 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> Sync( [FromQuery] DateTime? since, @@ -312,6 +346,25 @@ public ActionResult 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 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 diff --git a/src/food-market.pos.core/Sync/IServerApi.cs b/src/food-market.pos.core/Sync/IServerApi.cs index 339ca5b..4a0aed1 100644 --- a/src/food-market.pos.core/Sync/IServerApi.cs +++ b/src/food-market.pos.core/Sync/IServerApi.cs @@ -18,6 +18,9 @@ public interface IServerApi [Get("/api/pos/v1/version")] Task VersionAsync(CancellationToken ct = default); + [Get("/api/pos/v1/sales/by-number/{number}")] + Task ReturnLookupAsync(string number, CancellationToken ct = default); + [Get("/api/me")] Task MeAsync(CancellationToken ct = default); diff --git a/src/food-market.pos/Services/CheckoutService.cs b/src/food-market.pos/Services/CheckoutService.cs index 764dc4d..dcb7fb2 100644 --- a/src/food-market.pos/Services/CheckoutService.cs +++ b/src/food-market.pos/Services/CheckoutService.cs @@ -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-чек из выбранных позиций + /// (IsReturn + ReferenceSaleId), записать в очередь, напечатать, попытаться + /// выгрузить. Деньги — к выдаче (refundCash/refundCard как в исходном чеке). + public async Task CommitReturnAsync( + IReadOnlyList 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 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(); diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs index aace038..baa6468 100644 --- a/src/food-market.pos/ViewModels/ViewModels.cs +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -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; } } +/// Строка возврата: сколько продано/уже возвращено и сколько кассир +/// выбирает к возврату (0..ReturnableQty). +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)); } +} + +/// Возврат по чеку: найти чек (номер/скан QR), отметить позиции и +/// количество (не больше проданного минус уже возвращённого), сумма к выдаче, +/// способ (нал/карта как в исходном чеке). public partial class ReturnViewModel : ViewModelBase { private readonly AppServices? _svc; [ObservableProperty] private string _receiptNumber = ""; [ObservableProperty] private string _message = "Отсканируйте QR чека или введите номер"; [ObservableProperty] private bool _busy; - public ObservableCollection 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 Lines { get; } = new(); + public decimal RefundTotal => Lines.Sum(l => l.LineRefund); + public Action? 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; } } diff --git a/src/food-market.pos/Views/ReturnView.axaml b/src/food-market.pos/Views/ReturnView.axaml index 35ebb0b..4a22641 100644 --- a/src/food-market.pos/Views/ReturnView.axaml +++ b/src/food-market.pos/Views/ReturnView.axaml @@ -6,31 +6,50 @@ - - -