Avalonia: Прайс-лист, Накладные, Справочник (5/7 разделов рабочие)

- Прайс-лист: поиск + грид на рабочем /api/supplier-prices/summary
  (наименование, форма, производитель, поставщик, цена)
- Накладные: реестр приходных документов (из buyer/report)
- Справочник: профиль покупателя + грузополучатели (buyer/locations)
- Core: GetPriceSummaryAsync, GetBuyerLocationsAsync + DTO
- README: статус разделов + примечание про buyer/orders 500 (фикс застейджен)

Рабочие: Прайс-лист, Заказы, Накладные, Отчёты, Справочник.
Осталось: Отправить (корзина+DBF), Отказы (нужен эндпоинт), автообновление.
Собирается; рантайм новых 3 разделов не проверен (клиент за игрой — окна не поднимал).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Exest 2026-08-08 10:20:36 +03:00
parent 132ce1531f
commit 77dfb08ef4
13 changed files with 400 additions and 13 deletions

View File

@ -52,14 +52,22 @@ dotnet publish src/Elfisa.Avalonia -c Release -r win-x64 --self-contained #
| Раздел | Что нужно | Сложность | | Раздел | Что нужно | Сложность |
|---|---|---| |---|---|---|
| Прайс-лист | поиск/каталог, наценки, добавление в заказ, большой грид | высокая | | ✅ Прайс-лист | поиск + грид (/api/supplier-prices/summary) | готово (без добавления в заказ) |
| Заказы | список + карточка заказа + позиции | средняя | | ✅ Заказы | список + карточка заказа с позициями | готово |
| Накладные | список приходов | средняя | | ✅ Накладные | реестр приходных документов | готово |
| Отправить | сборка заявки + выгрузка DBF (портировать `orderexport`) | средняя | | ✅ Отчёты | 4 типа + ИИ → Word/Excel | готово |
| Отказы | список отказных позиций | низкая | | ✅ Справочник | профиль + грузополучатели | готово |
| Справочник | контрагенты, грузополучатели, настройки | средняя | | ⏳ Отправить | сборка заявки (корзина) + выгрузка DBF (`orderexport`) | нужен flow создания заказа |
| Автообновление | аналог AutoUpdater под каждую ОС | средняя | | ⏳ Отказы | список отказных позиций | нужен эндпоинт |
| ⏳ Автообновление | аналог AutoUpdater под каждую ОС | не начато |
**Порядок рекомендую:** Заказы → Прайс-лист → Отправить → остальное. **Прайс-лист/Заказы/Накладные** строятся на рабочих эндпоинтах
Бизнес-логика каждого — обычный C#, переносится как есть; переписывается (`supplier-prices/summary`, `buyer/report`). «Родные» `buyer/orders` и
только UI (WinForms → Avalonia XAML), что и есть основная работа. `buyer/catalog` возвращают 500 (баг квотинга PascalCase в GORM-билдере).
Фикс для **buyer/orders** готов и застейджен (`es_api_service.exe.new`,
apply-new-exe.bat) — после деплоя Заказы/Накладные перейдут на него.
Каталог не используется (берём рабочий summary), его фикс — по желанию.
Осталось: **Отправить** (корзина + создание заказа + DBF) и **Отказы**,
плюс автообновление и сохранение сессии. Логика переносится из WinForms,
переписывается только UI (XAML).

View File

