Compare commits

..

No commits in common. "feature/avalonia-ui" and "v2.0.0" have entirely different histories.

41 changed files with 191 additions and 1440 deletions

View File

@ -1,30 +0,0 @@
name: Desktop tests
# Автозапуск юнит-тестов десктопа при изменениях кода.
# Работает и в GitHub Actions, и в Gitea Actions (если настроены раннеры).
on:
push:
paths:
- 'src/Elfisa.Core/**'
- 'src/Elfisa.Avalonia/**'
- 'tests/**'
- '.github/workflows/desktop-tests.yml'
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore tests/Elfisa.Tests/Elfisa.Tests.csproj
- name: Test
run: dotnet test tests/Elfisa.Tests/Elfisa.Tests.csproj -c Release --no-restore --logger "console;verbosity=minimal"

View File

@ -13,14 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.ButtonSkillDemo", "s
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.GradientButtonDemo", "src\Elfisa.GradientButtonDemo\Elfisa.GradientButtonDemo.csproj", "{19457083-89B8-4C5B-BCA1-BFB66C273502}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.GradientButtonDemo", "src\Elfisa.GradientButtonDemo\Elfisa.GradientButtonDemo.csproj", "{19457083-89B8-4C5B-BCA1-BFB66C273502}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Tests", "tests\Elfisa.Tests\Elfisa.Tests.csproj", "{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Core", "src\Elfisa.Core\Elfisa.Core.csproj", "{40529B23-A30E-4BCF-B706-254DB547A643}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Avalonia", "src\Elfisa.Avalonia\Elfisa.Avalonia.csproj", "{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -91,42 +83,6 @@ Global
{19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x64.Build.0 = Release|Any CPU {19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x64.Build.0 = Release|Any CPU
{19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x86.ActiveCfg = Release|Any CPU {19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x86.ActiveCfg = Release|Any CPU
{19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x86.Build.0 = Release|Any CPU {19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x86.Build.0 = Release|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x64.ActiveCfg = Debug|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x64.Build.0 = Debug|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x86.ActiveCfg = Debug|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x86.Build.0 = Debug|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|Any CPU.Build.0 = Release|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x64.ActiveCfg = Release|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x64.Build.0 = Release|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x86.ActiveCfg = Release|Any CPU
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x86.Build.0 = Release|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x64.ActiveCfg = Debug|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x64.Build.0 = Debug|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x86.ActiveCfg = Debug|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x86.Build.0 = Debug|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Release|Any CPU.Build.0 = Release|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Release|x64.ActiveCfg = Release|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Release|x64.Build.0 = Release|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Release|x86.ActiveCfg = Release|Any CPU
{40529B23-A30E-4BCF-B706-254DB547A643}.Release|x86.Build.0 = Release|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|Any CPU.Build.0 = Debug|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x64.ActiveCfg = Debug|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x64.Build.0 = Debug|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x86.ActiveCfg = Debug|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x86.Build.0 = Debug|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|Any CPU.ActiveCfg = Release|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|Any CPU.Build.0 = Release|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x64.ActiveCfg = Release|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x64.Build.0 = Release|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x86.ActiveCfg = Release|Any CPU
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -135,8 +91,5 @@ Global
{7974F646-2F23-46E3-A614-A3D0C5D8C844} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7974F646-2F23-46E3-A614-A3D0C5D8C844} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{1A28299F-5E9B-4E2E-AEC1-EB35FB911F85} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {1A28299F-5E9B-4E2E-AEC1-EB35FB911F85} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{19457083-89B8-4C5B-BCA1-BFB66C273502} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {19457083-89B8-4C5B-BCA1-BFB66C273502} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{40529B23-A30E-4BCF-B706-254DB547A643} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@ -3,7 +3,7 @@
#define MyAppName "Электронная Фармация" #define MyAppName "Электронная Фармация"
#define MyAppBrand "ЭльФиСА" #define MyAppBrand "ЭльФиСА"
#define MyAppVersion "2.0.10" #define MyAppVersion "2.0.0"
#define MyAppPublisher "PharmData" #define MyAppPublisher "PharmData"
#define MyAppURL "https://cdn.24pharmdata.ru" #define MyAppURL "https://cdn.24pharmdata.ru"
#define MyAppSupportURL "https://24pharmdata.ru" #define MyAppSupportURL "https://24pharmdata.ru"

View File

@ -41,18 +41,11 @@
<SolidColorBrush x:Key="SidebarActive" Color="#0A4A44"/> <SolidColorBrush x:Key="SidebarActive" Color="#0A4A44"/>
<SolidColorBrush x:Key="SidebarText" Color="#D5F5F0"/> <SolidColorBrush x:Key="SidebarText" Color="#D5F5F0"/>
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/> <SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/>
<!-- Шрифт интерфейса. Меняется из Настроек: Resources["AppFontFamily"] = FontFamily. -->
<FontFamily x:Key="AppFontFamily">Segoe UI</FontFamily>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> </Application.Resources>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<!-- Единый шрифт для всех окон (дочерние контролы наследуют). -->
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}"/>
</Style>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
<StyleInclude Source="/Styles/Elfisa.axaml"/> <StyleInclude Source="/Styles/Elfisa.axaml"/>
</Application.Styles> </Application.Styles>

View File

@ -4,7 +4,6 @@ using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins; using Avalonia.Data.Core.Plugins;
using System.Linq; using System.Linq;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Elfisa.Avalonia.ViewModels; using Elfisa.Avalonia.ViewModels;
using Elfisa.Avalonia.Views; using Elfisa.Avalonia.Views;
@ -12,38 +11,17 @@ namespace Elfisa.Avalonia;
public partial class App : Application public partial class App : Application
{ {
/// <summary>Дефолтный шрифт интерфейса (когда «По умолчанию»).</summary>
public const string DefaultFont = "Segoe UI";
public override void Initialize() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
/// <summary>Применяет шрифт ко всему приложению (ресурс наследуется всеми окнами).</summary>
public static void ApplyFont(string? name)
{
var family = string.IsNullOrWhiteSpace(name) ? DefaultFont : name!;
if (Current is { } app)
app.Resources["AppFontFamily"] = new FontFamily(family);
}
/// <summary>Применяет масштаб интерфейса (размер шрифта/текста) к главному окну.</summary>
public static void ApplyScale(double scale)
{
if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d
&& d.MainWindow is MainWindow mw)
mw.ApplyScale(scale);
}
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
RequestedThemeVariant = Elfisa.Core.SettingsStore.Current.Theme == "Dark" RequestedThemeVariant = Elfisa.Core.SettingsStore.Current.Theme == "Dark"
? global::Avalonia.Styling.ThemeVariant.Dark ? global::Avalonia.Styling.ThemeVariant.Dark
: global::Avalonia.Styling.ThemeVariant.Light; : global::Avalonia.Styling.ThemeVariant.Light;
ApplyFont(Elfisa.Core.SettingsStore.Current.FontFamily);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow

View File

@ -5,19 +5,15 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<Version>2.0.10</Version> <Version>2.0.0</Version>
<AssemblyVersion>2.0.9.0</AssemblyVersion> <AssemblyVersion>2.0.0.0</AssemblyVersion>
<FileVersion>2.0.9.0</FileVersion> <FileVersion>2.0.0.0</FileVersion>
<Product>ЭльФиСА — Электронная Фармация</Product> <Product>ЭльФиСА — Электронная Фармация</Product>
<Company>PharmData</Company> <Company>PharmData</Company>
<!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). --> <!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). -->
<SatelliteResourceLanguages>ru;en</SatelliteResourceLanguages> <SatelliteResourceLanguages>ru;en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Elfisa.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Models\" /> <Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@ -9,19 +8,15 @@ using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels; namespace Elfisa.Avalonia.ViewModels;
/// <summary>«Загрузить» — скачивание подключённых прайсов с сервера в память.</summary> /// <summary>«Загрузить» — скачивание актуального прайса с сервера в память.</summary>
public partial class LoadPriceViewModel : ViewModelBase public partial class LoadPriceViewModel : ViewModelBase
{ {
private readonly ApiClient _api = new(); private readonly ApiClient _api = new();
/// <summary>Прайсы, подключённые покупателю (какие именно скачиваем).</summary>
public ObservableCollection<BuyerPriceListDto> PriceLists { get; } = new();
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
[ObservableProperty] private double _progress; // 0..100 [ObservableProperty] private double _progress; // 0..100
[ObservableProperty] private string _progressText = ""; [ObservableProperty] private string _progressText = "";
[ObservableProperty] private string? _result; [ObservableProperty] private string? _result;
[ObservableProperty] private string _priceListsInfo = "Загрузка списка прайсов…";
public string LastLoaded => public string LastLoaded =>
PriceCache.LoadedAt is { } dt PriceCache.LoadedAt is { } dt
@ -31,23 +26,6 @@ public partial class LoadPriceViewModel : ViewModelBase
public LoadPriceViewModel() public LoadPriceViewModel()
{ {
_api.SetToken(Session.Current.Token); _api.SetToken(Session.Current.Token);
if (Session.Current.IsAuthenticated)
_ = LoadPriceListsAsync();
}
/// <summary>Тянет список подключённых прайсов — чтобы было видно, что именно скачиваем.</summary>
private async Task LoadPriceListsAsync()
{
try
{
var lists = await _api.GetBuyerPriceListsAsync();
PriceLists.Clear();
foreach (var pl in lists) PriceLists.Add(pl);
PriceListsInfo = lists.Count == 0
? "Прайсы пока не подключены — обратитесь к менеджеру."
: $"Подключено прайсов: {lists.Count}";
}
catch { PriceListsInfo = "Не удалось получить список подключённых прайсов."; }
} }
[RelayCommand] [RelayCommand]
@ -61,10 +39,7 @@ public partial class LoadPriceViewModel : ViewModelBase
ProgressText = "Соединение с сервером…"; ProgressText = "Соединение с сервером…";
var all = new List<PriceItem>(); var all = new List<PriceItem>();
// Весь прайс одним запросом: сервер на КАЖДЫЙ запрос заново прогоняет тяжёлую const int page = 500;
// оконную дедупликацию (и COUNT для total), поэтому дробление на страницы
// умножает нагрузку. Одна большая страница = один прогон = в разы быстрее.
const int page = 500000;
int offset = 0, total = 0; int offset = 0, total = 0;
while (true) while (true)
@ -80,7 +55,7 @@ public partial class LoadPriceViewModel : ViewModelBase
: $"Загружено {all.Count} позиций…"; : $"Загружено {all.Count} позиций…";
offset += batch.Count; offset += batch.Count;
if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 500000) if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 30000)
break; break;
} }
@ -92,9 +67,6 @@ public partial class LoadPriceViewModel : ViewModelBase
var suppliers = all.Select(x => x.SupplierName).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().Count(); var suppliers = all.Select(x => x.SupplierName).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().Count();
Result = $"Прайс загружен: {all.Count} позиций от {suppliers} поставщик(ов)."; Result = $"Прайс загружен: {all.Count} позиций от {suppliers} поставщик(ов).";
OnPropertyChanged(nameof(LastLoaded)); OnPropertyChanged(nameof(LastLoaded));
// Успешно загрузили → сразу в раздел «Прайс-лист».
ShellNavigator.Navigate?.Invoke("Прайс-лист");
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -49,11 +49,8 @@ public partial class PriceListViewModel : ViewModelBase
public ObservableCollection<CartItem> Cart => _cart.Items; public ObservableCollection<CartItem> Cart => _cart.Items;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new(); public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
public ObservableCollection<BuyerPriceListDto> MyPriceLists { get; } = new();
[ObservableProperty] private string _search = ""; [ObservableProperty] private string _search = "";
[ObservableProperty] private BuyerPriceListDto? _selectedPriceList;
[ObservableProperty] private bool _hasMultiplePriceLists;
[ObservableProperty] private string? _selectedBaseName; [ObservableProperty] private string? _selectedBaseName;
[ObservableProperty] private string? _selectedMg; [ObservableProperty] private string? _selectedMg;
[ObservableProperty] private OfferRow? _selectedOffer; [ObservableProperty] private OfferRow? _selectedOffer;
@ -71,39 +68,11 @@ public partial class PriceListViewModel : ViewModelBase
Recalc(); Recalc();
if (Session.Current.IsAuthenticated) if (Session.Current.IsAuthenticated)
{ {
_ = LoadPriceListsAsync(); // грузит «Мои прайсы» → ставит выбор → запускает поиск _ = SearchAsync();
_ = LoadLocationsAsync(); _ = LoadLocationsAsync();
} }
} }
private bool _suppressPriceListSearch;
/// <summary>Загружает подключённые покупателю прайсы в дропдаун «Мои прайсы».</summary>
private async Task LoadPriceListsAsync()
{
List<BuyerPriceListDto> lists;
try { lists = await _api.GetBuyerPriceListsAsync(); }
catch { lists = new(); }
MyPriceLists.Clear();
MyPriceLists.Add(new BuyerPriceListDto { PriceListId = null, Name = "Все прайсы" });
foreach (var pl in lists) MyPriceLists.Add(pl);
HasMultiplePriceLists = lists.Count > 1;
_suppressPriceListSearch = true;
// Один подключён — выбираем его; несколько или ни одного — «Все прайсы».
SelectedPriceList = lists.Count == 1 ? MyPriceLists[1] : MyPriceLists[0];
_suppressPriceListSearch = false;
await SearchAsync();
}
partial void OnSelectedPriceListChanged(BuyerPriceListDto? value)
{
if (_suppressPriceListSearch) return;
_ = SearchAsync();
}
private void Recalc() => CartTotal = _cart.Total; private void Recalc() => CartTotal = _cart.Total;
private async Task LoadLocationsAsync() private async Task LoadLocationsAsync()
@ -128,9 +97,7 @@ public partial class PriceListViewModel : ViewModelBase
try try
{ {
Busy = true; Busy = true;
// Без лимита показа: тянем весь прайс/все совпадения одним запросом (сервер var resp = await _api.GetPriceSummaryAsync(Search, limit: 300);
// при одном большом запросе прогоняет дедупликацию один раз, без COUNT).
var resp = await _api.GetPriceSummaryAsync(Search, limit: 500000, priceListId: SelectedPriceList?.PriceListId);
RebuildIndex(resp.Summary ?? new()); RebuildIndex(resp.Summary ?? new());
Total = resp.TotalDrugs; Total = resp.TotalDrugs;
} }
@ -208,14 +175,14 @@ public partial class PriceListViewModel : ViewModelBase
SelectedOffer = Offers.FirstOrDefault(); SelectedOffer = Offers.FirstOrDefault();
} }
internal static string ExtractDosage(string? drugName) private static string ExtractDosage(string? drugName)
{ {
if (string.IsNullOrWhiteSpace(drugName)) return string.Empty; if (string.IsNullOrWhiteSpace(drugName)) return string.Empty;
var m = DosageRegex.Match(drugName); var m = DosageRegex.Match(drugName);
return m.Success ? m.Value.Trim() : string.Empty; return m.Success ? m.Value.Trim() : string.Empty;
} }
internal static string BuildBaseName(string? drugName, string? tradeName, string dosage) private static string BuildBaseName(string? drugName, string? tradeName, string dosage)
{ {
if (!string.IsNullOrWhiteSpace(tradeName)) return tradeName.Trim(); if (!string.IsNullOrWhiteSpace(tradeName)) return tradeName.Trim();

View File

@ -15,12 +15,6 @@ public partial class RootViewModel : ViewModelBase
public RootViewModel() public RootViewModel()
{ {
// Запомнили вход в прошлый раз и токен ещё живой — сразу в программу.
if (Session.TryRestore())
{
Current = new ShellViewModel(Logout);
return;
}
Current = new LoginViewModel(OnLoggedIn); Current = new LoginViewModel(OnLoggedIn);
TryDevAutoLogin(); TryDevAutoLogin();
} }
@ -44,11 +38,7 @@ public partial class RootViewModel : ViewModelBase
catch { /* остаёмся на экране входа */ } catch { /* остаёмся на экране входа */ }
} }
private void OnLoggedIn() private void OnLoggedIn() => Current = new ShellViewModel(Logout);
{
Session.Current.Save(); // запомнить вход между запусками
Current = new ShellViewModel(Logout);
}
private void Logout() private void Logout()
{ {

View File

@ -9,119 +9,98 @@ using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels; namespace Elfisa.Avalonia.ViewModels;
/// <summary> /// <summary>
/// Отправить — отправка НЕОТПРАВЛЕННЫХ накладных (черновиков заказов из раздела /// Отправить: поиск по прайсу → корзина (общая с Прайс-листом) → создание заказа.
/// «Заказы»). Список готовых накладных, кнопка «Отправить» (создаёт реальный заказ /// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика.
/// у поставщика). Без поиска по прайсу и наименований — только сами накладные.
/// </summary> /// </summary>
public partial class SendOrderViewModel : ViewModelBase public partial class SendOrderViewModel : ViewModelBase
{ {
private readonly ApiClient _api = new(); private readonly ApiClient _api = new();
private readonly CartService _cart = CartService.Instance;
/// <summary>Неотправленные накладные (черновики).</summary> public ObservableCollection<PriceItem> Results { get; } = new();
public ObservableCollection<DraftOrder> Drafts { get; } = new(); public ObservableCollection<CartItem> Cart => _cart.Items;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private DraftOrder? _selected; [ObservableProperty] private string _search = "";
[ObservableProperty] private PriceItem? _selectedResult;
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
[ObservableProperty] private string _comment = "";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status; [ObservableProperty] private string? _status;
[ObservableProperty] private decimal _totalSum; [ObservableProperty] private decimal _cartTotal;
[ObservableProperty] private bool _isEmpty = true;
[ObservableProperty] private int _count;
public SendOrderViewModel() public SendOrderViewModel()
{ {
_api.SetToken(Session.Current.Token); _api.SetToken(Session.Current.Token);
Load(); _cart.Changed += Recalc;
Recalc();
if (Session.Current.IsAuthenticated) _ = InitAsync();
}
private async Task InitAsync()
{
try
{
var locs = await _api.GetBuyerLocationsAsync();
Locations.Clear();
foreach (var l in locs) Locations.Add(l);
SelectedLocation = Locations.FirstOrDefault(l => l.IsDefault) ?? Locations.FirstOrDefault();
await SearchAsync();
}
catch (Exception ex) { Status = ex.Message; }
} }
[RelayCommand] [RelayCommand]
private void Refresh() => Load(); private async Task SearchAsync()
private void Load()
{ {
Drafts.Clear(); try
foreach (var d in DraftStore.All.OrderByDescending(x => x.CreatedAt)) {
Drafts.Add(d); Busy = true; Status = null;
TotalSum = Drafts.Sum(d => d.Total); var resp = await _api.GetPriceSummaryAsync(Search, limit: 200);
Count = Drafts.Count; Results.Clear();
IsEmpty = Drafts.Count == 0; foreach (var it in resp.Summary ?? new()) Results.Add(it);
if (Selected is null || !Drafts.Contains(Selected)) }
Selected = Drafts.FirstOrDefault(); catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
} }
/// <summary>Отправить одну накладную.</summary>
[RelayCommand] [RelayCommand]
private async Task SendAsync(DraftOrder? draft) private void AddToCart()
{
if (SelectedResult is null) { Status = "Выберите позицию в списке слева."; return; }
_cart.Add(SelectedResult);
}
[RelayCommand]
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
private void Recalc() => CartTotal = _cart.Total;
[RelayCommand]
private async Task SendAsync()
{ {
var d = draft ?? Selected;
if (d is null) { Status = "Выберите накладную для отправки."; return; }
Status = null; Status = null;
if (_cart.Count == 0) { Status = "Корзина пуста."; return; }
try try
{ {
Busy = true; Busy = true;
var resp = await CreateFromDraft(d); var req = new BuyerOrderCreateRequest
DraftStore.Remove(d.Id); {
Status = $"✓ Накладная {resp.GlobalSign ?? resp.OrderId} отправлена ({resp.ItemsCount} поз., {resp.TotalAmount:N2})."; LocationId = SelectedLocation?.BuyerLocationId,
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
Items = _cart.Items.Select(c => new BuyerOrderItemReq
{
SupplierPriceId = c.Source.SupplierPriceId ?? "",
Qty = (double)c.Qty,
ItemName = c.Source.DrugName,
Barcode = c.Source.Barcode
}).ToList()
};
var resp = await _api.CreateOrderAsync(req);
Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} создан: {resp.ItemsCount} поз., {resp.TotalAmount:N2}.";
_cart.Clear();
} }
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; } catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
finally { Busy = false; Load(); } finally { Busy = false; }
}
/// <summary>Отправить все неотправленные накладные.</summary>
[RelayCommand]
private async Task SendAllAsync()
{
if (Drafts.Count == 0) { Status = "Нет неотправленных накладных."; return; }
Status = null;
int ok = 0;
string? lastErr = null;
try
{
Busy = true;
foreach (var d in DraftStore.All.ToList())
{
try
{
await CreateFromDraft(d);
DraftStore.Remove(d.Id);
ok++;
}
catch (Exception ex) { lastErr = ex.Message; }
}
}
finally { Busy = false; Load(); }
Status = lastErr is null
? $"✓ Отправлено накладных: {ok}."
: $"Отправлено: {ok}. Не удалось часть — {lastErr}";
}
/// <summary>Удалить неотправленную накладную (не отправляя).</summary>
[RelayCommand]
private void Delete(DraftOrder? draft)
{
var d = draft ?? Selected;
if (d is null) return;
DraftStore.Remove(d.Id);
Load();
Status = "Накладная удалена.";
}
private async Task<BuyerOrderResponse> CreateFromDraft(DraftOrder d)
{
var req = new BuyerOrderCreateRequest
{
// Номер EX резервировался при сохранении черновика — используем его.
GlobalSign = d.Number.StartsWith("EX-", StringComparison.OrdinalIgnoreCase) ? d.Number : null,
LocationId = d.LocationId,
Comment = d.Comment,
Items = d.Items.Select(i => new BuyerOrderItemReq
{
SupplierPriceId = i.SupplierPriceId,
Qty = (double)i.Qty,
ItemName = i.Name,
Barcode = i.Barcode
}).ToList()
};
return await _api.CreateOrderAsync(req);
} }
} }

