diff --git a/docs/pos-screenshots/04-sale-items-1366x768.png b/docs/pos-screenshots/04-sale-items-1366x768.png index ab53ed6..e2d6bfc 100644 Binary files a/docs/pos-screenshots/04-sale-items-1366x768.png and b/docs/pos-screenshots/04-sale-items-1366x768.png differ diff --git a/docs/pos-screenshots/04-sale-items-1920x1080.png b/docs/pos-screenshots/04-sale-items-1920x1080.png index a0b7657..fcab9c5 100644 Binary files a/docs/pos-screenshots/04-sale-items-1920x1080.png and b/docs/pos-screenshots/04-sale-items-1920x1080.png differ diff --git a/docs/pos-screenshots/09-shell-offline-1366x768.png b/docs/pos-screenshots/09-shell-offline-1366x768.png index a5c35ef..c40851c 100644 Binary files a/docs/pos-screenshots/09-shell-offline-1366x768.png and b/docs/pos-screenshots/09-shell-offline-1366x768.png differ diff --git a/docs/pos-screenshots/09-shell-offline-1920x1080.png b/docs/pos-screenshots/09-shell-offline-1920x1080.png index 317ed6a..db24f95 100644 Binary files a/docs/pos-screenshots/09-shell-offline-1920x1080.png and b/docs/pos-screenshots/09-shell-offline-1920x1080.png differ diff --git a/docs/pos-screenshots/10-shell-online-1366x768.png b/docs/pos-screenshots/10-shell-online-1366x768.png index 5d26866..ac0d43d 100644 Binary files a/docs/pos-screenshots/10-shell-online-1366x768.png and b/docs/pos-screenshots/10-shell-online-1366x768.png differ diff --git a/docs/pos-screenshots/10-shell-online-1920x1080.png b/docs/pos-screenshots/10-shell-online-1920x1080.png index 44fa745..e6d55fa 100644 Binary files a/docs/pos-screenshots/10-shell-online-1920x1080.png and b/docs/pos-screenshots/10-shell-online-1920x1080.png differ diff --git a/docs/pos-screenshots/11-shell-dark-1366x768.png b/docs/pos-screenshots/11-shell-dark-1366x768.png new file mode 100644 index 0000000..661268c Binary files /dev/null and b/docs/pos-screenshots/11-shell-dark-1366x768.png differ diff --git a/docs/pos-screenshots/11-shell-dark-1920x1080.png b/docs/pos-screenshots/11-shell-dark-1920x1080.png new file mode 100644 index 0000000..872f6de Binary files /dev/null and b/docs/pos-screenshots/11-shell-dark-1920x1080.png differ diff --git a/docs/pos-screenshots/index.html b/docs/pos-screenshots/index.html index c82e919..a3e4872 100644 --- a/docs/pos-screenshots/index.html +++ b/docs/pos-screenshots/index.html @@ -1 +1 @@ -Касса food-market — скриншоты

Касса food-market — скриншоты процессов

Светлая тема (Avalonia/Skia, headless, Linux) — 1920×1080 и 1366×768. Кадров: 22

Стало (светлая тема)

Вход кассира (адрес сервера, логин, пароль) (1920x1080)
Вход кассира (адрес сервера, логин, пароль) (1366x768)
Быстрый вход по PIN (зарегистрированное устройство) (1920x1080)
Быстрый вход по PIN (зарегистрированное устройство) (1366x768)
Смена: Z-отчёт/состояние смены (1920x1080)
Смена: Z-отчёт/состояние смены (1366x768)
Продажа: пустой чек, ожидание сканирования (1920x1080)
Продажа: пустой чек, ожидание сканирования (1366x768)
Продажа: чек с товарами (вкл. весовой) (1920x1080)
Продажа: чек с товарами (вкл. весовой) (1366x768)
Оплата наличными со сдачей (1920x1080)
Оплата наличными со сдачей (1366x768)
Оплата смешанная (нал+карта) (1920x1080)
Оплата смешанная (нал+карта) (1366x768)
Возврат по чеку (выбор позиций) (1920x1080)
Возврат по чеку (выбор позиций) (1366x768)
Z-отчёт при закрытии (должно/факт/разница) (1920x1080)
Z-отчёт при закрытии (должно/факт/разница) (1366x768)
Статус-бар: нет связи, чеки в очереди (1920x1080)
Статус-бар: нет связи, чеки в очереди (1366x768)
Статус-бар: на связи, очередь пуста (1920x1080)
Статус-бар: на связи, очередь пуста (1366x768)

