Avalonia: раздел «Отправить» (корзина + создание заказа)

- Отправить: поиск по прайсу → корзина (кол-во через NumericUpDown,
  сумма/итого) → выбор грузополучателя + комментарий → создание заказа
  POST /api/buyer/orders
- Core: CreateOrderAsync + DTO (BuyerOrderCreateRequest/ItemReq/Response)
- Отказы: заглушка с точным пояснением (нужен серверный эндпоинт;
  RefuseItemsCount живёт только в локальной SQLite WinForms)

Итог: 6/7 разделов функциональны (Прайс-лист, Заказы, Накладные, Отчёты,
Справочник, Отправить), Отказы — заглушка. Собирается.
⚠️ Реальная отправка заказа (POST) не тестировалась — создаёт заказ на
проде; проверять вживую отдельно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Exest 2026-08-08 10:30:57 +03:00
parent 77dfb08ef4
commit b13a867f8d
7 changed files with 307 additions and 4 deletions

View File

@ -57,8 +57,8 @@ dotnet publish src/Elfisa.Avalonia -c Release -r win-x64 --self-contained #
| ✅ Накладные | реестр приходных документов | готово | | ✅ Накладные | реестр приходных документов | готово |
| ✅ Отчёты | 4 типа + ИИ → Word/Excel | готово | | ✅ Отчёты | 4 типа + ИИ → Word/Excel | готово |
| ✅ Справочник | профиль + грузополучатели | готово | | ✅ Справочник | профиль + грузополучатели | готово |
| ⏳ Отправить | сборка заявки (корзина) + выгрузка DBF (`orderexport`) | нужен flow создания заказа | | ✅ Отправить | поиск → корзина → создание заказа (POST /api/buyer/orders) | готов UI; реальная отправка не тестировалась (создаёт заказ на проде) |
| ⏳ Отказы | список отказных позиций | нужен эндпоинт | | ⏳ Отказы | список отказных позиций | нужен серверный эндпоинт (RefuseItemsCount есть только в локальной БД WinForms) |
| ⏳ Автообновление | аналог AutoUpdater под каждую ОС | не начато | | ⏳ Автообновление | аналог AutoUpdater под каждую ОС | не начато |
**Прайс-лист/Заказы/Накладные** строятся на рабочих эндпоинтах **Прайс-лист/Заказы/Накладные** строятся на рабочих эндпоинтах

View File

@ -0,0 +1,143 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
public partial class CartItem : ObservableObject
{
public PriceItem Source { get; }
public string Name => Source.DrugName ?? "";
public string Supplier => Source.SupplierName ?? "";
public decimal Price => Source.Price;
[ObservableProperty] private decimal _qty = 1;
public decimal LineSum => Price * Qty;
public event Action? Changed;
public CartItem(PriceItem src) => Source = src;
partial void OnQtyChanged(decimal value)
{
OnPropertyChanged(nameof(LineSum));
Changed?.Invoke();
}
}
/// <summary>
/// Отправить: поиск по прайсу → корзина → создание заказа (POST /api/buyer/orders).
/// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика.
/// </summary>
public partial class SendOrderViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
public ObservableCollection<PriceItem> Results { get; } = new();
public ObservableCollection<CartItem> Cart { get; } = new();
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private string _search = "";
[ObservableProperty] private PriceItem? _selectedResult;
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
[ObservableProperty] private string _comment = "";
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
[ObservableProperty] private decimal _cartTotal;
public SendOrderViewModel()
{
_api.SetToken(Session.Current.Token);
Cart.CollectionChanged += OnCartChanged;
if (Session.Current.IsAuthenticated) _ = InitAsync();
}
private void OnCartChanged(object? s, NotifyCollectionChangedEventArgs e) => Recalc();
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]
private async Task SearchAsync()
{
try
{
Busy = true; Status = null;
var resp = await _api.GetPriceSummaryAsync(Search, limit: 200);
Results.Clear();
foreach (var it in resp.Summary ?? new()) Results.Add(it);
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void AddToCart()
{
if (SelectedResult is null) { Status = "Выберите позицию в списке слева."; return; }
var existing = Cart.FirstOrDefault(c => c.Source.SupplierPriceId == SelectedResult.SupplierPriceId);
if (existing != null) { existing.Qty += 1; }
else
{
var ci = new CartItem(SelectedResult);
ci.Changed += Recalc;
Cart.Add(ci);
}
Recalc();
}
[RelayCommand]
private void RemoveFromCart(CartItem? item)
{
if (item is null) return;
item.Changed -= Recalc;
Cart.Remove(item);
Recalc();
}
private void Recalc() => CartTotal = Cart.Sum(c => c.LineSum);
[RelayCommand]
private async Task SendAsync()
{
Status = null;
if (Cart.Count == 0) { Status = "Корзина пуста."; return; }
try
{
Busy = true;
var req = new BuyerOrderCreateRequest
{
LocationId = SelectedLocation?.BuyerLocationId,
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
Items = Cart.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();
Recalc();
}
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
finally { Busy = false; }
}
}

View File

@ -46,8 +46,9 @@ public partial class ShellViewModel : ViewModelBase
new("Заказы", "🛒", () => new OrdersViewModel()), new("Заказы", "🛒", () => new OrdersViewModel()),
new("Накладные", "📄", () => new InvoicesViewModel()), new("Накладные", "📄", () => new InvoicesViewModel()),
new("Отчёты", "📊", () => new ReportsViewModel()), new("Отчёты", "📊", () => new ReportsViewModel()),
new("Отправить", "📤", () => new PlaceholderPageViewModel("Отправить", "Формирование и выгрузка заявок поставщику (DBF).")), new("Отправить", "📤", () => new SendOrderViewModel()),
new("Отказы", "⛔", () => new PlaceholderPageViewModel("Отказы", "Отказные позиции по заказам.")), new("Отказы", "⛔", () => new PlaceholderPageViewModel("Отказы",
"Отказные позиции по заказам. Нужен серверный эндпоинт: данные (RefuseItemsCount) сейчас есть только в локальной БД WinForms-клиента.")),
new("Справочник", "📖", () => new DirectoryViewModel()), new("Справочник", "📖", () => new DirectoryViewModel()),
}; };
SelectedNav = NavItems.First(n => n.Title == "Отчёты"); SelectedNav = NavItems.First(n => n.Title == "Отчёты");