View File

@ -2,7 +2,6 @@ using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Elfisa.Core; using Elfisa.Core;
@ -12,23 +11,10 @@ public partial class SettingsViewModel : ViewModelBase
{ {
private readonly ApiClient _api = new(); private readonly ApiClient _api = new();
/// <summary>Пункт «По умолчанию» в списке шрифтов.</summary>
public const string DefaultFontLabel = "По умолчанию";
[ObservableProperty] private string _apiBaseUrl = ""; [ObservableProperty] private string _apiBaseUrl = "";
public string[] Themes { get; } = { "Светлая", "Тёмная" }; public string[] Themes { get; } = { "Светлая", "Тёмная" };
[ObservableProperty] private int _themeIndex; [ObservableProperty] private int _themeIndex;
/// <summary>Размер шрифта/текста — масштаб интерфейса.</summary>
public string[] FontSizes { get; } = { "Мелкий (90%)", "Обычный (100%)", "Крупный (115%)", "Очень крупный (130%)", "Огромный (150%)" };
private static readonly double[] ScaleValues = { 0.9, 1.0, 1.15, 1.3, 1.5 };
[ObservableProperty] private int _fontSizeIndex = 1;
public double SelectedScale => ScaleValues[Math.Clamp(FontSizeIndex, 0, ScaleValues.Length - 1)];
/// <summary>Все системные шрифты + «По умолчанию» первым.</summary>
public ObservableCollection<string> Fonts { get; } = new();
[ObservableProperty] private string _selectedFont = DefaultFontLabel;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new(); public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private BuyerLocationDto? _selectedLocation; [ObservableProperty] private BuyerLocationDto? _selectedLocation;
@ -37,26 +23,6 @@ public partial class SettingsViewModel : ViewModelBase
_api.SetToken(Session.Current.Token); _api.SetToken(Session.Current.Token);
ApiBaseUrl = SettingsStore.Current.ApiBaseUrl ?? AppConfig.DefaultApiBaseUrl; ApiBaseUrl = SettingsStore.Current.ApiBaseUrl ?? AppConfig.DefaultApiBaseUrl;
ThemeIndex = SettingsStore.Current.Theme == "Dark" ? 1 : 0; ThemeIndex = SettingsStore.Current.Theme == "Dark" ? 1 : 0;
var scale = SettingsStore.Current.UiScale;
var best = 1;
for (int i = 0; i < ScaleValues.Length; i++)
if (Math.Abs(ScaleValues[i] - scale) < Math.Abs(ScaleValues[best] - scale)) best = i;
FontSizeIndex = best;
Fonts.Add(DefaultFontLabel);
foreach (var f in FontManager.Current.SystemFonts
.Select(x => x.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.OrderBy(n => n, StringComparer.CurrentCultureIgnoreCase))
Fonts.Add(f);
var saved = SettingsStore.Current.FontFamily;
SelectedFont = !string.IsNullOrWhiteSpace(saved) && Fonts.Contains(saved!)
? saved!
: DefaultFontLabel;
if (Session.Current.IsAuthenticated) _ = LoadLocationsAsync(); if (Session.Current.IsAuthenticated) _ = LoadLocationsAsync();
} }

View File

@ -2,19 +2,12 @@ using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Elfisa.Core; using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels; namespace Elfisa.Avalonia.ViewModels;
/// <summary>Навигация между разделами из любой VM (напр. после загрузки прайса).</summary>
public static class ShellNavigator
{
public static Action<string>? Navigate;
}
public class NavItem public class NavItem
{ {
public string Title { get; } public string Title { get; }
@ -92,13 +85,6 @@ public partial class ShellViewModel : ViewModelBase
SelectedNav = NavItems.First(n => n.Title == "Отчёты"); SelectedNav = NavItems.First(n => n.Title == "Отчёты");
if (Session.Current.IsAuthenticated) _ = LoadPharmacyAsync(); if (Session.Current.IsAuthenticated) _ = LoadPharmacyAsync();
// Навигация из других VM (напр. LoadPrice после успешной загрузки → «Прайс-лист»).
ShellNavigator.Navigate = title => Dispatcher.UIThread.Post(() =>
{
var nav = NavItems.FirstOrDefault(n => n.Title == title);
if (nav != null) SelectedNav = nav;
});
} }
private async Task LoadPharmacyAsync() private async Task LoadPharmacyAsync()

View File

@ -1,95 +0,0 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Avalonia.Controls.ApplicationLifetimes;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>Окно «Доступно обновление»: скачивает установщик и запускает его.</summary>
public partial class UpdateViewModel : ViewModelBase
{
private readonly UpdateInfo _info;
private readonly Action _close;
public UpdateViewModel(UpdateInfo info, Action close)
{
_info = info;
_close = close;
}
public string Message =>
$"Установлена версия {_info.CurrentVersion}.\nДоступна новая версия {_info.LatestVersion}.";
[ObservableProperty] private bool _busy;
[ObservableProperty] private double _progress;
[ObservableProperty] private string _status = "";
[RelayCommand]
private void Later() => _close();
[RelayCommand]
private async Task UpdateAsync()
{
// Не-Windows: установщика нет — открываем страницу релиза в браузере.
if (!OperatingSystem.IsWindows())
{
OpenUrl(string.IsNullOrWhiteSpace(_info.ReleaseUrl) ? "https://cdn.24pharmdata.ru" : _info.ReleaseUrl);
_close();
return;
}
try
{
Busy = true;
Status = "Скачивание обновления…";
var progress = new Progress<double>(p =>
{
Progress = p;
Status = $"Скачивание обновления… {p:0}%";
});
var setupPath = await UpdateService.DownloadSetupAsync(progress);
Status = "Запуск установки…";
LaunchInstallerAndExit(setupPath);
}
catch (Exception ex)
{
Busy = false;
Status = "Не удалось обновить: " + ex.Message;
}
}
/// <summary>Тихая установка поверх + перезапуск приложения, затем закрываем себя.</summary>
private void LaunchInstallerAndExit(string setupPath)
{
var exe = Process.GetCurrentProcess().MainModule?.FileName;
var inner = $"\"{setupPath}\" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART";
if (!string.IsNullOrEmpty(exe))
inner += $" && start \"\" \"{exe}\"";
Process.Start(new ProcessStartInfo("cmd.exe")
{
Arguments = "/c \"" + inner + "\"",
UseShellExecute = false,
CreateNoWindow = true,
});
// Закрываем приложение, чтобы установщик заменил файлы (CloseApplications=yes).
if (global::Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)
d.Shutdown();
else
Environment.Exit(0);
}
private static void OpenUrl(string url)
{
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch { /* игнорируем */ }
}
}

View File

@ -2,7 +2,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels" xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters" xmlns:conv="using:Avalonia.Data.Converters"
xmlns:core="using:Elfisa.Core"
x:Class="Elfisa.Avalonia.Views.LoadPriceView" x:Class="Elfisa.Avalonia.Views.LoadPriceView"
x:DataType="vm:LoadPriceViewModel"> x:DataType="vm:LoadPriceViewModel">
@ -21,27 +20,6 @@
<TextBlock Text="{Binding LastLoaded}" Classes="muted" HorizontalAlignment="Center" TextAlignment="Center"/> <TextBlock Text="{Binding LastLoaded}" Classes="muted" HorizontalAlignment="Center" TextAlignment="Center"/>
<!-- Подключённые прайсы — что именно скачиваем -->
<Border Background="{DynamicResource RowAlt}" CornerRadius="10" Padding="16,12" Width="380"
BorderThickness="1" BorderBrush="{DynamicResource Border}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding PriceListsInfo}" FontWeight="SemiBold" Foreground="{DynamicResource Text}"/>
<ItemsControl ItemsSource="{Binding PriceLists}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="core:BuyerPriceListDto">
<Grid ColumnDefinitions="Auto,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="• " Foreground="{DynamicResource Accent}" FontWeight="Bold"/>
<StackPanel Grid.Column="1" Spacing="1">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SupplierName}" Classes="muted" FontSize="12"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Button Classes="primary" Content="Загрузить прайс" HorizontalAlignment="Center" <Button Classes="primary" Content="Загрузить прайс" HorizontalAlignment="Center"
Padding="28,12" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> Padding="28,12" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>