@ -0,0 +1,48 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>Справочник покупателя: профиль + грузополучатели (адреса).</summary>
public partial class DirectoryViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
public string UserName => Session.Current.Username ?? "";
public string RoleLabel => Session.Current.Role switch
{
"buyer" => "Покупатель",
"supplier" => "Поставщик",
"manager" => "Менеджер",
"admin" => "Администратор",
_ => Session.Current.Role ?? ""
};
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
public DirectoryViewModel()
{
_api.SetToken(Session.Current.Token);
if (Session.Current.IsAuthenticated) _ = LoadAsync();
}
private async Task LoadAsync()
{
try
{
Busy = true;
var locs = await _api.GetBuyerLocationsAsync();
Locations.Clear();
foreach (var l in locs) Locations.Add(l);
if (Locations.Count == 0) Status = "Грузополучатели не заданы.";
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
}

View File

@ -0,0 +1,72 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
public sealed class InvoiceRow
{
public string Date { get; init; } = "";
public string Number { get; init; } = "";
public string Supplier { get; init; } = "";
public string Location { get; init; } = "";
public int ItemsCount { get; init; }
public decimal Sum { get; init; }
}
/// <summary>
/// Накладные — реестр приходных документов. Строится из /api/buyer/report
/// (после деплоя фикса /api/buyer/orders можно перейти на «родной» источник).
/// </summary>
public partial class InvoicesViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
public ObservableCollection<InvoiceRow> Rows { get; } = new();
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
[ObservableProperty] private string _totals = "";
public InvoicesViewModel()
{
_api.SetToken(Session.Current.Token);
if (Session.Current.IsAuthenticated) _ = LoadAsync();
}
private async Task LoadAsync()
{
try
{
Busy = true;
var to = DateTime.Today;
var from = to.AddMonths(-24);
var lines = await _api.GetBuyerReportAsync(from, to);
Rows.Clear();
foreach (var g in lines.GroupBy(l => l.OrderId)
.OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue))
{
var f = g.First();
Rows.Add(new InvoiceRow
{
Date = f.OrderDate?.ToLocalTime().ToString("dd.MM.yyyy") ?? "",
Number = f.GlobalSign ?? "",
Supplier = string.Join(", ", g.Select(x => x.Supplier)
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()),
Location = f.Location ?? "",
ItemsCount = g.Count(),
Sum = g.Sum(x => x.Sum)
});
}
Totals = $"Накладных: {Rows.Count} Сумма: {Rows.Sum(r => r.Sum):N2}";
if (Rows.Count == 0) Status = "Накладных за последние 2 года нет.";
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
}

View File

@ -0,0 +1,45 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>Прайс-лист (сводный прайс с /api/supplier-prices/summary — как в WinForms).</summary>
public partial class PriceListViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
public ObservableCollection<PriceItem> Items { get; } = new();
[ObservableProperty] private string _search = "";
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
[ObservableProperty] private int _total;
public PriceListViewModel()
{
_api.SetToken(Session.Current.Token);
if (Session.Current.IsAuthenticated) _ = SearchAsync();
}
[RelayCommand]
private async Task SearchAsync()
{
Status = null;
try
{
Busy = true;
var resp = await _api.GetPriceSummaryAsync(Search, limit: 300);
Items.Clear();
foreach (var it in resp.Summary ?? new()) Items.Add(it);
Total = resp.TotalDrugs;
Status = $"Показано {Items.Count} из {Total} позиций"
+ (string.IsNullOrWhiteSpace(Search) ? "" : $" по запросу «{Search.Trim()}»");
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
}

View File

@ -42,13 +42,13 @@ public partial class ShellViewModel : ViewModelBase
_logout = logout; _logout = logout;
NavItems = new ObservableCollection<NavItem> NavItems = new ObservableCollection<NavItem>
{ {
new("Прайс-лист", "🧾", () => new PlaceholderPageViewModel("Прайс-лист", "Каталог, поиск и добавление позиций в заказ.")), new("Прайс-лист", "🧾", () => new PriceListViewModel()),
new("Заказы", "🛒", () => new OrdersViewModel()), new("Заказы", "🛒", () => new OrdersViewModel()),
new("Накладные", "📄", () => new PlaceholderPageViewModel("Накладные", "Приход товара по заказам.")), new("Накладные", "📄", () => new InvoicesViewModel()),
new("Отчёты", "📊", () => new ReportsViewModel()), new("Отчёты", "📊", () => new ReportsViewModel()),
new("Отправить", "📤", () => new PlaceholderPageViewModel("Отправить", "Формирование и выгрузка заявок поставщику (DBF).")), new("Отправить", "📤", () => new PlaceholderPageViewModel("Отправить", "Формирование и выгрузка заявок поставщику (DBF).")),
new("Отказы", "⛔", () => new PlaceholderPageViewModel("Отказы", "Отказные позиции по заказам.")), new("Отказы", "⛔", () => new PlaceholderPageViewModel("Отказы", "Отказные позиции по заказам.")),
new("Справочник", "📖", () => new PlaceholderPageViewModel("Справочник", "Контрагенты, грузополучатели, настройки.")), new("Справочник", "📖", () => new DirectoryViewModel()),
}; };
SelectedNav = NavItems.First(n => n.Title == "Отчёты"); SelectedNav = NavItems.First(n => n.Title == "Отчёты");
} }

