fix(pos-ui): формат чисел KZ/RU, скидка в строке, центровка/обрезка кнопок

По итогам проверки Mac-Claude (скриншоты):
- Формат чисел как в вебе/мобильном: «3 422 ₸» (пробел-разделитель тысяч),
  вес «1,235 кг» (запятая), единица измерения рядом с количеством («3 шт»).
  PosFormat — культура ru-RU с NBSP-разделителем, применяется процессно.
- Скидка на строку видна: «1 560 → 1 500 ₸, скидка 60» под позицией, чтобы
  цена×кол-во ≠ сумма не выглядело ошибкой (SaleLineVm.DiscountText/HasDiscount).
- Кнопки: текст по центру по вертикали и горизонтали (стиль Button.pos +
  ContentPresenter); «Очистить» и «Enter · Провести и печать» больше не
  обрезаются на 1366×768 и 1920×1080 (проверено скриншотами).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 09:07:29 +05:00
parent 87eb48cca9
commit c5ff1ade94
7 changed files with 105 additions and 13 deletions

View file

@ -134,3 +134,17 @@ Testcontainers) + headless UI (Avalonia.Headless, 15 проверок) + офл
- 08.10 — замеры Linux+Windows (п.9); холодный старт/поиск в цель. - 08.10 — замеры Linux+Windows (п.9); холодный старт/поиск в цель.
- 08.10 — серверная привязка продаж к смене + возвраты; +2 интеграционных теста; +офлайн/checkout/версия unit-тесты (37); headless UI-тест (--uitest). - 08.10 — серверная привязка продаж к смене + возвраты; +2 интеграционных теста; +офлайн/checkout/версия unit-тесты (37); headless UI-тест (--uitest).
- 08.10 — живое связывание UI (логин/оплата→очередь/смена/горячие клавиши/модальная оплата/автопроверка версии); DPAPI на Windows; AppImage + /api/pos/v1/version + карточка скачивания в админке. - 08.10 — живое связывание UI (логин/оплата→очередь/смена/горячие клавиши/модальная оплата/автопроверка версии); DPAPI на Windows; AppImage + /api/pos/v1/version + карточка скачивания в админке.
## Доводка 08.10 (по итогам проверки Mac-Claude)
1. [ ] Настоящий GUI-тест на Windows 11 (интерактивная сессия tester) + скриншоты реального окна.
2. [ ] Первый запуск на Windows 7.5с → <2с: R2R-сборка, trimming, замеры; Defender — в docs.
3. [ ] Выбор точки продаж при регистрации устройства (список активных, сохранение, смена — админ).
4. [ ] Быстрый вход кассира по PIN (4–6 цифр), хэш на сервере, блок после 5 ошибок.
5. [ ] Возврат по чеку — полноценный экран выбора позиций + тесты.
6. [ ] Повторный full-chain (C) + POS smoke Win/Linux зелёные; галерея обновлена.
7. [ ] Коммиты в main; push origin.
8. [x] Формат чисел KZ/RU: «3 422 ₸» (пробел тысяч), «1,235 кг» (запятая) + единица рядом с кол-вом.
Реализовано `PosFormat` (культура ru-RU с NBSP-разделителем), применяется процессно.
9. [x] Скидка на строку видна: «1 560 → 1 500 ₸, скидка 60» под позицией (SaleLineVm.DiscountText).
10. [x] Кнопки: текст по центру по вертикали/горизонтали (стиль Button.pos), «Очистить» и
«Enter · Провести и печать» больше не обрезаются (проверено на 1366×768 и 1920×1080).

View file

@ -12,10 +12,23 @@
<Style Selector="Button.pos"> <Style Selector="Button.pos">
<Setter Property="Height" Value="56" /> <Setter Property="Height" Value="56" />
<Setter Property="FontSize" Value="18" /> <Setter Property="FontSize" Value="18" />
<Setter Property="Padding" Value="18,0" /> <Setter Property="Padding" Value="12,0" />
<Setter Property="CornerRadius" Value="8" /> <Setter Property="CornerRadius" Value="8" />
<Setter Property="Background" Value="#2d3748" /> <Setter Property="Background" Value="#2d3748" />
<Setter Property="Foreground" Value="#f7fafc" /> <Setter Property="Foreground" Value="#f7fafc" />
<!-- Текст строго по центру кнопки (и по вертикали, и по горизонтали). -->
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<!-- Содержимое кнопок кассы не обрезаем: при нехватке ширины переносим,
текст по центру. Избавляет от «Esc · Очистит…». -->
<Style Selector="Button.pos /template/ ContentPresenter">
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.pos TextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="TextAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Button.primary"> <Style Selector="Button.primary">
<Setter Property="Background" Value="#16a34a" /> <Setter Property="Background" Value="#16a34a" />