View File

@ -11,9 +11,6 @@
Background="{DynamicResource Bg}" Background="{DynamicResource Bg}"
Title="ЭльФиСА"> Title="ЭльФиСА">
<!-- Масштаб интерфейса (размер шрифта/текста) — зум всего содержимого. --> <ContentControl Content="{Binding Current}"/>
<LayoutTransformControl x:Name="ScaleHost">
<ContentControl Content="{Binding Current}"/>
</LayoutTransformControl>
</Window> </Window>

View File

@ -1,6 +1,4 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Media;
using Elfisa.Core;
namespace Elfisa.Avalonia.Views; namespace Elfisa.Avalonia.Views;
@ -9,14 +7,5 @@ public partial class MainWindow : Window
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
ApplyScale(SettingsStore.Current.UiScale);
}
/// <summary>Масштабирует всё содержимое окна (размер шрифта/текста и элементов).</summary>
public void ApplyScale(double scale)
{
var host = this.FindControl<LayoutTransformControl>("ScaleHost");
if (host is null) return;
host.LayoutTransform = scale == 1.0 ? null : new ScaleTransform(scale, scale);
} }
} }

View File

@ -6,20 +6,11 @@
x:DataType="vm:PriceListViewModel"> x:DataType="vm:PriceListViewModel">
<DockPanel Margin="24"> <DockPanel Margin="24">
<Grid DockPanel.Dock="Top" Margin="0,0,0,14" ColumnDefinitions="*,Auto"> <Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center"> <StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Прайс-лист" Classes="h1"/> <TextBlock Text="Прайс-лист" Classes="h1"/>
<TextBlock Text="Каталог позиций — собери заказ и сохрани" Classes="muted"/> <TextBlock Text="Каталог позиций — собери заказ и сохрани" Classes="muted"/>
</StackPanel> </StackPanel>
<!-- Мои прайсы: видно, по какому прайсу заказываем; если подключено несколько — можно выбрать -->
<Border Grid.Column="1" VerticalAlignment="Center" Padding="12,8" CornerRadius="10"
Background="{DynamicResource Panel}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="Мои прайсы" Classes="label" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding MyPriceLists}" SelectedItem="{Binding SelectedPriceList}"
MinWidth="240" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid> </Grid>
<Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6" <Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6"
@ -34,17 +25,13 @@
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True"> <Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
<DockPanel> <DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
<TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию (от 2 символов)…"> <TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию (от 2 символов)…"/>
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SearchCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/> <Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel> </StackPanel>
<Border DockPanel.Dock="Bottom" Margin="14" Padding="0"> <Border DockPanel.Dock="Bottom" Margin="14" Padding="0">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="Кол-во" Classes="label" VerticalAlignment="Center"/> <TextBlock Text="Кол-во" Classes="label" VerticalAlignment="Center"/>
<NumericUpDown x:Name="QtyBox" Value="{Binding SelectedOffer.OrderQty}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" Width="120" <NumericUpDown Value="{Binding SelectedOffer.OrderQty}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" Width="120"
IsEnabled="{Binding SelectedOffer, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/> IsEnabled="{Binding SelectedOffer, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
<Button Classes="primary" Content="Добавить в заказ ▸" Command="{Binding AddToOrderCommand}"/> <Button Classes="primary" Content="Добавить в заказ ▸" Command="{Binding AddToOrderCommand}"/>
<TextBlock Text="или выдели строку и набери число на клавиатуре, Enter — добавить" Classes="muted" VerticalAlignment="Center" TextWrapping="Wrap" MaxWidth="240"/> <TextBlock Text="или выдели строку и набери число на клавиатуре, Enter — добавить" Classes="muted" VerticalAlignment="Center" TextWrapping="Wrap" MaxWidth="240"/>

View File

@ -1,7 +1,6 @@
using System; using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Elfisa.Avalonia.ViewModels; using Elfisa.Avalonia.ViewModels;
@ -17,20 +16,6 @@ public partial class PriceListView : UserControl
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
var grid = this.FindControl<DataGrid>("OffersGrid"); var grid = this.FindControl<DataGrid>("OffersGrid");
if (grid != null) grid.KeyDown += OffersGrid_KeyDown; if (grid != null) grid.KeyDown += OffersGrid_KeyDown;
// Enter в поле «Кол-во» (мышью выставил число) — тоже добавляет в заказ.
// Tunnel, чтобы перехватить до внутреннего TextBox NumericUpDown.
var qty = this.FindControl<NumericUpDown>("QtyBox");
if (qty != null) qty.AddHandler(KeyDownEvent, QtyBox_KeyDown, RoutingStrategies.Tunnel);
}
private void QtyBox_KeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
(DataContext as PriceListViewModel)?.AddSelectedOffer();
e.Handled = true;
}
} }
// Выделил предложение → набираешь число цифрами (как колонка «Заказ» в старой программе). // Выделил предложение → набираешь число цифрами (как колонка «Заказ» в старой программе).