Было (тёмная тема)

для сравнения — прежний мрачный интерфейс
01-login-1366x768
01-login-1920x1080
01b-login-pin-1366x768
01b-login-pin-1920x1080
02-shift-open-1366x768
02-shift-open-1920x1080
03-sale-empty-1366x768
03-sale-empty-1920x1080
04-sale-items-1366x768
04-sale-items-1920x1080
05-payment-cash-1366x768
05-payment-cash-1920x1080
06-payment-mixed-1366x768
06-payment-mixed-1920x1080
07-return-1366x768
07-return-1920x1080
08-shift-z-1366x768
08-shift-z-1920x1080
09-shell-offline-1366x768
09-shell-offline-1920x1080
10-shell-online-1366x768
10-shell-online-1920x1080

Windows 11 — реальное окно

Снято на Windows 11 (VM, software-рендер — у VM нет GPU; консоль 1280×800). Подтверждает нативный рендер: кириллица, формат чисел «3 362 ₸», кнопки без обрезки. Высокое разрешение 1366/1920 — в headless-кадрах выше (тот же движок Avalonia/Skia/Inter).
Windows 11 — экран продажи (реальное окно)
\ No newline at end of file +Касса food-market — скриншоты

Касса food-market — скриншоты процессов

Светлая тема (Avalonia/Skia, headless, Linux) — 1920×1080 и 1366×768. Кадров: 24

Стало (светлая тема)

Вход кассира (адрес сервера, логин, пароль) (1920x1080)
Вход кассира (адрес сервера, логин, пароль) (1366x768)
Быстрый вход по PIN (зарегистрированное устройство) (1920x1080)
Быстрый вход по PIN (зарегистрированное устройство) (1366x768)
Смена: Z-отчёт/состояние смены (1920x1080)
Смена: Z-отчёт/состояние смены (1366x768)
Продажа: пустой чек, ожидание сканирования (1920x1080)
Продажа: пустой чек, ожидание сканирования (1366x768)
Продажа: чек с товарами (вкл. весовой) (1920x1080)
Продажа: чек с товарами (вкл. весовой) (1366x768)
Оплата наличными со сдачей (1920x1080)
Оплата наличными со сдачей (1366x768)
Оплата смешанная (нал+карта) (1920x1080)
Оплата смешанная (нал+карта) (1366x768)
Возврат по чеку (выбор позиций) (1920x1080)
Возврат по чеку (выбор позиций) (1366x768)
Z-отчёт при закрытии (должно/факт/разница) (1920x1080)
Z-отчёт при закрытии (должно/факт/разница) (1366x768)
Статус-бар: нет связи, чеки в очереди (1920x1080)
Статус-бар: нет связи, чеки в очереди (1366x768)
Статус-бар: на связи, очередь пуста (1920x1080)
Статус-бар: на связи, очередь пуста (1366x768)
Тёмная тема (опция в настройках, не по умолчанию) (1920x1080)
Тёмная тема (опция в настройках, не по умолчанию) (1366x768)

Было (тёмная тема)

для сравнения — прежний мрачный интерфейс
01-login-1366x768
01-login-1920x1080
01b-login-pin-1366x768
01b-login-pin-1920x1080
02-shift-open-1366x768
02-shift-open-1920x1080
03-sale-empty-1366x768
03-sale-empty-1920x1080
04-sale-items-1366x768
04-sale-items-1920x1080
05-payment-cash-1366x768
05-payment-cash-1920x1080
06-payment-mixed-1366x768
06-payment-mixed-1920x1080
07-return-1366x768
07-return-1920x1080
08-shift-z-1366x768
08-shift-z-1920x1080
09-shell-offline-1366x768
09-shell-offline-1920x1080
10-shell-online-1366x768
10-shell-online-1920x1080

Windows 11 — реальное окно