View file

@ -0,0 +1,35 @@
using System.Globalization;
namespace foodmarket.Pos;
/// <summary>Единый формат чисел кассы — как в вебе/мобильном (KZ/RU):
/// разделитель тысяч — пробел («3 422 ₸»), десятичный — запятая («1,235 кг»).
/// Применяется процессно (CurrentCulture + дефолт новых потоков), чтобы и
/// привязки XAML (StringFormat), и Toin-code форматирование совпадали.</summary>
public static class PosFormat
{
public static readonly CultureInfo Culture = BuildCulture();
private static CultureInfo BuildCulture()
{
// База — ru-RU (запятая-десятичный). Явно выставляем пробел-разделитель
// тысяч (NBSP), чтобы не зависеть от версии ICU/окружения.
var c = (CultureInfo)CultureInfo.GetCultureInfo("ru-RU").Clone();
const string sep = " "; // неразрывный пробол
c.NumberFormat.NumberGroupSeparator = sep;
c.NumberFormat.CurrencyGroupSeparator = sep;
c.NumberFormat.PercentGroupSeparator = sep;
c.NumberFormat.NumberDecimalSeparator = ",";
c.NumberFormat.CurrencyDecimalSeparator = ",";
return c;
}
/// <summary>Выставить кассовую культуру текущему и новым потокам.</summary>
public static void Apply()
{
CultureInfo.CurrentCulture = Culture;
CultureInfo.CurrentUICulture = Culture;
CultureInfo.DefaultThreadCurrentCulture = Culture;
CultureInfo.DefaultThreadCurrentUICulture = Culture;
}
}

View file

@ -8,6 +8,7 @@ internal static class Program
[STAThread] [STAThread]
public static int Main(string[] args) public static int Main(string[] args)
{ {
PosFormat.Apply(); // единый формат чисел (KZ/RU: «3 422 ₸», «1,235 кг»)
// CLI-режимы (без интерактивного окна): // CLI-режимы (без интерактивного окна):
// --smoke : прогон сценария кассы через логику (без UI), печать результатов. // --smoke : прогон сценария кассы через логику (без UI), печать результатов.
// --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768). // --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768).

View file

@ -202,10 +202,29 @@ public partial class SaleLineVm : ObservableObject
[ObservableProperty] private decimal _price; [ObservableProperty] private decimal _price;
[ObservableProperty] private decimal _quantity = 1m; [ObservableProperty] private decimal _quantity = 1m;
[ObservableProperty] private decimal _discount; [ObservableProperty] private decimal _discount;
public decimal LineTotal => Math.Max(0m, decimal.Round(Price * Quantity, 2) - Discount); public decimal Gross => decimal.Round(Price * Quantity, 2);
public string QtyText => Quantity == decimal.Truncate(Quantity) && Unit == "шт" ? Quantity.ToString("0") : Quantity.ToString("0.###"); public decimal LineTotal => Math.Max(0m, Gross - Discount);
partial void OnQuantityChanged(decimal value) { OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(QtyText)); } /// <summary>Количество с единицей измерения: «3 шт», «1,235 кг».</summary>
partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(LineTotal)); public string QtyText
{
get
{
var n = Quantity == decimal.Truncate(Quantity) && Unit == "шт"
? Quantity.ToString("0")
: Quantity.ToString("0.###");
return $"{n} {Unit}";
}
}
public bool HasDiscount => Discount > 0m;
/// <summary>Пояснение скидки в строке, чтобы цена×кол-во ≠ сумма не выглядело
/// ошибкой: «1 560 → 1 500 ₸, скидка 60».</summary>
public string DiscountText => $"{Gross:N0} → {LineTotal:N0} ₸, скидка {Discount:N0}";
partial void OnQuantityChanged(decimal value)
{ OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(QtyText)); OnPropertyChanged(nameof(Gross)); OnPropertyChanged(nameof(DiscountText)); }
partial void OnDiscountChanged(decimal value)
{ OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(HasDiscount)); OnPropertyChanged(nameof(DiscountText)); }
partial void OnPriceChanged(decimal value)
{ OnPropertyChanged(nameof(Gross)); OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(DiscountText)); }
} }
/// <summary>Экран продажи — сердце кассы. Пробитие по сканеру/поиску, корзина, /// <summary>Экран продажи — сердце кассы. Пробитие по сканеру/поиску, корзина,

View file