View File

@ -1,19 +1,16 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels" xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:core="using:Elfisa.Core"
xmlns:conv="using:Avalonia.Data.Converters" xmlns:conv="using:Avalonia.Data.Converters"
x:Class="Elfisa.Avalonia.Views.SendOrderView" x:Class="Elfisa.Avalonia.Views.SendOrderView"
x:DataType="vm:SendOrderViewModel"> x:DataType="vm:SendOrderViewModel">
<DockPanel Margin="24"> <DockPanel Margin="24">
<Grid DockPanel.Dock="Top" Margin="0,0,0,14" ColumnDefinitions="*,Auto"> <Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center"> <StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Отправить" Classes="h1"/> <TextBlock Text="Отправить заказ" Classes="h1"/>
<TextBlock Text="Отправка неотправленных накладных из раздела «Заказы»" Classes="muted"/> <TextBlock Text="Найдите позиции в прайсе, соберите корзину и отправьте поставщику" Classes="muted"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="Обновить" VerticalAlignment="Center"
Command="{Binding RefreshCommand}"/>
</Grid> </Grid>
<Border DockPanel.Dock="Bottom" Margin="0,10,0,0" Padding="10,7" CornerRadius="6" <Border DockPanel.Dock="Bottom" Margin="0,10,0,0" Padding="10,7" CornerRadius="6"
@ -22,68 +19,86 @@
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/> <TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
</Border> </Border>
<!-- Панель отправки: главное действие — отправить все --> <Grid ColumnDefinitions="*,16,440">
<Border DockPanel.Dock="Top" Classes="card" Padding="16" Margin="0,0,0,12">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<TextBlock Foreground="{DynamicResource Text}" FontWeight="SemiBold" FontSize="15"
Text="{Binding Count, StringFormat='Неотправленных накладных: {0}'}"/>
<TextBlock Classes="muted" FontSize="12.5"
Text="{Binding TotalSum, StringFormat='на сумму {0:N2}'}"/>
</StackPanel>
<Button Grid.Column="1" Classes="primary" Padding="26,12" FontSize="15"
Content="Отправить все ▸" Command="{Binding SendAllCommand}"
IsEnabled="{Binding !Busy}" IsVisible="{Binding !IsEmpty}"/>
</Grid>
</Border>
<!-- Список накладных --> <!-- Прайс: поиск + результаты -->
<Border Classes="card" Padding="0" ClipToBounds="True"> <Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
<Grid> <DockPanel>
<!-- Пусто --> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
<StackPanel IsVisible="{Binding IsEmpty}" VerticalAlignment="Center" HorizontalAlignment="Center" <TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию…"/>
Spacing="8" Margin="24"> <Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
<TextBlock Text="📭" FontSize="34" HorizontalAlignment="Center"/> </StackPanel>
<TextBlock Text="Нет неотправленных накладных" Classes="h2" HorizontalAlignment="Center"/> <Button DockPanel.Dock="Bottom" Classes="primary" Content="Добавить в заказ ▸"
<TextBlock Classes="muted" HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="360" TextWrapping="Wrap" Margin="14" HorizontalAlignment="Left"
Text="Соберите заказ в разделе «Прайс-лист» и нажмите «Сохранить заказ» — он появится здесь для отправки."/> Command="{Binding AddToCartCommand}"/>
</StackPanel> <DataGrid ItemsSource="{Binding Results}" SelectedItem="{Binding SelectedResult}"
x:CompileBindings="False" AutoGenerateColumns="False" IsReadOnly="True"
HeadersVisibility="Column" CanUserResizeColumns="True" CanUserSortColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
<DataGridTextColumn Header="Поставщик" Width="120" Binding="{Binding SupplierName}"/>
<DataGridTextColumn Header="Цена" Width="100" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
<!-- Есть накладные --> <!-- Корзина -->
<ScrollViewer IsVisible="{Binding !IsEmpty}"> <Border Grid.Column="2" Classes="card" Padding="16">
<ItemsControl ItemsSource="{Binding Drafts}" x:CompileBindings="False" Margin="0,0,0,14"> <DockPanel>
<ItemsControl.ItemTemplate> <TextBlock DockPanel.Dock="Top" Text="Корзина" Classes="h2" Margin="0,0,0,10"/>
<DataTemplate x:DataType="core:DraftOrder">
<Border Padding="14" Margin="14,14,14,0" CornerRadius="10" <StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}"> <StackPanel Spacing="5">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center"> <TextBlock Text="Грузополучатель" Classes="muted"/>
<StackPanel Grid.Column="0" Spacing="3"> <ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}"
<TextBlock Text="{Binding Number}" FontWeight="Bold" FontSize="15" Foreground="{DynamicResource Text}"/> HorizontalAlignment="Stretch"/>
<TextBlock Classes="muted" FontSize="12.5" </StackPanel>
Text="{Binding CreatedAt, StringFormat='dd.MM.yyyy HH:mm'}"/> <TextBox Text="{Binding Comment}" Watermark="комментарий к заказу (необязательно)"/>
<TextBlock Classes="muted" FontSize="12.5" TextTrimming="CharacterEllipsis" <Grid ColumnDefinitions="*,Auto">
Text="{Binding LocationAddress}"/> <TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" VerticalAlignment="Center"
<TextBlock Classes="muted" FontSize="12.5" Foreground="{DynamicResource Text}"/>
Text="{Binding ItemsCount, StringFormat='Позиций: {0}'}"/> <TextBlock Grid.Column="1" Text="{Binding CartTotal, StringFormat='{}{0:N2}'}"
</StackPanel> FontWeight="Bold" FontSize="16" Foreground="{DynamicResource Accent}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"> </Grid>
<TextBlock Text="{Binding Total, StringFormat='{}{0:N2}'}" FontWeight="Bold" <Button Classes="primary" Content="Отправить заказ" HorizontalAlignment="Stretch"
FontSize="16" Foreground="{DynamicResource Accent}" VerticalAlignment="Center"/> Command="{Binding SendCommand}" IsEnabled="{Binding !Busy}"/>
<Button Classes="primary" Content="Отправить" </StackPanel>
Command="{Binding $parent[ItemsControl].DataContext.SendCommand}"
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Cart}" x:CompileBindings="False">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CartItem">
<Border Padding="10" Margin="0,0,0,8" CornerRadius="9"
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="✕" Width="30" Height="30" Padding="0"
VerticalAlignment="Top" Background="Transparent"
Foreground="{DynamicResource Muted}" Cursor="Hand"
Command="{Binding $parent[ItemsControl].DataContext.RemoveFromCartCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
<Button Content="✕" Width="32" Height="32" Padding="0" VerticalAlignment="Center" <StackPanel Spacing="6">
Background="Transparent" Foreground="{DynamicResource Muted}" Cursor="Hand" <TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}"
Command="{Binding $parent[ItemsControl].DataContext.DeleteCommand}" TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"/>
CommandParameter="{Binding}"/> <TextBlock Text="{Binding Supplier}" Classes="muted"/>
</StackPanel> <Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
</Grid> <NumericUpDown Grid.Column="0" Value="{Binding Qty}" Minimum="1" Maximum="100000"
</Border> Increment="1" FormatString="0" Width="120"/>
</DataTemplate> <TextBlock Grid.Column="1" Text="{Binding Price, StringFormat='× {0:N2}'}"
</ItemsControl.ItemTemplate> Classes="muted" VerticalAlignment="Center" Margin="10,0"/>
</ItemsControl> <TextBlock Grid.Column="2" Text="{Binding LineSum, StringFormat='{}{0:N2}'}"
</ScrollViewer> FontWeight="SemiBold" Foreground="{DynamicResource Text}"
</Grid> VerticalAlignment="Center"/>
</Border> </Grid>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</DockPanel> </DockPanel>
</UserControl> </UserControl>

View File