View File

@ -0,0 +1,56 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:core="using:Elfisa.Core"
x:Class="Elfisa.Avalonia.Views.DirectoryView"
x:DataType="vm:DirectoryViewModel">
<ScrollViewer>
<StackPanel Margin="24" Spacing="14">
<StackPanel Spacing="2">
<TextBlock Text="Справочник" Classes="h1"/>
<TextBlock Text="Профиль и грузополучатели" Classes="muted"/>
</StackPanel>
<!-- Профиль -->
<Border Classes="card" MaxWidth="720" HorizontalAlignment="Left">
<StackPanel Spacing="10">
<TextBlock Text="Учётная запись" Classes="h2"/>
<Grid ColumnDefinitions="160,*" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Логин" Classes="muted"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding UserName}" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Роль" Classes="muted" Margin="0,6,0,0"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding RoleLabel}" Foreground="{DynamicResource Text}" Margin="0,6,0,0"/>
</Grid>
</StackPanel>
</Border>
<!-- Грузополучатели -->
<Border Classes="card" MaxWidth="720" HorizontalAlignment="Left">
<StackPanel Spacing="10">
<TextBlock Text="Грузополучатели (адреса)" Classes="h2"/>
<ItemsControl ItemsSource="{Binding Locations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="core:BuyerLocationDto">
<Border Padding="12" Margin="0,0,0,8" CornerRadius="9"
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
<StackPanel Spacing="3">
<TextBlock Text="{Binding Address}" Foreground="{DynamicResource Text}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{Binding RegionName}" Classes="muted"/>
<Border Background="{DynamicResource AccentSoft}" CornerRadius="5" Padding="6,1"
IsVisible="{Binding IsDefault}">
<TextBlock Text="по умолчанию" FontSize="11" Foreground="{DynamicResource AccentPressed}"/>
</Border>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding Status}" Classes="muted"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

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

View File

@ -0,0 +1,39 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters"
x:Class="Elfisa.Avalonia.Views.InvoicesView"
x:DataType="vm:InvoicesViewModel">
<DockPanel Margin="24">
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Накладные" Classes="h1"/>
<TextBlock Text="Реестр приходных документов" Classes="muted"/>
</StackPanel>
<TextBlock Text="{Binding Totals}" HorizontalAlignment="Right" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
</Grid>
<Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6"
Background="{DynamicResource AccentSoft}"
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" FontSize="12.5"/>
</Border>
<Border Classes="card" Padding="0" ClipToBounds="True">
<DataGrid ItemsSource="{Binding Rows}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Дата" Width="112" Binding="{Binding Date}"/>
<DataGridTextColumn Header="Номер" Width="150" Binding="{Binding Number}"/>
<DataGridTextColumn Header="Поставщик" Width="160" Binding="{Binding Supplier}"/>
<DataGridTextColumn Header="Аптека" Width="*" Binding="{Binding Location}"/>
<DataGridTextColumn Header="Позиций" Width="90" Binding="{Binding ItemsCount}" CellStyleClasses="num"/>
<DataGridTextColumn Header="Сумма" Width="132" Binding="{Binding Sum, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
</DataGrid.Columns>
</DataGrid>
</Border>
</DockPanel>
</UserControl>

