feat(pos): отложенный чек (park/resume) + снимок корзины
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

- Cart.Snapshot()/Restore() — независимая копия строк и скидки на чек.
- SaleViewModel: ParkCommand («Отложить» — сохранить чек, начать новый) и
  ResumeCommand («Вернуть» — восстановить отложенный); кнопки на экране продажи.
- Unit-тест независимости снимка (38 POS unit-тестов зелёные). Скриншоты обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 05:03:18 +05:00
parent 81085aed8b
commit b305cc9b9a
14 changed files with 92 additions and 6 deletions

View file

@ -51,8 +51,8 @@
5. [x] Продажа: сканер keyboard-wedge (Enter добавляет), поиск, количество, весовой товар 5. [x] Продажа: сканер keyboard-wedge (Enter добавляет), поиск, количество, весовой товар
(ручной ввод + COM CAS/Масса-К парсеры), весовые ШК 20–29, скидка на строку и на чек (ручной ввод + COM CAS/Масса-К парсеры), весовые ШК 20–29, скидка на строку и на чек
(распределяется по строкам, т.к. сервер понимает только построчную), удаление строки (Del), (распределяется по строкам, т.к. сервер понимает только построчную), удаление строки (Del),
нал/карта/смешанная + сдача, горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del, без мыши. нал/карта/смешанная + сдача, отложенный чек (park/resume, независимый снимок корзины),
(Отложенный чек — заготовка; базовый сценарий «скан→оплата→печать→очередь» работает и покрыт тестом.) горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del, без мыши. Сценарий «скан→оплата→печать→очередь» покрыт тестом.
6. [x] Возврат: `IsReturn` в контракте и на сервере — возврат добавляет остаток (CustomerReturn), 6. [x] Возврат: `IsReturn` в контракте и на сервере — возврат добавляет остаток (CustomerReturn),
наличные к выдаче, учитывается в смене; экран возврата по номеру/QR. Без чека — по праву наличные к выдаче, учитывается в смене; экран возврата по номеру/QR. Без чека — по праву
админа (флаг в чеке). Частичный — по позициям. (Полный интерактивный выбор позиций — экран-заготовка.) админа (флаг в чеке). Частичный — по позициям. (Полный интерактивный выбор позиций — экран-заготовка.)
@ -106,8 +106,8 @@
global.json пинит 8.0.417, он не стоит локально). Это НЕ связано с POS: ни один из падающих тестов global.json пинит 8.0.417, он не стоит локально). Это НЕ связано с POS: ни один из падающих тестов
и ни один файл EF-модели не менялись в этой задаче; все POS-тесты (unit/integration/UI) зелёные. и ни один файл EF-модели не менялись в этой задаче; все POS-тесты (unit/integration/UI) зелёные.
- Фискализация ОФД KZ — только заглушка (по заданию). - Фискализация ОФД KZ — только заглушка (по заданию).
- Отложенный чек и полный интерактивный выбор позиций возврата — экраны-заготовки; серверная и - Полный интерактивный выбор позиций при возврате по чеку — экран-заготовка (поиск/отметка позиций);
доменная части (возврат, привязка к смене) реализованы и покрыты тестами. серверная и доменная части возврата (IsReturn, +остаток, привязка к смене) реализованы и покрыты тестами.
## Коммиты ## Коммиты
(добавляются при коммите — тематические, в main, attribution Co-Authored-By: Claude Opus) (добавляются при коммите — тематические, в main, attribution Co-Authored-By: Claude Opus)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -51,7 +51,8 @@ Linux x64. Быстрая, без лишнего; офлайн-режим; се
штрихкод с префиксом 20–29 (код + вес/цена) распознаётся автоматически. штрихкод с префиксом 20–29 (код + вес/цена) распознаётся автоматически.
- Скидка на позицию и на чек (по праву). - Скидка на позицию и на чек (по праву).
- Оплата: наличные / карта / смешанная; показывается сдача. - Оплата: наличные / карта / смешанная; показывается сдача.
- Отложенный чек — отложить и вернуться. - Отложенный чек — кнопка «Отложить» сохраняет текущий чек и открывает новый
(обслужить другого покупателя), «Вернуть» — восстанавливает отложенный.
## Возврат ## Возврат
- По чеку (номер или скан QR) — частичный или полный. - По чеку (номер или скан QR) — частичный или полный.

View file

@ -75,4 +75,24 @@ public CartTotals Totals()
} }
public bool IsEmpty => _lines.Count == 0; 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);

View file

@ -214,6 +214,7 @@ public partial class SaleViewModel : ViewModelBase
{ {
private readonly AppServices? _svc; private readonly AppServices? _svc;
private readonly Cart _cart = new(); private readonly Cart _cart = new();
private readonly Stack<CartSnapshot> _parked = new();
public ObservableCollection<SaleLineVm> Lines { get; } = new(); public ObservableCollection<SaleLineVm> Lines { get; } = new();
[ObservableProperty] private string _scanInput = ""; [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 = "Чек очищен"; } [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> /// <summary>Пример заполненного чека для скриншотов/демо.</summary>
public static SaleViewModel Sample() public static SaleViewModel Sample()
{ {

View file

@ -6,13 +6,15 @@
<Grid ColumnDefinitions="*,380" Background="#0f172a"> <Grid ColumnDefinitions="*,380" Background="#0f172a">
<!-- Левая часть: чек --> <!-- Левая часть: чек -->
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto" Margin="16,12,8,12"> <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)" <TextBox Grid.Column="0" Classes="pos" Watermark="Штрихкод / поиск товара (Enter)"
Text="{Binding ScanInput}" FontSize="22"> Text="{Binding ScanInput}" FontSize="22">
<TextBox.KeyBindings> <TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}"/> <KeyBinding Gesture="Enter" Command="{Binding ScanCommand}"/>
</TextBox.KeyBindings> </TextBox.KeyBindings>
</TextBox> </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> </Grid>
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0"> <Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0">

View file

@ -375,3 +375,31 @@ public class PosUpdateCheckTests
public void Version_comparison(string? local, string? remote, bool expected) public void Version_comparison(string? local, string? remote, bool expected)
=> UpdateCheck.IsNewer(local, remote).Should().Be(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);
}
}