@ -3,7 +3,7 @@
xmlns:vm="using:Elfisa.Avalonia.ViewModels" xmlns:vm="using:Elfisa.Avalonia.ViewModels"
x:Class="Elfisa.Avalonia.Views.SettingsWindow" x:Class="Elfisa.Avalonia.Views.SettingsWindow"
x:DataType="vm:SettingsViewModel" x:DataType="vm:SettingsViewModel"
Width="460" Height="590" CanResize="False" Width="460" Height="360" CanResize="False"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Background="{DynamicResource Bg}"
Title="Настройки"> Title="Настройки">
@ -21,37 +21,11 @@
<ComboBox ItemsSource="{Binding Themes}" SelectedIndex="{Binding ThemeIndex}" HorizontalAlignment="Stretch"/> <ComboBox ItemsSource="{Binding Themes}" SelectedIndex="{Binding ThemeIndex}" HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Шрифт" Classes="label"/>
<ComboBox ItemsSource="{Binding Fonts}" SelectedItem="{Binding SelectedFont}"
HorizontalAlignment="Stretch" MaxDropDownHeight="360">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontFamily="{Binding}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Размер шрифта / текста" Classes="label"/>
<ComboBox ItemsSource="{Binding FontSizes}" SelectedIndex="{Binding FontSizeIndex}" HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Text="Грузополучатель по умолчанию" Classes="label"/> <TextBlock Text="Грузополучатель по умолчанию" Classes="label"/>
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}" HorizontalAlignment="Stretch"/> <ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}" HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Обновления" Classes="label"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Проверить обновления" Click="OnCheckUpdate"/>
<TextBlock x:Name="UpdateStatus" VerticalAlignment="Center" TextWrapping="Wrap"
Foreground="{DynamicResource Muted}"/>
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,8,0,0"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,8,0,0">
<Button Content="Отмена" Click="OnCancel" Padding="18,9"/> <Button Content="Отмена" Click="OnCancel" Padding="18,9"/>
<Button Classes="primary" Content="Сохранить" Click="OnSave"/> <Button Classes="primary" Content="Сохранить" Click="OnSave"/>

View File

@ -1,5 +1,3 @@
using System;
using System.Reflection;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
@ -14,44 +12,17 @@ public partial class SettingsWindow : Window
{ {
public SettingsWindow() => AvaloniaXamlLoader.Load(this); public SettingsWindow() => AvaloniaXamlLoader.Load(this);
private async void OnCheckUpdate(object? sender, RoutedEventArgs e)
{
var status = this.FindControl<TextBlock>("UpdateStatus");
var current = Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0);
if (status is not null) status.Text = "Проверяем…";
var info = await UpdateService.CheckAsync(current);
if (info is null)
{
if (status is not null) status.Text = "Не удалось проверить (нет сети).";
return;
}
if (!info.Available)
{
if (status is not null) status.Text = $"У вас последняя версия ({info.CurrentVersion}).";
return;
}
if (status is not null) status.Text = $"Доступна {info.LatestVersion} — открываю…";
var win = new UpdateWindow();
win.DataContext = new UpdateViewModel(info, win.Close);
await win.ShowDialog(this);
}
private void OnSave(object? sender, RoutedEventArgs e) private void OnSave(object? sender, RoutedEventArgs e)
{ {
if (DataContext is SettingsViewModel vm) if (DataContext is SettingsViewModel vm)
{ {
SettingsStore.Current.ApiBaseUrl = string.IsNullOrWhiteSpace(vm.ApiBaseUrl) ? null : vm.ApiBaseUrl.Trim().TrimEnd('/'); SettingsStore.Current.ApiBaseUrl = string.IsNullOrWhiteSpace(vm.ApiBaseUrl) ? null : vm.ApiBaseUrl.Trim().TrimEnd('/');
SettingsStore.Current.Theme = vm.ThemeIndex == 1 ? "Dark" : "Light"; SettingsStore.Current.Theme = vm.ThemeIndex == 1 ? "Dark" : "Light";
var font = vm.SelectedFont == SettingsViewModel.DefaultFontLabel ? null : vm.SelectedFont;
SettingsStore.Current.FontFamily = font;
SettingsStore.Current.UiScale = vm.SelectedScale;
SettingsStore.Current.DefaultLocationId = vm.SelectedLocation?.BuyerLocationId; SettingsStore.Current.DefaultLocationId = vm.SelectedLocation?.BuyerLocationId;
SettingsStore.Save(); SettingsStore.Save();
if (Application.Current is { } app) if (Application.Current is { } app)
app.RequestedThemeVariant = vm.ThemeIndex == 1 ? ThemeVariant.Dark : ThemeVariant.Light; app.RequestedThemeVariant = vm.ThemeIndex == 1 ? ThemeVariant.Dark : ThemeVariant.Light;
App.ApplyFont(font); // шрифт — сразу ко всем окнам
App.ApplyScale(vm.SelectedScale); // масштаб — к главному окну
} }
Close(); Close();
} }

View File

@ -1,25 +1,15 @@
using System;
using System.Reflection;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Elfisa.Avalonia.ViewModels; using Elfisa.Avalonia.ViewModels;
using Elfisa.Core;
namespace Elfisa.Avalonia.Views; namespace Elfisa.Avalonia.Views;
public partial class ShellView : UserControl public partial class ShellView : UserControl
{ {
private static bool _updateChecked; // один раз за запуск public ShellView() => AvaloniaXamlLoader.Load(this);
public ShellView()
{
AvaloniaXamlLoader.Load(this);
Loaded += OnLoaded;
}
private async void OnSettingsClick(object? sender, RoutedEventArgs e) private async void OnSettingsClick(object? sender, RoutedEventArgs e)
{ {
@ -28,28 +18,4 @@ public partial class ShellView : UserControl
if (owner is not null) await win.ShowDialog(owner); if (owner is not null) await win.ShowDialog(owner);
else win.Show(); else win.Show();
} }
// Проверка обновлений при первом появлении шелла. Тихо игнорируем офлайн/ошибки.
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
if (_updateChecked) return;
_updateChecked = true;
try
{
var current = Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0);
var info = await UpdateService.CheckAsync(current);
if (info is not { Available: true }) return;
await Dispatcher.UIThread.InvokeAsync(async () =>
{
var win = new UpdateWindow();
win.DataContext = new UpdateViewModel(info, win.Close);
var owner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (owner is not null) await win.ShowDialog(owner);
else win.Show();
});
}
catch { /* обновление не должно мешать работе */ }
}
} }

View File

@ -1,28 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
x:Class="Elfisa.Avalonia.Views.UpdateWindow"
x:DataType="vm:UpdateViewModel"
Width="440" Height="240" CanResize="False"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}"
Title="Обновление">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="Доступно обновление" Classes="h1"/>
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
<StackPanel Spacing="6" IsVisible="{Binding Busy}">
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="8"/>
</StackPanel>
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource Muted}"
TextWrapping="Wrap" IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,4,0,0">
<Button Content="Позже" Command="{Binding LaterCommand}" IsEnabled="{Binding !Busy}" Padding="18,9"/>
<Button Classes="primary" Content="Обновить" Command="{Binding UpdateCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
</StackPanel>
</Window>

View File

@ -1,9 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Elfisa.Avalonia.Views;
public partial class UpdateWindow : Window
{
public UpdateWindow() => AvaloniaXamlLoader.Load(this);
}

View File