View File

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

View File

@ -0,0 +1,44 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters"
x:Class="Elfisa.Avalonia.Views.PriceListView"
x:DataType="vm:PriceListViewModel">
<DockPanel Margin="24">
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Прайс-лист" Classes="h1"/>
<TextBlock Text="Каталог доступных позиций и цены" Classes="muted"/>
</StackPanel>
</Grid>
<Border DockPanel.Dock="Top" Classes="card" Margin="0,0,0,12" Padding="16">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBox Text="{Binding Search}" Width="440"
Watermark="поиск по наименованию (от 2 символов)…"/>
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
</Border>
<Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6"
Background="{DynamicResource AccentSoft}"
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" FontSize="12.5"/>
</Border>
<Border Classes="card" Padding="0" ClipToBounds="True">
<DataGrid ItemsSource="{Binding Items}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
<DataGridTextColumn Header="Форма" Width="150" Binding="{Binding CureForm}"/>
<DataGridTextColumn Header="Производитель" Width="220" Binding="{Binding Manufacturer}"/>
<DataGridTextColumn Header="Поставщик" Width="130" Binding="{Binding SupplierName}"/>
<DataGridTextColumn Header="Цена" Width="110" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
</DataGrid.Columns>
</DataGrid>
</Border>
</DockPanel>
</UserControl>

View File

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

View File

@ -97,6 +97,26 @@ public sealed class ApiClient
return new AiReportResult { Content = bytes, FileName = name.Trim('"') }; return new AiReportResult { Content = bytes, FileName = name.Trim('"') };
} }
/// <summary>Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов).</summary>
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string? q = null, int limit = 200, int offset = 0)
{
EnsureAuth();
var path = $"/api/supplier-prices/summary?limit={limit}&offset={offset}";
if (!string.IsNullOrWhiteSpace(q) && q.Trim().Length >= 2)
path += "&q=" + Uri.EscapeDataString(q.Trim());
var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false);
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
}
/// <summary>Адреса-грузополучатели покупателя.</summary>
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
{
EnsureAuth();
var text = await AuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей").ConfigureAwait(false);
try { return JsonSerializer.Deserialize<List<BuyerLocationDto>>(text, Json) ?? new(); }
catch { return new(); }
}
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)); using var req = new HttpRequestMessage(HttpMethod.Get, Url(path));

View File

@ -52,3 +52,31 @@ public sealed class ErrorResponse
{ {
[JsonPropertyName("error")] public string? Error { get; set; } [JsonPropertyName("error")] public string? Error { get; set; }
} }
public sealed class PriceSummaryResponse
{
[JsonPropertyName("summary")] public List<PriceItem>? Summary { get; set; }
[JsonPropertyName("total_drugs")] public int TotalDrugs { get; set; }
}
public sealed class PriceItem
{
[JsonPropertyName("supplier_price_id")] public string? SupplierPriceId { get; set; }
[JsonPropertyName("supplier_name")] public string? SupplierName { get; set; }
[JsonPropertyName("drug_name")] public string? DrugName { get; set; }
[JsonPropertyName("cure_form")] public string? CureForm { get; set; }
[JsonPropertyName("barcode")] public string? Barcode { get; set; }
[JsonPropertyName("price")] public decimal Price { get; set; }
[JsonPropertyName("quantity")] public decimal Quantity { get; set; }
[JsonPropertyName("manufacturer")] public string? Manufacturer { get; set; }
[JsonPropertyName("trade_name")] public string? TradeName { get; set; }
[JsonPropertyName("es_code")] public long EsCode { get; set; }
}
public sealed class BuyerLocationDto
{
[JsonPropertyName("buyer_location_id")] public string? BuyerLocationId { get; set; }
[JsonPropertyName("address")] public string? Address { get; set; }
[JsonPropertyName("region_name")] public string? RegionName { get; set; }
[JsonPropertyName("is_default")] public bool IsDefault { get; set; }
}