@ -28,7 +28,9 @@
</Border> </Border>
<Grid ColumnDefinitions="*,2*"> <Grid ColumnDefinitions="*,2*">
<Button Classes="pos" Margin="0,0,6,0" HorizontalAlignment="Stretch" Height="64" FontSize="16" Content="Без сдачи" Command="{Binding ExactCashCommand}"/> <Button Classes="pos" Margin="0,0,6,0" HorizontalAlignment="Stretch" Height="64" FontSize="16" Content="Без сдачи" Command="{Binding ExactCashCommand}"/>
<Button Grid.Column="1" Classes="pos primary" HorizontalAlignment="Stretch" Height="64" FontSize="20" Content="Enter · Провести и напечатать чек" Command="{Binding ConfirmCommand}" IsDefault="True" IsEnabled="{Binding Sufficient}"/> <Button Grid.Column="1" Classes="pos primary" HorizontalAlignment="Stretch" Height="64" FontSize="19" Command="{Binding ConfirmCommand}" IsDefault="True" IsEnabled="{Binding Sufficient}">
<TextBlock Text="Enter · Провести и печать" TextWrapping="Wrap" TextAlignment="Center"/>
</Button>
</Grid> </Grid>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="44" Content="Esc · Отмена" Command="{Binding CancelCommand}"/> <Button Classes="pos" HorizontalAlignment="Stretch" Height="44" Content="Esc · Отмена" Command="{Binding CancelCommand}"/>
</StackPanel> </StackPanel>

View file

@ -19,7 +19,7 @@
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0"> <Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0">
<DockPanel> <DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,90,120,120" Background="#1f2937" Height="38"> <Grid DockPanel.Dock="Top" ColumnDefinitions="*,110,110,120" Background="#1f2937" Height="38">
<TextBlock Text=" Товар" Foreground="#9ca3af" VerticalAlignment="Center"/> <TextBlock Text=" Товар" Foreground="#9ca3af" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="Кол-во" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/> <TextBlock Grid.Column="1" Text="Кол-во" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<TextBlock Grid.Column="2" Text="Цена" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/> <TextBlock Grid.Column="2" Text="Цена" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
@ -28,9 +28,11 @@
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0"> <ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SaleLineVm"> <DataTemplate x:DataType="vm:SaleLineVm">
<Grid ColumnDefinitions="*,90,120,120" Height="46"> <Grid ColumnDefinitions="*,110,110,120" MinHeight="46">
<StackPanel VerticalAlignment="Center"> <StackPanel VerticalAlignment="Center" Margin="0,4">
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16"/> <TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" TextWrapping="Wrap"/>
<TextBlock Text="{Binding DiscountText}" Foreground="#fca5a5" FontSize="12.5"
IsVisible="{Binding HasDiscount}"/>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="1" Text="{Binding QtyText}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/> <TextBlock Grid.Column="1" Text="{Binding QtyText}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Price, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/> <TextBlock Grid.Column="2" Text="{Binding Price, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
@ -59,10 +61,16 @@
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="34" FontWeight="Bold" HorizontalAlignment="Right"/> <TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="34" FontWeight="Bold" HorizontalAlignment="Right"/>
</Grid> </Grid>
<StackPanel Grid.Row="4" Spacing="8"> <StackPanel Grid.Row="4" Spacing="8">
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="68" FontSize="22" Content="F12 · ОПЛАТА"/> <Button Classes="pos primary" HorizontalAlignment="Stretch" Height="68" FontSize="22" Command="{Binding PayCommand}">
<TextBlock Text="F12 · ОПЛАТА" TextAlignment="Center"/>
</Button>
<Grid ColumnDefinitions="*,*" > <Grid ColumnDefinitions="*,*" >
<Button Classes="pos" Margin="0,0,4,0" HorizontalAlignment="Stretch" Content="Скидка"/> <Button Classes="pos" Margin="0,0,4,0" FontSize="16" HorizontalAlignment="Stretch">
<Button Grid.Column="1" Classes="pos danger" Margin="4,0,0,0" HorizontalAlignment="Stretch" Content="Esc · Очистить" Command="{Binding ClearCommand}"/> <TextBlock Text="Скидка" TextAlignment="Center"/>
</Button>
<Button Grid.Column="1" Classes="pos danger" Margin="4,0,0,0" FontSize="16" HorizontalAlignment="Stretch" Command="{Binding ClearCommand}" ToolTip.Tip="Очистить чек (Esc)">
<TextBlock Text="Очистить" TextWrapping="Wrap" TextAlignment="Center"/>
</Button>
</Grid> </Grid>
</StackPanel> </StackPanel>
</Grid> </Grid>