@ -25,33 +25,24 @@ public sealed class ApiClient
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
} }
// Тест-конструктор: подмена HTTP-обработчика (мок) для юнит-тестов.
internal ApiClient(HttpMessageHandler handler, string baseUrl)
{
_baseUrl = baseUrl.TrimEnd('/');
_http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(120) };
}
public string BaseUrl => _baseUrl; public string BaseUrl => _baseUrl;
public string? Token => _token; public string? Token => _token;
public bool IsAuthenticated => !string.IsNullOrEmpty(_token); public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token; public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token;
internal string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString(); private string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
/// <summary>Логин. Возвращает (token, role) либо бросает с понятным сообщением.</summary> /// <summary>Логин. Возвращает (token, role) либо бросает с понятным сообщением.</summary>
public async Task<LoginResponse> LoginAsync(string username, string password) public async Task<LoginResponse> LoginAsync(string username, string password)
{ {
var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json); var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json);
HttpResponseMessage resp; using var req = new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
try
{ {
resp = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Post, Url("/auth/login")) Content = new StringContent(body, Encoding.UTF8, "application/json")
{ };
Content = new StringContent(body, Encoding.UTF8, "application/json") HttpResponseMessage resp;
}).ConfigureAwait(false); try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
}
catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); } catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); }
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
@ -107,27 +98,16 @@ public sealed class ApiClient
} }
/// <summary>Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов).</summary> /// <summary>Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов).</summary>
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string? q = null, int limit = 200, int offset = 0, string? priceListId = null) public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string? q = null, int limit = 200, int offset = 0)
{ {
EnsureAuth(); EnsureAuth();
var path = $"/api/supplier-prices/summary?limit={limit}&offset={offset}"; var path = $"/api/supplier-prices/summary?limit={limit}&offset={offset}";
if (!string.IsNullOrWhiteSpace(q) && q.Trim().Length >= 2) if (!string.IsNullOrWhiteSpace(q) && q.Trim().Length >= 2)
path += "&q=" + Uri.EscapeDataString(q.Trim()); path += "&q=" + Uri.EscapeDataString(q.Trim());
if (!string.IsNullOrWhiteSpace(priceListId))
path += "&price_list_id=" + Uri.EscapeDataString(priceListId);
var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false); var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false);
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse(); return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
} }
/// <summary>Прайс-листы, подключённые текущему покупателю (для выбора «Мои прайсы»).</summary>
public async Task<List<BuyerPriceListDto>> GetBuyerPriceListsAsync()
{
EnsureAuth();
var text = await AuthorizedGetAsync("/api/buyer/price-lists", "загрузку прайсов").ConfigureAwait(false);
try { return JsonSerializer.Deserialize<List<BuyerPriceListDto>>(text, Json) ?? new(); }
catch { return new(); }
}
/// <summary>Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика.</summary> /// <summary>Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика.</summary>
public async Task<BuyerOrderResponse> CreateOrderAsync(BuyerOrderCreateRequest request) public async Task<BuyerOrderResponse> CreateOrderAsync(BuyerOrderCreateRequest request)
{ {
@ -173,16 +153,10 @@ public sealed class ApiClient
private async Task<string> AuthorizedGetAsync(string path, string op) private async Task<string> AuthorizedGetAsync(string path, string op)
{ {
using var req = new HttpRequestMessage(HttpMethod.Get, Url(path));
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
HttpResponseMessage resp; HttpResponseMessage resp;
try try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
{
resp = await SendWithRetryAsync(() =>
{
var r = new HttpRequestMessage(HttpMethod.Get, Url(path));
r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
return r;
}).ConfigureAwait(false);
}
catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); } catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); }
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
@ -191,21 +165,6 @@ public sealed class ApiClient
return text; return text;
} }
/// <summary>Отправляет запрос с повтором на транзиентных сбоях (холодный TLS,
/// сброс соединения, hairpin-NAT) — чтобы поиск/загрузка не падали с первого раза.</summary>
private async Task<HttpResponseMessage> SendWithRetryAsync(Func<HttpRequestMessage> make, int retries = 2)
{
Exception? last = null;
for (int attempt = 0; attempt <= retries; attempt++)
{
using var req = make();
try { return await _http.SendAsync(req).ConfigureAwait(false); }
catch (HttpRequestException ex) { last = ex; } // сеть/TLS — повторяем
if (attempt < retries) await Task.Delay(250 * (attempt + 1)).ConfigureAwait(false);
}
throw last!;
}
private void EnsureAuth() private void EnsureAuth()
{ {
if (!IsAuthenticated) throw new ApiException("Требуется авторизация."); if (!IsAuthenticated) throw new ApiException("Требуется авторизация.");

View File

@ -82,19 +82,6 @@ public sealed class BuyerLocationDto
public override string ToString() => Address ?? ""; public override string ToString() => Address ?? "";
} }
/// <summary>Подключённый покупателю прайс-лист («Мои прайсы»).</summary>
public sealed class BuyerPriceListDto
{
[JsonPropertyName("price_list_id")] public string? PriceListId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("supplier_name")] public string? SupplierName { get; set; }
[JsonPropertyName("markup_pct")] public double MarkupPct { get; set; }
[JsonPropertyName("default_markup_pct")] public double DefaultMarkupPct { get; set; }
public override string ToString() =>
string.IsNullOrWhiteSpace(SupplierName) ? (Name ?? "Прайс")
: $"{Name} · {SupplierName}";
}
public sealed class BuyerOrderItemReq public sealed class BuyerOrderItemReq
{ {
[JsonPropertyName("supplier_price_id")] public string SupplierPriceId { get; set; } = ""; [JsonPropertyName("supplier_price_id")] public string SupplierPriceId { get; set; } = "";

View File

@ -6,8 +6,4 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Elfisa.Tests" />
</ItemGroup>
</Project> </Project>

View File

@ -1,9 +1,6 @@
using System;
using System.Text.Json;
namespace Elfisa.Core; namespace Elfisa.Core;
/// <summary>Текущая сессия пользователя (токен, роль, логин). Сохраняется между запусками.</summary> /// <summary>Текущая сессия пользователя (токен, роль, логин).</summary>
public sealed class Session public sealed class Session
{ {
public static Session Current { get; } = new(); public static Session Current { get; } = new();
@ -14,57 +11,10 @@ public sealed class Session
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(Token); public bool IsAuthenticated => !string.IsNullOrWhiteSpace(Token);
/// <summary>Сохранить сессию в настройки (чтобы не логиниться каждый раз).</summary>
public void Save()
{
SettingsStore.Current.Token = Token;
SettingsStore.Current.Role = Role;
SettingsStore.Current.Username = Username;
SettingsStore.Save();
}
/// <summary>Восстановить сессию из настроек, если токен есть и не истёк.</summary>
public static bool TryRestore()
{
var s = SettingsStore.Current;
if (string.IsNullOrWhiteSpace(s.Token) || IsJwtExpired(s.Token))
return false;
Current.Token = s.Token;
Current.Role = s.Role;
Current.Username = s.Username;
return true;
}
public void Clear() public void Clear()
{ {
Token = null; Token = null;
Role = null; Role = null;
Username = null; Username = null;
SettingsStore.Current.Token = null;
SettingsStore.Current.Role = null;
SettingsStore.Current.Username = null;
SettingsStore.Save();
}
/// <summary>Проверяет, истёк ли JWT (по claim exp), с запасом в 1 минуту.</summary>
internal static bool IsJwtExpired(string token)
{
try
{
var parts = token.Split('.');
if (parts.Length != 3) return true;
var payload = parts[1].Replace('-', '+').Replace('_', '/');
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("exp", out var exp) && exp.TryGetInt64(out var expUnix))
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() >= expUnix - 60;
return false; // нет exp — считаем валидным
}
catch { return true; }
} }
} }

View File

@ -6,8 +6,6 @@ public sealed class AppSettings
{ {
public string? ApiBaseUrl { get; set; } public string? ApiBaseUrl { get; set; }
public string Theme { get; set; } = "Light"; // Light | Dark public string Theme { get; set; } = "Light"; // Light | Dark
public string? FontFamily { get; set; } // шрифт интерфейса (null = по умолчанию)
public double UiScale { get; set; } = 1.0; // масштаб интерфейса (размер шрифта/текста)
public string? DefaultLocationId { get; set; } public string? DefaultLocationId { get; set; }
public string? Token { get; set; } // сохранённая сессия public string? Token { get; set; } // сохранённая сессия
public string? Role { get; set; } public string? Role { get; set; }

View File

@ -1,142 +0,0 @@
using System.Net.Http;
using System.Text.Json;
namespace Elfisa.Core;
/// <summary>Результат проверки обновления.</summary>
public sealed class UpdateInfo
{
public bool Available { get; init; }
public string CurrentVersion { get; init; } = "";
public string LatestVersion { get; init; } = "";
public string ReleaseUrl { get; init; } = "";
public string SetupUrl { get; init; } = "";
}
/// <summary>
/// Проверка и скачивание обновлений Avalonia-клиента.
/// Смотрит в Gitea Releases (тот же репозиторий, что и старый WinForms-апдейтер),
/// установщик тянет с CDN. Никаких токенов — репозиторий и CDN публичны.
/// </summary>
public static class UpdateService
{
// Первичный источник — публичный CDN (тот же хост, откуда качается установщик;
// надёжнее приватных API и отдельных поддоменов). Fallback — Gitea Releases.
private const string LatestJsonUrl = "https://cdn.24pharmdata.ru/latest.json";
private const string ReleasesLatest =
"https://git.24pharmdata.ru/api/v1/repos/pharmdata/elfisa-pharmacy/releases/latest";
/// <summary>Стабильный URL установщика на CDN (всегда последняя версия).</summary>
public const string SetupUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe";
/// <summary>Проверяет наличие новой версии. Возвращает null при офлайне/ошибке (не мешаем работе).</summary>
public static async Task<UpdateInfo?> CheckAsync(Version current)
{
var info = await TryCdnAsync(current).ConfigureAwait(false)
?? await TryGiteaAsync(current).ConfigureAwait(false);
Log(info is null
? $"current={current}: проверка не удалась (нет ответа CDN и Gitea)"
: $"current={current} latest={info.LatestVersion} available={info.Available}");
return info;
}
// CDN latest.json: {"version":"2.0.7","setup":"https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe"}
private static async Task<UpdateInfo?> TryCdnAsync(Version current)
{
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
var json = await http.GetStringAsync(LatestJsonUrl).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var ver = root.TryGetProperty("version", out var v) ? v.GetString() : null;
var latest = ParseVersion(ver);
if (latest is null) return null;
var setup = root.TryGetProperty("setup", out var s) ? s.GetString() : null;
return new UpdateInfo
{
Available = latest > current,
CurrentVersion = current.ToString(),
LatestVersion = latest.ToString(),
ReleaseUrl = "",
SetupUrl = string.IsNullOrWhiteSpace(setup) ? SetupUrl : setup!,
};
}
catch (Exception ex) { Log("CDN latest.json недоступен: " + ex.Message); return null; }
}
private static async Task<UpdateInfo?> TryGiteaAsync(Version current)
{
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
http.DefaultRequestHeaders.Add("Accept", "application/json");
var json = await http.GetStringAsync(ReleasesLatest).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var tag = root.TryGetProperty("tag_name", out var t) ? t.GetString() : null;
var latest = ParseVersion(tag);
if (latest is null) return null;
var htmlUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() : null;
return new UpdateInfo
{
Available = latest > current,
CurrentVersion = current.ToString(),
LatestVersion = latest.ToString(),
ReleaseUrl = htmlUrl ?? "",
SetupUrl = SetupUrl,
};
}
catch (Exception ex) { Log("Gitea releases/latest недоступен: " + ex.Message); return null; }
}
/// <summary>Лог проверки в %TEMP%\elfisa-update.log — для диагностики на машине клиента.</summary>
private static void Log(string msg)
{
try
{
var path = Path.Combine(Path.GetTempPath(), "elfisa-update.log");
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {msg}{Environment.NewLine}");
}
catch { /* лог не критичен */ }
}
/// <summary>Скачивает установщик с CDN во временный файл. Возвращает путь.</summary>
public static async Task<string> DownloadSetupAsync(IProgress<double>? progress, CancellationToken ct = default)
{
var path = Path.Combine(Path.GetTempPath(), "ElfisaPharmacy-Setup.exe");
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
using var resp = await http.GetAsync(SetupUrl, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var total = resp.Content.Headers.ContentLength ?? -1;
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
var buffer = new byte[81920];
long readTotal = 0;
int n;
while ((n = await src.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
readTotal += n;
if (total > 0) progress?.Report(readTotal * 100.0 / total);
}
return path;
}
/// <summary>Разбирает тег вида v2.0.4 / 2.0.4 / 2.0.4-beta в Version.</summary>
internal static Version? ParseVersion(string? tag)
{
if (string.IsNullOrWhiteSpace(tag)) return null;
tag = tag.Trim();
if (tag.StartsWith("v", StringComparison.OrdinalIgnoreCase)) tag = tag[1..];
var dash = tag.IndexOf('-');
if (dash > 0) tag = tag[..dash];
switch (tag.Split('.').Length)
{
case 1: tag += ".0.0.0"; break;
case 2: tag += ".0.0"; break;
case 3: tag += ".0"; break;
}
return Version.TryParse(tag, out var v) ? v : null;
}
}

View File

@ -1,100 +0,0 @@
using System.Net;
using System.Net.Http;
using Elfisa.Core;
using Xunit;
namespace Elfisa.Tests;
public class ApiClientTests
{
private static ApiClient Client(HttpMessageHandler h) => new(h, "https://api.test");
[Fact]
public void Url_builds_absolute_paths()
{
var c = new ApiClient("https://api.test/");
Assert.Equal("https://api.test/auth/login", c.Url("/auth/login"));
Assert.Equal("https://api.test/x/y", c.Url("x/y"));
}
[Fact]
public async Task Login_success_returns_token_and_role()
{
var h = new FakeHttpHandler(HttpStatusCode.OK, "{\"token\":\"abc\",\"role\":\"buyer\"}");
var resp = await Client(h).LoginAsync("u", "p");
Assert.Equal("abc", resp.Token);
Assert.Equal("buyer", resp.Role);
}
[Fact]
public async Task Login_401_maps_to_friendly_message()
{
var h = new FakeHttpHandler(HttpStatusCode.Unauthorized, "{\"error\":\"x\"}");
var ex = await Assert.ThrowsAsync<ApiException>(() => Client(h).LoginAsync("u", "p"));
Assert.Contains("Неверный логин", ex.Message);
}
[Fact]
public async Task Login_429_maps_to_ratelimit_message()
{
var h = new FakeHttpHandler((HttpStatusCode)429, "{}");
var ex = await Assert.ThrowsAsync<ApiException>(() => Client(h).LoginAsync("u", "p"));
Assert.Contains("Слишком много", ex.Message);
}
[Fact]
public async Task Login_retries_transient_then_succeeds()
{
var h = new FakeHttpHandler((req, i) =>
i < 2 ? throw new HttpRequestException("transient")
: FakeHttpHandler.Json(HttpStatusCode.OK, "{\"token\":\"ok\"}"));
var resp = await Client(h).LoginAsync("u", "p");
Assert.Equal("ok", resp.Token);
Assert.Equal(3, h.Calls); // 2 сбоя + 1 успех
}
[Fact]
public async Task Login_gives_up_after_retries()
{
var h = new FakeHttpHandler((req, i) => throw new HttpRequestException("down"));
await Assert.ThrowsAsync<ApiException>(() => Client(h).LoginAsync("u", "p"));
Assert.Equal(3, h.Calls); // 1 попытка + 2 повтора
}
[Fact]
public async Task Summary_parses_response_and_builds_query()
{
var body = "{\"total_drugs\":2,\"summary\":[{\"supplier_price_id\":\"1\",\"drug_name\":\"Аспирин\",\"price\":10.5}]}";
var h = new FakeHttpHandler(HttpStatusCode.OK, body);
var c = Client(h);
c.SetToken("t");
var resp = await c.GetPriceSummaryAsync("асп", limit: 50, offset: 20);
Assert.Equal(2, resp.TotalDrugs);
Assert.Single(resp.Summary!);
Assert.Equal("Аспирин", resp.Summary![0].DrugName);
Assert.Equal(10.5m, resp.Summary![0].Price);
var url = h.LastRequest!.RequestUri!.ToString();
Assert.Contains("limit=50", url);
Assert.Contains("offset=20", url);
Assert.Contains("q=", url);
}
[Fact]
public async Task Summary_short_query_omits_q()
{
var h = new FakeHttpHandler(HttpStatusCode.OK, "{\"total_drugs\":0,\"summary\":[]}");
var c = Client(h);
c.SetToken("t");
await c.GetPriceSummaryAsync("a", limit: 10, offset: 0); // 1 символ < 2 → без q
Assert.DoesNotContain("q=", h.LastRequest!.RequestUri!.ToString());
}
[Fact]
public async Task Authorized_call_without_token_throws()
{
var h = new FakeHttpHandler(HttpStatusCode.OK, "{}");
var c = Client(h); // токен не задан
await Assert.ThrowsAsync<ApiException>(() => c.GetPriceSummaryAsync("асп"));
}
}

View File

@ -1,69 +0,0 @@
using Elfisa.Avalonia.ViewModels;
using Elfisa.Core;
using Xunit;
namespace Elfisa.Tests;
// Все тесты корзины в одном классе → xUnit гонит их последовательно (синглтон CartService).
public class CartServiceTests
{
private static PriceItem Item(string id, decimal price, string name = "X")
=> new() { SupplierPriceId = id, Price = price, DrugName = name, SupplierName = "S" };
[Fact]
public void Add_items_and_total()
{
var cart = CartService.Instance;
cart.Clear();
cart.Add(Item("1", 10m), 2);
cart.Add(Item("2", 5m), 3);
Assert.Equal(2, cart.Count);
Assert.Equal(35m, cart.Total); // 20 + 15
cart.Clear();
}
[Fact]
public void Add_same_id_merges_quantity()
{
var cart = CartService.Instance;
cart.Clear();
cart.Add(Item("1", 10m), 2);
cart.Add(Item("1", 10m), 3); // тот же supplier_price_id → qty += 3
Assert.Equal(1, cart.Count);
Assert.Equal(50m, cart.Total); // qty 5 * 10
cart.Clear();
}
[Fact]
public void Add_ignores_empty_id()
{
var cart = CartService.Instance;
cart.Clear();
cart.Add(Item("", 10m), 1);
Assert.Equal(0, cart.Count);
}
[Fact]
public void Remove_and_clear()
{
var cart = CartService.Instance;
cart.Clear();
cart.Add(Item("1", 10m), 1);
cart.Remove(cart.Items[0]);
Assert.Equal(0, cart.Count);
}
[Fact]
public void Changed_event_fires_on_add()
{
var cart = CartService.Instance;
cart.Clear();
int fired = 0;
Action handler = () => fired++;
cart.Changed += handler;
cart.Add(Item("1", 10m), 1);
cart.Changed -= handler;
cart.Clear();
Assert.True(fired >= 1);
}
}

View File

@ -1,36 +0,0 @@
using Elfisa.Core;
using Xunit;
namespace Elfisa.Tests;
public class DraftTests
{
[Fact]
public void DraftItem_line_sum_multiplies()
{
var it = new DraftItem { Price = 10.5m, Qty = 3 };
Assert.Equal(31.5m, it.LineSum);
}
[Fact]
public void DraftOrder_total_and_count()
{
var o = new DraftOrder();
o.Items.Add(new DraftItem { Price = 10m, Qty = 2 }); // 20
o.Items.Add(new DraftItem { Price = 5.5m, Qty = 4 }); // 22
Assert.Equal(42m, o.Total);
Assert.Equal(2, o.ItemsCount);
}
[Fact]
public void DraftOrder_generates_id()
=> Assert.False(string.IsNullOrWhiteSpace(new DraftOrder().Id));
[Fact]
public void Empty_order_totals_zero()
{
var o = new DraftOrder();
Assert.Equal(0m, o.Total);
Assert.Equal(0, o.ItemsCount);
}
}

View File

@ -1,60 +0,0 @@
using System.Text.Json;
using Elfisa.Core;
using Xunit;
namespace Elfisa.Tests;
public class DtoTests
{
[Fact]
public void PriceItem_deserializes_snake_case()
{
var json = "{\"supplier_price_id\":\"sp1\",\"drug_name\":\"Аспирин\",\"barcode\":\"123\"," +
"\"price\":12.30,\"quantity\":5,\"es_code\":777,\"trade_name\":\"Асп\"}";
var it = JsonSerializer.Deserialize<PriceItem>(json)!;
Assert.Equal("sp1", it.SupplierPriceId);
Assert.Equal("Аспирин", it.DrugName);
Assert.Equal("123", it.Barcode);
Assert.Equal(12.30m, it.Price);
Assert.Equal(5m, it.Quantity);
Assert.Equal(777, it.EsCode);
Assert.Equal("Асп", it.TradeName);
}
[Fact]
public void BuyerOrderCreateRequest_serializes_snake_case()
{
var req = new BuyerOrderCreateRequest
{
LocationId = "loc1",
GlobalSign = "EX-1",
Items = { new BuyerOrderItemReq { SupplierPriceId = "sp1", Qty = 2, ItemName = "Товар", Barcode = "111" } }
};
var json = JsonSerializer.Serialize(req);
Assert.Contains("\"location_id\":\"loc1\"", json);
Assert.Contains("\"supplier_price_id\":\"sp1\"", json);
Assert.Contains("\"qty\":2", json);
}
[Fact]
public void LoginResponse_round_trip()
{
var r = JsonSerializer.Deserialize<LoginResponse>(
"{\"token\":\"t\",\"role\":\"manager\",\"redirect_url\":\"/x\"}")!;
Assert.Equal("t", r.Token);
Assert.Equal("manager", r.Role);
Assert.Equal("/x", r.RedirectUrl);
}
[Fact]
public void AppSettings_round_trip_preserves_fields()
{
var s = new AppSettings { ApiBaseUrl = "https://x", Theme = "Dark", FontFamily = "Arial", UiScale = 1.3, Username = "u" };
var back = JsonSerializer.Deserialize<AppSettings>(JsonSerializer.Serialize(s))!;
Assert.Equal("https://x", back.ApiBaseUrl);
Assert.Equal("Dark", back.Theme);
Assert.Equal("Arial", back.FontFamily);
Assert.Equal(1.3, back.UiScale);
Assert.Equal("u", back.Username);
}
}

View File

@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<AssemblyName>Elfisa.Tests</AssemblyName>
<RootNamespace>Elfisa.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Elfisa.Core\Elfisa.Core.csproj" />
<ProjectReference Include="..\..\src\Elfisa.Avalonia\Elfisa.Avalonia.csproj" />
</ItemGroup>
</Project>

View File

@ -1,36 +0,0 @@
using Elfisa.Avalonia.ViewModels;
using Xunit;
namespace Elfisa.Tests;
// Логика дрилдаула прайса: извлечение дозировки (МГ) и базового наименования.
public class PriceLogicTests
{
[Theory]
[InlineData("Нурофен таб. 200мг №10", "200мг")]
[InlineData("Парацетамол 500 мг таб", "500 мг")]
[InlineData("Витамин D3 2000 МЕ", "2000 МЕ")]
[InlineData("Настойка 25мл", "25мл")]
[InlineData("Метформин 1000мг", "1000мг")]
[InlineData("Бинт стерильный", "")]
[InlineData("", "")]
[InlineData(null, "")]
public void ExtractDosage_finds_unit(string? name, string expected)
=> Assert.Equal(expected, PriceListViewModel.ExtractDosage(name));
[Fact]
public void BuildBaseName_prefers_trade_name()
=> Assert.Equal("Нурофен", PriceListViewModel.BuildBaseName("Нурофен таб 200мг", "Нурофен", "200мг"));
[Fact]
public void BuildBaseName_strips_dosage()
=> Assert.Equal("Аспирин", PriceListViewModel.BuildBaseName("Аспирин 500мг", null, "500мг"));
[Fact]
public void BuildBaseName_keeps_name_without_dosage()
=> Assert.Equal("Бинт стерильный", PriceListViewModel.BuildBaseName("Бинт стерильный", null, ""));
[Fact]
public void BuildBaseName_empty_returns_empty()
=> Assert.Equal("", PriceListViewModel.BuildBaseName("", null, ""));
}

View File

@ -1,39 +0,0 @@
# Тесты десктопа (Elfisa.Tests)
Автотесты для кросс-платформенного клиента ЭльФиСА (`Elfisa.Core` + `Elfisa.Avalonia`).
## Запуск
```bash
dotnet test tests/Elfisa.Tests/Elfisa.Tests.csproj
```
или всё решение:
```bash
dotnet test Elfisa.sln
```
CI (`.github/workflows/desktop-tests.yml`) гоняет их автоматически при пуше в `src/**` или `tests/**`.
## Что покрыто (56 тестов)
| Файл | Область |
|---|---|
| `UpdateServiceTests` | разбор версий тегов (`v2.0.4`→`2.0.4.0`), сравнение «новее», URL установщика |
| `SessionTests` | срок JWT-сессии (`IsJwtExpired`): будущий/просроченный/битый токен, запас 60с |
| `ApiClientTests` | сборка URL, маппинг ошибок (401/429), ретраи на транзиентных сбоях, парсинг сводного прайса, требование токена |
| `DtoTests` | JSON-контракты (snake_case) DTO: `PriceItem`, `BuyerOrderCreateRequest`, `LoginResponse`, `AppSettings` |
| `DraftTests` | суммы черновиков заказа (`LineSum`, `Total`, `ItemsCount`) |
| `CartServiceTests` | корзина: добавление, слияние по `supplier_price_id`, удаление, очистка, событие Changed |
| `PriceLogicTests` | дрилдаун прайса: извлечение дозировки (МГ) и базового наименования |
## Как устроены моки
- `FakeHttpHandler``TestHelpers.cs`) — подменяет HTTP у `ApiClient` через internal-конструктор `ApiClient(HttpMessageHandler, baseUrl)`. Позволяет тестировать ретраи/ошибки/парсинг без сети.
- `Jwt.Make(exp)` — генерирует JWT с нужным `exp` для тестов сессии.
- Тестируемые внутренние методы открыты через `InternalsVisibleTo("Elfisa.Tests")` (в csproj `Elfisa.Core` и `Elfisa.Avalonia`).
## Задел (не сделано)
Headless UI-тесты (рендер вьюх без экрана) — `Avalonia.Headless.XUnit` под Avalonia 12 требует **xUnit v3**, что конфликтует с текущим v2. Добавить после миграции тест-проекта на xUnit v3.

View File

@ -1,32 +0,0 @@
using Elfisa.Core;
using Xunit;
namespace Elfisa.Tests;
public class SessionTests
{
[Fact]
public void Future_token_is_not_expired()
=> Assert.False(Session.IsJwtExpired(Jwt.Make(Jwt.Now + 3600)));
[Fact]
public void Past_token_is_expired()
=> Assert.True(Session.IsJwtExpired(Jwt.Make(Jwt.Now - 10)));
[Fact]
public void Within_60s_skew_is_treated_expired()
=> Assert.True(Session.IsJwtExpired(Jwt.Make(Jwt.Now + 30)));
[Theory]
[InlineData("")]
[InlineData("onlyonepart")]
[InlineData("has.two")]
[InlineData("too.many.parts.here")]
[InlineData("a.!!!notbase64!!!.c")]
public void Malformed_token_is_treated_expired(string token)
=> Assert.True(Session.IsJwtExpired(token));
[Fact]
public void No_exp_claim_is_not_expired()
=> Assert.False(Session.IsJwtExpired(Jwt.MakeNoExp()));
}

View File

@ -1,41 +0,0 @@
using System.Net;
using System.Net.Http;
using System.Text;
namespace Elfisa.Tests;
/// <summary>Мок HttpMessageHandler: отдаёт заданные ответы или бросает ошибки.</summary>
internal sealed class FakeHttpHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, int, HttpResponseMessage> _responder;
public int Calls { get; private set; }
public HttpRequestMessage? LastRequest { get; private set; }
public FakeHttpHandler(Func<HttpRequestMessage, int, HttpResponseMessage> responder) => _responder = responder;
public FakeHttpHandler(HttpStatusCode code, string body)
: this((_, __) => Json(code, body)) { }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
var i = Calls++;
return Task.FromResult(_responder(request, i));
}
public static HttpResponseMessage Json(HttpStatusCode code, string body) =>
new(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
}
/// <summary>Генератор JWT для тестов срока действия сессии.</summary>
internal static class Jwt
{
public static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
private static string B64(string s) =>
Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static string Make(long exp) => $"{B64("{\"alg\":\"HS256\"}")}.{B64($"{{\"exp\":{exp}}}")}.sig";
public static string MakeNoExp() => $"{B64("{\"alg\":\"HS256\"}")}.{B64("{\"sub\":\"x\"}")}.sig";
}