Снято на Windows 11 (VM, software-рендер — у VM нет GPU; консоль 1280×800). Подтверждает нативный рендер: кириллица, формат чисел «3 362 ₸», кнопки без обрезки. Высокое разрешение 1366/1920 — в headless-кадрах выше (тот же движок Avalonia/Skia/Inter).
Windows 11 — экран продажи (реальное окно)
\ No newline at end of file diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index ddd0d7e..8bc165d 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -1300,5 +1300,7 @@ private static void Apply(Product e, ProductInput i) } e.PurchaseCurrencyId = i.PurchaseCurrencyId; e.ImageUrl = i.ImageUrl; + e.IsFavorite = i.IsFavorite; + e.FavoriteOrder = i.FavoriteOrder; } } diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index fb87d00..94ec073 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -145,6 +145,8 @@ orderby l.SortOrder // Product не имеет IsArchived в текущей доменной модели; всегда false. // Когда добавим архив — заполняется здесь, без правки контракта. IsArchived = false, + IsFavorite = x.p.IsFavorite, + FavoriteOrder = x.p.FavoriteOrder, UpdatedAt = x.stamp, }).ToList(); diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index 740cc3e..2f61585 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -100,4 +100,5 @@ public record ProductInput( [Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null, [StringLength(1000)] string? ImageUrl = null, IReadOnlyList? Prices = null, - IReadOnlyList? Barcodes = null); + IReadOnlyList? Barcodes = null, + bool IsFavorite = false, int FavoriteOrder = 0); diff --git a/src/food-market.domain/Catalog/Product.cs b/src/food-market.domain/Catalog/Product.cs index 0e937ea..6e3d25c 100644 --- a/src/food-market.domain/Catalog/Product.cs +++ b/src/food-market.domain/Catalog/Product.cs @@ -73,6 +73,12 @@ public class Product : TenantEntity /// вне сезона), архивный — навсегда (списан). public bool IsAvailableForSale { get; set; } = true; + /// Товар в «Избранном» кассы — показывается плиткой на экране продажи + /// (обычно товары без штрихкода: хлеб, овощи на вес). Настраивает админ. + public bool IsFavorite { get; set; } + /// Порядок плитки в «Избранном» (меньше — раньше). + public int FavoriteOrder { get; set; } + public ICollection Prices { get; set; } = []; public ICollection Barcodes { get; set; } = []; public ICollection Images { get; set; } = []; diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261008120000_Phase37_ProductFavorite.cs b/src/food-market.infrastructure/Persistence/Migrations/20261008120000_Phase37_ProductFavorite.cs new file mode 100644 index 0000000..95f1725 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261008120000_Phase37_ProductFavorite.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase37 — «Избранное» кассы: товары-плитки на экране продажи + /// (обычно без штрихкода — хлеб, овощи на вес). Добавляет в products: + /// + /// IsFavorite boolean DEFAULT false. + /// FavoriteOrder integer DEFAULT 0. + /// + /// Идемпотентно. Отдаётся в POS-синке (ProductSyncDto). + [DbContext(typeof(AppDbContext))] + [Migration("20261008120000_Phase37_ProductFavorite")] + public partial class Phase37_ProductFavorite : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.products ADD COLUMN IF NOT EXISTS ""IsFavorite"" boolean NOT NULL DEFAULT false; + ALTER TABLE public.products ADD COLUMN IF NOT EXISTS ""FavoriteOrder"" integer NOT NULL DEFAULT 0; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.products DROP COLUMN IF EXISTS ""IsFavorite""; + ALTER TABLE public.products DROP COLUMN IF EXISTS ""FavoriteOrder""; + "); + } + } +} diff --git a/src/food-market.pos.core/Data/PosStore.cs b/src/food-market.pos.core/Data/PosStore.cs index ad8002c..d223961 100644 --- a/src/food-market.pos.core/Data/PosStore.cs +++ b/src/food-market.pos.core/Data/PosStore.cs @@ -34,7 +34,8 @@ private void InitSchema() CREATE TABLE IF NOT EXISTS products( Id TEXT PRIMARY KEY, Name TEXT NOT NULL, NameLower TEXT NOT NULL DEFAULT '', Article TEXT, UnitCode TEXT NOT NULL, Packaging INTEGER NOT NULL, VatPercent TEXT NOT NULL, VatEnabled INTEGER NOT NULL, - Price TEXT NOT NULL DEFAULT '0', IsArchived INTEGER NOT NULL DEFAULT 0, UpdatedAt TEXT); + Price TEXT NOT NULL DEFAULT '0', IsArchived INTEGER NOT NULL DEFAULT 0, UpdatedAt TEXT, + IsFavorite INTEGER NOT NULL DEFAULT 0, FavoriteOrder INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS barcodes(Code TEXT NOT NULL, ProductId TEXT NOT NULL); CREATE INDEX IF NOT EXISTS ix_barcodes_code ON barcodes(Code); CREATE INDEX IF NOT EXISTS ix_barcodes_productid ON barcodes(ProductId); @@ -43,6 +44,15 @@ private void InitSchema() ClientSaleId TEXT PRIMARY KEY, Json TEXT NOT NULL, Status INTEGER NOT NULL DEFAULT 0, CreatedAt TEXT NOT NULL, Error TEXT); CREATE TABLE IF NOT EXISTS kv(Key TEXT PRIMARY KEY, Value TEXT);"); + // Для БД, созданных до «Избранного» — добавляем колонки (SQLite не умеет + // IF NOT EXISTS для ADD COLUMN, поэтому игнорируем ошибку «duplicate column»). + TryExec("ALTER TABLE products ADD COLUMN IsFavorite INTEGER NOT NULL DEFAULT 0"); + TryExec("ALTER TABLE products ADD COLUMN FavoriteOrder INTEGER NOT NULL DEFAULT 0"); + } + + private void TryExec(string sql) + { + try { Exec(sql); } catch { /* колонка уже есть */ } } // ── каталог ────────────────────────────────────────────────────────────── @@ -57,11 +67,11 @@ public void ApplySync(PosSyncResponse sync) // Подготовленные команды с переиспользуемыми параметрами — массовая // вставка 30k товаров за ~1с вместо десятков секунд (команда на строку). var up = _cn.CreateCommand(); up.Transaction = tx; - up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt) - VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up) + up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt,IsFavorite,FavoriteOrder) + VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up,$fav,$favo) ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve, - Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up;"; - var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up"); + Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up, IsFavorite=$fav, FavoriteOrder=$favo;"; + var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up", "$fav", "$favo"); up.Prepare(); var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id"; var delP = AddParams(delBc, "$id"); delBc.Prepare(); @@ -79,6 +89,7 @@ public void ApplySync(PosSyncResponse sync) upP["$ve"].Value = p.VatEnabled ? 1 : 0; upP["$pr"].Value = price.ToString(System.Globalization.CultureInfo.InvariantCulture); upP["$hasPrice"].Value = hasPrice ? 1 : 0; upP["$ar"].Value = p.IsArchived ? 1 : 0; upP["$up"].Value = p.UpdatedAt.ToString("O"); + upP["$fav"].Value = p.IsFavorite ? 1 : 0; upP["$favo"].Value = p.FavoriteOrder; up.ExecuteNonQuery(); delP["$id"].Value = idStr; delBc.ExecuteNonQuery(); foreach (var bc in p.Barcodes) @@ -123,6 +134,18 @@ public int ProductCount() return r.Read() ? Map(r) : null; } + /// Избранные товары для плиток «быстрых товаров» на экране продажи. + public IReadOnlyList GetFavorites(int limit = 12) + { + using var cmd = _cn.CreateCommand(); + cmd.CommandText = "SELECT * FROM products WHERE IsFavorite=1 AND IsArchived=0 ORDER BY FavoriteOrder, Name LIMIT $l"; + Bind(cmd, "$l", limit.ToString()); + var list = new List(); + using var r = cmd.ExecuteReader(); + while (r.Read()) list.Add(Map(r)); + return list; + } + public IReadOnlyList SearchByName(string term, int limit = 50) { term = (term ?? string.Empty).Trim(); diff --git a/src/food-market.pos.core/Models/PosModels.cs b/src/food-market.pos.core/Models/PosModels.cs index 9b41dcb..726df57 100644 --- a/src/food-market.pos.core/Models/PosModels.cs +++ b/src/food-market.pos.core/Models/PosModels.cs @@ -18,6 +18,7 @@ public sealed class PosProduct public required bool VatEnabled { get; init; } public required decimal Price { get; init; } public bool IsArchived { get; init; } + public bool IsFavorite { get; init; } public bool IsWeighed => Packaging == PosPackaging.Weight; } diff --git a/src/food-market.pos/App.axaml.cs b/src/food-market.pos/App.axaml.cs index d5d46a7..ea0243e 100644 --- a/src/food-market.pos/App.axaml.cs +++ b/src/food-market.pos/App.axaml.cs @@ -32,6 +32,8 @@ public override void OnFrameworkInitializationCompleted() else { var services = new AppServices(); + // Тема устройства (по умолчанию светлая; тёмная — опция в настройках). + try { if (services.Store.GetKv("device.theme") == "Dark") RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Dark; } catch { } desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel(services) }; } } diff --git a/src/food-market.pos/Tooling/ScreenshotRunner.cs b/src/food-market.pos/Tooling/ScreenshotRunner.cs index 43db87a..46cb18d 100644 --- a/src/food-market.pos/Tooling/ScreenshotRunner.cs +++ b/src/food-market.pos/Tooling/ScreenshotRunner.cs @@ -41,6 +41,7 @@ public static int Run(string[] args) new("08-shift-z", "Z-отчёт при закрытии (должно/факт/разница)", () => new ShiftView { DataContext = ShiftViewModel.SampleClosed() }), new("09-shell-offline", "Статус-бар: нет связи, чеки в очереди", () => ShellOffline()), new("10-shell-online", "Статус-бар: на связи, очередь пуста", () => ShellOnline()), + new("11-shell-dark", "Тёмная тема (опция в настройках, не по умолчанию)", () => ShellDark()), }; var sizes = new (string suffix, int w, int h)[] { ("1920x1080", 1920, 1080), ("1366x768", 1366, 768) }; @@ -80,6 +81,13 @@ private static Control ShellOnline() var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" }; return new MainWindow { DataContext = vm }; } + private static Control ShellDark() + { + // Переключаем тему на тёмную для этого кадра (последний — не влияет на прочие). + if (Avalonia.Application.Current is { } app) app.RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Dark; + var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" }; + return new MainWindow { DataContext = vm }; + } private static void WriteGallery(string dir, List<(string file, string caption)> shots) { diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs index 936a046..1039255 100644 --- a/src/food-market.pos/ViewModels/ViewModels.cs +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -94,6 +94,18 @@ private static string LocalVersion() [RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } }; [RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc); + /// Переключить светлую/тёмную тему (тёмная — опция, по умолчанию светлая). + /// Выбор сохраняется на устройстве. + [RelayCommand] + private void ToggleTheme() + { + var app = Avalonia.Application.Current; + if (app is null) return; + var dark = app.ActualThemeVariant != Avalonia.Styling.ThemeVariant.Dark; + app.RequestedThemeVariant = dark ? Avalonia.Styling.ThemeVariant.Dark : Avalonia.Styling.ThemeVariant.Light; + _svc?.Store.SetKv("device.theme", dark ? "Dark" : "Light"); + } + private void OpenPayment(PaymentViewModel pvm) { pvm.OnCancel = ClosePayment; @@ -365,6 +377,9 @@ public partial class SaleViewModel : ViewModelBase private readonly Cart _cart = new(); private readonly Stack _parked = new(); public ObservableCollection Lines { get; } = new(); + public ObservableCollection Favorites { get; } = new(); + [ObservableProperty] private bool _favoritesOpen = true; + public bool HasFavorites => Favorites.Count > 0; [ObservableProperty] private string _scanInput = ""; [ObservableProperty] private string _shiftNumber = "С-2026-000001"; @@ -385,7 +400,17 @@ public partial class SaleViewModel : ViewModelBase public Action? SaleCommitted; public SaleViewModel() { } - public SaleViewModel(AppServices svc) { _svc = svc; } + public SaleViewModel(AppServices svc) { _svc = svc; LoadFavorites(); } + + private void LoadFavorites() + { + Favorites.Clear(); + if (_svc is not null) foreach (var p in _svc.Store.GetFavorites()) Favorites.Add(p); + OnPropertyChanged(nameof(HasFavorites)); + } + + [RelayCommand] private void AddFavorite(PosProduct p) => AddProduct(p, 1m); + [RelayCommand] private void ToggleFavorites() => FavoritesOpen = !FavoritesOpen; private void Recalc() { @@ -531,6 +556,13 @@ public static SaleViewModel Sample() vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2); vm.ItemCount = vm.Lines.Count; vm.Message = "Чек готов к оплате — F12 Оплата"; + PosProduct Fav(string n, string unit, decimal price, bool weighed) => new() + { Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty(), UnitCode = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true }; + foreach (var f in new[] { + Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2.5%", "шт", 450m, false), + Fav("Яблоки (вес)", "кг", 690m, true), Fav("Картофель (вес)", "кг", 280m, true), + Fav("Пакет-майка", "шт", 20m, false), Fav("Вода 0,5л", "шт", 150m, false) }) + vm.Favorites.Add(f); return vm; } } diff --git a/src/food-market.pos/Views/MainWindow.axaml b/src/food-market.pos/Views/MainWindow.axaml index 4077d77..f0747be 100644 --- a/src/food-market.pos/Views/MainWindow.axaml +++ b/src/food-market.pos/Views/MainWindow.axaml @@ -32,6 +32,10 @@ + diff --git a/src/food-market.pos/Views/SaleView.axaml b/src/food-market.pos/Views/SaleView.axaml index e9fbae0..7457b68 100644 --- a/src/food-market.pos/Views/SaleView.axaml +++ b/src/food-market.pos/Views/SaleView.axaml @@ -5,8 +5,8 @@ x:DataType="vm:SaleViewModel"> - - + + @@ -20,8 +20,30 @@ + + + + + + + + + + - + @@ -74,7 +96,7 @@ - + diff --git a/src/food-market.shared/Pos/V1/SyncDtos.cs b/src/food-market.shared/Pos/V1/SyncDtos.cs index 8966f38..dd52016 100644 --- a/src/food-market.shared/Pos/V1/SyncDtos.cs +++ b/src/food-market.shared/Pos/V1/SyncDtos.cs @@ -36,6 +36,10 @@ public record ProductSyncDto public required bool IsMarked { get; init; } /// true — товар архивный, на кассе не продаётся, оставлен для отчётов. public required bool IsArchived { get; init; } + /// Товар в «Избранном» кассы (плитка на экране продажи). + public bool IsFavorite { get; init; } + /// Порядок плитки в «Избранном». + public int FavoriteOrder { get; init; } public DateTime UpdatedAt { get; init; } } diff --git a/tests/food-market.UnitTests/PosCoreTests.cs b/tests/food-market.UnitTests/PosCoreTests.cs index d7b6e89..d51f356 100644 --- a/tests/food-market.UnitTests/PosCoreTests.cs +++ b/tests/food-market.UnitTests/PosCoreTests.cs @@ -436,3 +436,31 @@ public void Cashier_not_assigned_to_device_point_is_blocked() => DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { B }) .Should().Be(DeviceRegistration.Decision.NotAssigned); } + +public class PosFavoritesTests +{ + private static ProductSyncDto P(Guid id, string name, bool fav, int order) => new() + { + Id = id, Name = name, Barcodes = Array.Empty(), UnitCode = "шт", Packaging = 1, + VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, + IsFavorite = fav, FavoriteOrder = order, UpdatedAt = DateTime.UtcNow, + }; + + [Fact] + public void GetFavorites_returns_only_favorites_in_order() + { + using var s = PosStore.InMemory(); + var a = Guid.NewGuid(); var b = Guid.NewGuid(); var c = Guid.NewGuid(); + s.ApplySync(new PosSyncResponse + { + ServerTime = DateTime.UtcNow, + Products = new[] { P(a, "Обычный", false, 0), P(b, "Хлеб", true, 2), P(c, "Молоко", true, 1) }, + Prices = Array.Empty(), Stocks = Array.Empty(), + Counterparties = Array.Empty(), DeletedProductIds = Array.Empty(), + }); + + var favs = s.GetFavorites(); + favs.Select(f => f.Name).Should().Equal("Молоко", "Хлеб"); // по FavoriteOrder 1,2 + favs.Should().OnlyContain(f => f.Name != "Обычный"); + } +}