diff --git a/src/food-market.pos.core/Sync/IServerApi.cs b/src/food-market.pos.core/Sync/IServerApi.cs index fc32afe..11e0ab1 100644 --- a/src/food-market.pos.core/Sync/IServerApi.cs +++ b/src/food-market.pos.core/Sync/IServerApi.cs @@ -70,5 +70,7 @@ public record OpenShiftResultDto(Guid id, string number); public record CloseShiftBody(decimal ActualCash, string? Notes); public record CloseShiftResultDto(decimal expectedCash, decimal difference); public record DrawerBody(int Direction, decimal Amount, string Reason); // 1=In, 2=Out -public record ShiftReportDto(string number, decimal openingFloat, ShiftSales sales, ShiftSales returns, decimal expectedCash, decimal? actualCash, decimal cashDifference); +public record ShiftProductRow(string name, decimal soldQty, decimal soldAmount, decimal returnedQty, decimal returnedAmount); +public record ShiftReportDto(string number, decimal openingFloat, ShiftSales sales, ShiftSales returns, decimal expectedCash, decimal? actualCash, decimal cashDifference, + System.Collections.Generic.IReadOnlyList? byProduct = null, bool overLimit24h = false); public record ShiftSales(decimal cash, decimal card); diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs index 60708cd..cfb1e30 100644 --- a/src/food-market.pos/ViewModels/ViewModels.cs +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -524,6 +524,11 @@ public partial class SaleViewModel : ViewModelBase partial void OnItemCountChanged(int value) => OnPropertyChanged(nameof(IsCartEmpty)); partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(HasAnyDiscount)); + // ── Автооткрытие смены первой продажей: вопрос «размен?» (можно пропустить) ─ + [ObservableProperty] private bool _isShiftOpenPromptOpen; + [ObservableProperty] private decimal? _shiftOpenFloat; + [ObservableProperty] private bool _shiftOpenBusy; + // ── Быстрое создание товара по неизвестному штрихкоду (онлайн) ──────────── [ObservableProperty] private bool _isQuickCreateOpen; [ObservableProperty] private string _quickCreateBarcode = ""; @@ -690,13 +695,48 @@ private void RemoveLine(SaleLineVm line) private void Pay() { if (_cart.IsEmpty) { Message = "Чек пуст — нечего оплачивать"; return; } - if (_svc is not null && !_svc.Checkout.HasOpenShift) { Message = "Смена не открыта — F10, откройте смену"; return; } + if (_svc is not null && !_svc.Checkout.HasOpenShift) + { + // Смена не открыта: онлайн — автооткрытие первой продажей с вопросом + // «размен?» (можно пропустить). Офлайн — просим открыть смену вручную. + if (_svc.Api is not null) { ShiftOpenFloat = null; IsShiftOpenPromptOpen = true; return; } + Message = "Смена не открыта — F10, откройте смену"; return; + } + OpenPaymentModal(); + } + + private void OpenPaymentModal() + { var pvm = new PaymentViewModel { Total = Total }; pvm.OnConfirm = (cash, card, change) => CommitPaymentAsync(cash, card, change); if (PaymentRequested is not null) PaymentRequested(pvm); else _ = CommitPaymentAsync(Total, 0m, 0m); // без оболочки (тест/демо) — оплата наличными «под расчёт» } + [RelayCommand] private void ShiftOpenCancel() => IsShiftOpenPromptOpen = false; + + /// Открыть смену первой продажей (с введённым разменом или без) и + /// продолжить к оплате. Размен необязателен — можно пропустить (0). + [RelayCommand] + private async Task ShiftOpenConfirmAsync() + { + if (_svc?.Api is null || ShiftOpenBusy) return; + if (_svc.Session?.RetailPointId is not Guid rp) { Message = "Нет привязки к точке"; IsShiftOpenPromptOpen = false; return; } + ShiftOpenBusy = true; + try + { + var accounts = await _svc.Api.MoneyAccountsAsync(); + var cash = accounts.items.FirstOrDefault(a => a.kind == 1 && a.isActive) ?? accounts.items.FirstOrDefault(a => a.kind == 1); + if (cash is null) { Message = "Не найдена наличная касса (создайте счёт в админке)"; return; } + var res = await _svc.Api.OpenShiftAsync(new OpenShiftBody(rp, cash.id, null, ShiftOpenFloat ?? 0m)); + _svc.Checkout.CurrentShift = new PosShift { Id = res.id, Number = res.number, RetailPointId = rp, CashAccountId = cash.id, OpeningFloat = ShiftOpenFloat ?? 0m }; + IsShiftOpenPromptOpen = false; + OpenPaymentModal(); + } + catch (Exception ex) { Message = "Не удалось открыть смену: " + ex.Message; } + finally { ShiftOpenBusy = false; } + } + /// Провести оплату: офлайн-устойчивая запись чека + печать + попытка /// отправки. Очищает корзину. public async Task CommitPaymentAsync(decimal cash, decimal card, decimal change) @@ -916,14 +956,59 @@ private async Task CloseShift() { var res = await _svc.Api.CloseShiftAsync(s.Id, new CloseShiftBody(ActualCash, null)); IsOpen = false; + _lastClosedShiftId = s.Id; _svc.Checkout.CurrentShift = null; OnShiftChanged?.Invoke(null); Message = $"Смена закрыта. Должно {res.expectedCash:N0} ₸, факт {ActualCash:N0} ₸, разница {res.difference:N0} ₸"; + await PrintZReportAsync(s.Id); // Z-отчёт с разбивкой по товарам на печать } catch (Exception ex) { Message = "Закрыть смену не удалось: " + ex.Message; } finally { Busy = false; } } + private Guid? _lastClosedShiftId; + + /// Печать Z-отчёта текущей (или только что закрытой) смены с разбивкой по товарам. + [RelayCommand] + private async Task PrintZReport() + { + var id = _svc?.Checkout.CurrentShift?.Id ?? _lastClosedShiftId; + if (_svc?.Api is null || id is not Guid sid) { Message = "Нет смены для Z-отчёта"; return; } + await PrintZReportAsync(sid); + } + + private async Task PrintZReportAsync(Guid shiftId) + { + if (_svc?.Api is null) return; + try + { + var r = await _svc.Api.ShiftReportAsync(shiftId); + var doc = new foodmarket.Pos.Hardware.ReceiptDoc { Title = $"Z-ОТЧЁТ {r.number}" }; + doc.Add("Размен", $"{r.openingFloat:N0}"); + doc.Add("Продажи нал", $"{r.sales.cash:N0}"); + doc.Add("Продажи карта", $"{r.sales.card:N0}"); + doc.Add("Возвраты нал", $"-{r.returns.cash:N0}"); + doc.Separator(); + doc.Add("Ожидается нал", $"{r.expectedCash:N0}", bold: true); + if (r.actualCash is { } ac) doc.Add("Факт нал", $"{ac:N0}"); + doc.Add("Разница", $"{r.cashDifference:N0}"); + if (r.overLimit24h) doc.Add("! Смена открыта > 24ч"); + if (r.byProduct is { Count: > 0 } bp) + { + doc.Separator(); + doc.Add("ПО ТОВАРАМ", center: true, bold: true); + foreach (var p in bp) + { + doc.Add(p.name, $"{p.soldQty:0.###} = {p.soldAmount:N0}"); + if (p.returnedQty > 0) doc.Add(" возврат", $"-{p.returnedQty:0.###} = {p.returnedAmount:N0}"); + } + } + await _svc.Printer.PrintAsync(doc); + Message = "Z-отчёт отправлен на печать"; + } + catch (Exception ex) { Message = "Печать Z-отчёта не удалась: " + ex.Message; } + } + [RelayCommand] private async Task DepositCash() => await DrawerAsync(1, DrawerIn, "Внесение"); [RelayCommand] diff --git a/src/food-market.pos/Views/SaleView.axaml b/src/food-market.pos/Views/SaleView.axaml index 61b8834..4d7366f 100644 --- a/src/food-market.pos/Views/SaleView.axaml +++ b/src/food-market.pos/Views/SaleView.axaml @@ -203,6 +203,23 @@ + + + + + + + + +