View File

@ -0,0 +1,104 @@
<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.SendOrderView"
x:DataType="vm:SendOrderViewModel">
<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="Bottom" Margin="0,10,0,0" Padding="10,7" CornerRadius="6"
Background="{DynamicResource AccentSoft}"
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
</Border>
<Grid ColumnDefinitions="*,16,440">
<!-- Прайс: поиск + результаты -->
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
<TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию…"/>
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Button DockPanel.Dock="Bottom" Classes="primary" Content="Добавить в заказ ▸"
Margin="14" HorizontalAlignment="Left"
Command="{Binding AddToCartCommand}"/>
<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>
<!-- Корзина -->
<Border Grid.Column="2" Classes="card" Padding="16">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Корзина" Classes="h2" Margin="0,0,0,10"/>
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
<StackPanel Spacing="5">
<TextBlock Text="Грузополучатель" Classes="muted"/>
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<TextBox Text="{Binding Comment}" Watermark="комментарий к заказу (необязательно)"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" VerticalAlignment="Center"
Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Column="1" Text="{Binding CartTotal, StringFormat='{}{0:N2}'}"
FontWeight="Bold" FontSize="16" Foreground="{DynamicResource Accent}"/>
</Grid>
<Button Classes="primary" Content="Отправить заказ" HorizontalAlignment="Stretch"
Command="{Binding SendCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<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}"/>
<StackPanel Spacing="6">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Supplier}" Classes="muted"/>
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<NumericUpDown Grid.Column="0" Value="{Binding Qty}" Minimum="1" Maximum="100000"
Increment="1" FormatString="0" Width="120"/>
<TextBlock Grid.Column="1" Text="{Binding Price, StringFormat='× {0:N2}'}"
Classes="muted" VerticalAlignment="Center" Margin="10,0"/>
<TextBlock Grid.Column="2" Text="{Binding LineSum, StringFormat='{}{0:N2}'}"
FontWeight="SemiBold" Foreground="{DynamicResource Text}"
VerticalAlignment="Center"/>
</Grid>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</DockPanel>
</UserControl>

View File

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

View File

@ -108,6 +108,27 @@ public sealed class ApiClient
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse(); return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
} }
/// <summary>Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика.</summary>
public async Task<BuyerOrderResponse> CreateOrderAsync(BuyerOrderCreateRequest request)
{
EnsureAuth();
if (request.Items.Count == 0) throw new ApiException("Корзина пуста.");
var body = JsonSerializer.Serialize(request, Json);
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/api/buyer/orders"))
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
HttpResponseMessage resp;
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
catch (Exception ex) { throw new ApiException($"Не удалось отправить заказ: {ex.Message}", ex); }
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка создания заказа ({(int)resp.StatusCode}).");
return JsonSerializer.Deserialize<BuyerOrderResponse>(text, Json) ?? new BuyerOrderResponse();
}
/// <summary>Адреса-грузополучатели покупателя.</summary> /// <summary>Адреса-грузополучатели покупателя.</summary>
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync() public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
{ {

View File

@ -79,4 +79,29 @@ public sealed class BuyerLocationDto
[JsonPropertyName("address")] public string? Address { get; set; } [JsonPropertyName("address")] public string? Address { get; set; }
[JsonPropertyName("region_name")] public string? RegionName { get; set; } [JsonPropertyName("region_name")] public string? RegionName { get; set; }
[JsonPropertyName("is_default")] public bool IsDefault { get; set; } [JsonPropertyName("is_default")] public bool IsDefault { get; set; }
public override string ToString() => Address ?? "";
}
public sealed class BuyerOrderItemReq
{
[JsonPropertyName("supplier_price_id")] public string SupplierPriceId { get; set; } = "";
[JsonPropertyName("qty")] public double Qty { get; set; }
[JsonPropertyName("item_name")] public string? ItemName { get; set; }
[JsonPropertyName("barcode")] public string? Barcode { get; set; }
}
public sealed class BuyerOrderCreateRequest
{
[JsonPropertyName("location_id")] public string? LocationId { get; set; }
[JsonPropertyName("comment")] public string? Comment { get; set; }
[JsonPropertyName("items")] public List<BuyerOrderItemReq> Items { get; set; } = new();
}
public sealed class BuyerOrderResponse
{
[JsonPropertyName("order_id")] public string? OrderId { get; set; }
[JsonPropertyName("status")] public string? Status { get; set; }
[JsonPropertyName("global_sign")] public string? GlobalSign { get; set; }
[JsonPropertyName("total_amount")] public decimal TotalAmount { get; set; }
[JsonPropertyName("items_count")] public int ItemsCount { get; set; }
} }