feat(pos): отложенный чек (park/resume) + снимок корзины
- Cart.Snapshot()/Restore() — независимая копия строк и скидки на чек. - SaleViewModel: ParkCommand («Отложить» — сохранить чек, начать новый) и ResumeCommand («Вернуть» — восстановить отложенный); кнопки на экране продажи. - Unit-тест независимости снимка (38 POS unit-тестов зелёные). Скриншоты обновлены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -51,8 +51,8 @@
|
|||
5. [x] Продажа: сканер keyboard-wedge (Enter добавляет), поиск, количество, весовой товар
|
||||
(ручной ввод + COM CAS/Масса-К парсеры), весовые ШК 20–29, скидка на строку и на чек
|
||||
(распределяется по строкам, т.к. сервер понимает только построчную), удаление строки (Del),
|
||||
нал/карта/смешанная + сдача, горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del, без мыши.
|
||||
(Отложенный чек — заготовка; базовый сценарий «скан→оплата→печать→очередь» работает и покрыт тестом.)
|
||||
нал/карта/смешанная + сдача, отложенный чек (park/resume, независимый снимок корзины),
|
||||
горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del, без мыши. Сценарий «скан→оплата→печать→очередь» покрыт тестом.
|
||||
6. [x] Возврат: `IsReturn` в контракте и на сервере — возврат добавляет остаток (CustomerReturn),
|
||||
наличные к выдаче, учитывается в смене; экран возврата по номеру/QR. Без чека — по праву
|
||||
админа (флаг в чеке). Частичный — по позициям. (Полный интерактивный выбор позиций — экран-заготовка.)
|
||||
|
|
@ -106,8 +106,8 @@
|
|||
global.json пинит 8.0.417, он не стоит локально). Это НЕ связано с POS: ни один из падающих тестов
|
||||
и ни один файл EF-модели не менялись в этой задаче; все POS-тесты (unit/integration/UI) зелёные.
|
||||
- Фискализация ОФД KZ — только заглушка (по заданию).
|
||||
- Отложенный чек и полный интерактивный выбор позиций возврата — экраны-заготовки; серверная и
|
||||
доменная части (возврат, привязка к смене) реализованы и покрыты тестами.
|
||||
- Полный интерактивный выбор позиций при возврате по чеку — экран-заготовка (поиск/отметка позиций);
|
||||
серверная и доменная части возврата (IsReturn, +остаток, привязка к смене) реализованы и покрыты тестами.
|
||||
|
||||
## Коммиты
|
||||
(добавляются при коммите — тематические, в main, attribution Co-Authored-By: Claude Opus)
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 81 KiB |
|
|
@ -51,7 +51,8 @@ Linux x64. Быстрая, без лишнего; офлайн-режим; се
|
|||
штрихкод с префиксом 20–29 (код + вес/цена) распознаётся автоматически.
|
||||
- Скидка на позицию и на чек (по праву).
|
||||
- Оплата: наличные / карта / смешанная; показывается сдача.
|
||||
- Отложенный чек — отложить и вернуться.
|
||||
- Отложенный чек — кнопка «Отложить» сохраняет текущий чек и открывает новый
|
||||
(обслужить другого покупателя), «Вернуть» — восстанавливает отложенный.
|
||||
|
||||
## Возврат
|
||||
- По чеку (номер или скан QR) — частичный или полный.
|
||||
|
|
|
|||
|
|
@ -75,4 +75,24 @@ public CartTotals Totals()
|
|||
}
|
||||
|
||||
public bool IsEmpty => _lines.Count == 0;
|
||||
|
||||
/// <summary>Снимок чека для отложенного чека (park/resume).</summary>
|
||||
public CartSnapshot Snapshot() => new(_lines.Select(Clone).ToList(), CartDiscount);
|
||||
|
||||
/// <summary>Восстановить отложенный чек поверх текущего (замена содержимого).</summary>
|
||||
public void Restore(CartSnapshot snapshot)
|
||||
{
|
||||
_lines.Clear();
|
||||
_lines.AddRange(snapshot.Lines.Select(Clone));
|
||||
CartDiscount = snapshot.CartDiscount;
|
||||
}
|
||||
|
||||
private static CartLine Clone(CartLine l) => new()
|
||||
{
|
||||
ProductId = l.ProductId, Name = l.Name, UnitCode = l.UnitCode, IsWeighed = l.IsWeighed,
|
||||
UnitPrice = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, VatPercent = l.VatPercent,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Отложенный чек — независимая копия строк корзины и скидки на чек.</summary>
|
||||
public sealed record CartSnapshot(IReadOnlyList<CartLine> Lines, decimal CartDiscount);
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ public partial class SaleViewModel : ViewModelBase
|
|||
{
|
||||
private readonly AppServices? _svc;
|
||||
private readonly Cart _cart = new();
|
||||
private readonly Stack<CartSnapshot> _parked = new();
|
||||
public ObservableCollection<SaleLineVm> Lines { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _scanInput = "";
|
||||
|
|
@ -319,6 +320,40 @@ public async Task CommitPaymentAsync(decimal cash, decimal card, decimal change)
|
|||
|
||||
[RelayCommand] private void Clear() { _cart.Clear(); Lines.Clear(); SelectedLine = null; Recalc(); Message = "Чек очищен"; }
|
||||
|
||||
public int ParkedCount => _parked.Count;
|
||||
|
||||
/// <summary>Отложить текущий чек и начать новый (обслужить другого покупателя).</summary>
|
||||
[RelayCommand]
|
||||
private void Park()
|
||||
{
|
||||
if (_cart.IsEmpty) { Message = "Нечего откладывать"; return; }
|
||||
_parked.Push(_cart.Snapshot());
|
||||
_cart.Clear(); Lines.Clear(); SelectedLine = null; Recalc();
|
||||
OnPropertyChanged(nameof(ParkedCount));
|
||||
Message = $"Чек отложен (отложенных: {_parked.Count})";
|
||||
}
|
||||
|
||||
/// <summary>Вернуть последний отложенный чек (поверх пустого).</summary>
|
||||
[RelayCommand]
|
||||
private void Resume()
|
||||
{
|
||||
if (_parked.Count == 0) { Message = "Нет отложенных чеков"; return; }
|
||||
if (!_cart.IsEmpty) { Message = "Сначала завершите или отложите текущий чек"; return; }
|
||||
_cart.Restore(_parked.Pop());
|
||||
RebuildLines();
|
||||
OnPropertyChanged(nameof(ParkedCount));
|
||||
Message = "Отложенный чек возвращён";
|
||||
}
|
||||
|
||||
private void RebuildLines()
|
||||
{
|
||||
Lines.Clear();
|
||||
foreach (var l in _cart.Lines)
|
||||
Lines.Insert(0, new SaleLineVm { ProductId = l.ProductId, Name = l.Name, Unit = l.UnitCode, Price = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, Model = l });
|
||||
SelectedLine = Lines.FirstOrDefault();
|
||||
Recalc();
|
||||
}
|
||||
|
||||
/// <summary>Пример заполненного чека для скриншотов/демо.</summary>
|
||||
public static SaleViewModel Sample()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@
|
|||
<Grid ColumnDefinitions="*,380" Background="#0f172a">
|
||||
<!-- Левая часть: чек -->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto" Margin="16,12,8,12">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,8">
|
||||
<TextBox Grid.Column="0" Classes="pos" Watermark="Штрихкод / поиск товара (Enter)"
|
||||
Text="{Binding ScanInput}" FontSize="22">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" Content="Отложить" Command="{Binding ParkCommand}"/>
|
||||
<Button Grid.Column="2" Classes="pos" Margin="6,0,0,0" Content="Вернуть" Command="{Binding ResumeCommand}"/>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0">
|
||||
|
|
|
|||
|
|
@ -375,3 +375,31 @@ public class PosUpdateCheckTests
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||