View File

@ -1,39 +0,0 @@
using Elfisa.Core;
using Xunit;
namespace Elfisa.Tests;
public class UpdateServiceTests
{
[Theory]
[InlineData("v2.0.4", "2.0.4.0")]
[InlineData("2.0.4", "2.0.4.0")]
[InlineData("2.0", "2.0.0.0")]
[InlineData("2", "2.0.0.0")]
[InlineData("2.0.4-beta", "2.0.4.0")]
[InlineData("V10.2.1", "10.2.1.0")]
[InlineData(" 2.0.9 ", "2.0.9.0")]
public void ParseVersion_valid(string tag, string expected)
=> Assert.Equal(Version.Parse(expected), UpdateService.ParseVersion(tag));
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("garbage")]
[InlineData("v.x.y")]
public void ParseVersion_invalid_returns_null(string? tag)
=> Assert.Null(UpdateService.ParseVersion(tag));
[Fact]
public void Newer_version_compares_greater()
{
Assert.True(UpdateService.ParseVersion("v2.0.5") > UpdateService.ParseVersion("v2.0.4"));
Assert.True(UpdateService.ParseVersion("2.1.0") > UpdateService.ParseVersion("2.0.9"));
Assert.False(UpdateService.ParseVersion("2.0.4") > UpdateService.ParseVersion("2.0.4"));
Assert.True(UpdateService.ParseVersion("v3") > UpdateService.ParseVersion("2.9.9"));
}
[Fact]
public void SetupUrl_points_to_cdn()
=> Assert.Equal("https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe", UpdateService.SetupUrl);
}

View File

@ -116,8 +116,6 @@
background:var(--brand-soft); color:var(--brand-ink); border:1px solid color-mix(in srgb,var(--brand) 26%, transparent)} background:var(--brand-soft); color:var(--brand-ink); border:1px solid color-mix(in srgb,var(--brand) 26%, transparent)}
.dl:hover{background:var(--brand); color:#fff} .dl:hover{background:var(--brand); color:#fff}
.dl svg{width:17px; height:17px; fill:currentColor} .dl svg{width:17px; height:17px; fill:currentColor}
.dl.soon{background:var(--panel-2); color:var(--muted); border-color:var(--line); cursor:default}
.dl.soon:hover{background:var(--panel-2); color:var(--muted)}
/* ---- features ---- */ /* ---- features ---- */
.feats{padding:30px 0 8px; display:grid; grid-template-columns:repeat(3,1fr); gap:14px} .feats{padding:30px 0 8px; display:grid; grid-template-columns:repeat(3,1fr); gap:14px}
@ -175,7 +173,7 @@
<div class="spec"><span>Формат</span><b>.exe установщик</b></div> <div class="spec"><span>Формат</span><b>.exe установщик</b></div>
<div class="spec"><span>Система</span><b>Windows 10 / 11</b></div> <div class="spec"><span>Система</span><b>Windows 10 / 11</b></div>
<div class="spec"><span>Разрядность</span><b>64-бит</b></div> <div class="spec"><span>Разрядность</span><b>64-бит</b></div>
<div class="spec"><span>Размер</span><b>~48 МБ</b></div> <div class="spec"><span>Размер</span><b>~90 МБ</b></div>
</div> </div>
<a class="dl" href="ElfisaPharmacy-Setup.exe" download> <a class="dl" href="ElfisaPharmacy-Setup.exe" download>
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
@ -187,14 +185,17 @@
<span class="youbadge">Ваша система</span> <span class="youbadge">Ваша система</span>
<div class="os-ico"><svg viewBox="0 0 24 24"><path d="M16.4 12.7c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.9-3.5.9-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.2 0 2-1.1 2.8-2.2.5-.8.9-1.6 1.2-2.5-.1 0-2.1-.9-2.1-3.2zM14.2 5.9c.6-.8 1-1.8.9-2.9-.9 0-2 .6-2.6 1.4-.6.7-1.1 1.7-.9 2.7 1 .1 2-.5 2.6-1.2z"/></svg></div> <div class="os-ico"><svg viewBox="0 0 24 24"><path d="M16.4 12.7c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.9-3.5.9-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.2 0 2-1.1 2.8-2.2.5-.8.9-1.6 1.2-2.5-.1 0-2.1-.9-2.1-3.2zM14.2 5.9c.6-.8 1-1.8.9-2.9-.9 0-2 .6-2.6 1.4-.6.7-1.1 1.7-.9 2.7 1 .1 2-.5 2.6-1.2z"/></svg></div>
<h3>macOS</h3> <h3>macOS</h3>
<p class="os-sub">Apple Silicon и Intel · готовим сборку</p> <p class="os-sub">Образ диска · Apple Silicon и Intel</p>
<div class="specs"> <div class="specs">
<div class="spec"><span>Формат</span><b>.dmg образ</b></div> <div class="spec"><span>Формат</span><b>.dmg образ</b></div>
<div class="spec"><span>Система</span><b>macOS 12+</b></div> <div class="spec"><span>Система</span><b>macOS 12+</b></div>
<div class="spec"><span>Процессор</span><b>Apple / Intel</b></div> <div class="spec"><span>Процессор</span><b>Apple / Intel</b></div>
<div class="spec"><span>Статус</span><b>скоро</b></div> <div class="spec"><span>Размер</span><b>~95 МБ</b></div>
</div> </div>
<span class="dl soon">Скоро</span> <a class="dl" href="Elfisa-macOS.dmg" download>
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Скачать образ .dmg
</a>
</div> </div>
<div class="card" data-os="linux"> <div class="card" data-os="linux">
@ -206,7 +207,7 @@
<div class="spec"><span>Формат</span><b>.AppImage</b></div> <div class="spec"><span>Формат</span><b>.AppImage</b></div>
<div class="spec"><span>Система</span><b>glibc 2.31+</b></div> <div class="spec"><span>Система</span><b>glibc 2.31+</b></div>
<div class="spec"><span>Разрядность</span><b>x86-64</b></div> <div class="spec"><span>Разрядность</span><b>x86-64</b></div>
<div class="spec"><span>Размер</span><b>~38 МБ</b></div> <div class="spec"><span>Размер</span><b>~95 МБ</b></div>
</div> </div>
<a class="dl" href="Elfisa-Linux-x86_64.AppImage" download> <a class="dl" href="Elfisa-Linux-x86_64.AppImage" download>
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
@ -233,9 +234,9 @@
<script> <script>
(function(){ (function(){
var files = { windows:"ElfisaPharmacy-Setup.exe", mac:null, linux:"Elfisa-Linux-x86_64.AppImage" }; var files = { windows:"ElfisaPharmacy-Setup.exe", mac:"Elfisa-macOS.dmg", linux:"Elfisa-Linux-x86_64.AppImage" };
var labels = { windows:"Скачать для Windows", mac:"Версия для macOS — скоро", linux:"Скачать для Linux" }; var labels = { windows:"Скачать для Windows", mac:"Скачать для macOS", linux:"Скачать для Linux" };
var metas = { windows:"Windows 10/11 · 64-бит · ~48 МБ", mac:"сборка готовится · выберите систему ниже", linux:"x86-64 · glibc 2.31+ · ~38 МБ" }; var metas = { windows:"Windows 10/11 · 64-бит · ~90 МБ", mac:"macOS 12+ · Apple/Intel · ~95 МБ", linux:"x86-64 · glibc 2.31+ · ~95 МБ" };
function detect(){ function detect(){
var ua = navigator.userAgent || ""; var ua = navigator.userAgent || "";
@ -248,12 +249,9 @@
var os = detect(); var os = detect();
var hero = document.getElementById("heroBtn"); var hero = document.getElementById("heroBtn");
if (hero){ hero.setAttribute("href", files[os]); }
var lbl = document.getElementById("heroLbl"); if(lbl) lbl.textContent = labels[os]; var lbl = document.getElementById("heroLbl"); if(lbl) lbl.textContent = labels[os];
var meta = document.getElementById("heroMeta"); if(meta) meta.textContent = metas[os]; var meta = document.getElementById("heroMeta"); if(meta) meta.textContent = metas[os];
if (hero){
if (files[os]){ hero.setAttribute("href", files[os]); hero.setAttribute("download",""); }
else { hero.setAttribute("href", "#os"); hero.removeAttribute("download"); hero.classList.add("soon-hero"); }
}
var card = document.querySelector('.card[data-os="'+os+'"]'); var card = document.querySelector('.card[data-os="'+os+'"]');
if (card){ card.classList.add("you"); } if (card){ card.classList.add("you"); }