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:
parent
77dfb08ef4
commit
b13a867f8d
@ -57,8 +57,8 @@ dotnet publish src/Elfisa.Avalonia -c Release -r win-x64 --self-contained #
|
||||
| ✅ Накладные | реестр приходных документов | готово |
|
||||
| ✅ Отчёты | 4 типа + ИИ → Word/Excel | готово |
|
||||
| ✅ Справочник | профиль + грузополучатели | готово |
|
||||
| ⏳ Отправить | сборка заявки (корзина) + выгрузка DBF (`orderexport`) | нужен flow создания заказа |
|
||||
| ⏳ Отказы | список отказных позиций | нужен эндпоинт |
|
||||
| ✅ Отправить | поиск → корзина → создание заказа (POST /api/buyer/orders) | готов UI; реальная отправка не тестировалась (создаёт заказ на проде) |
|
||||
| ⏳ Отказы | список отказных позиций | нужен серверный эндпоинт (RefuseItemsCount есть только в локальной БД WinForms) |
|
||||
| ⏳ Автообновление | аналог AutoUpdater под каждую ОС | не начато |
|
||||
|
||||
**Прайс-лист/Заказы/Накладные** строятся на рабочих эндпоинтах
|
||||
|
||||
143
src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs
Normal file
143
src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -46,8 +46,9 @@ public partial class ShellViewModel : ViewModelBase
|
||||
new("Заказы", "🛒", () => new OrdersViewModel()),
|
||||
new("Накладные", "📄", () => new InvoicesViewModel()),
|
||||
new("Отчёты", "📊", () => new ReportsViewModel()),
|
||||
new("Отправить", "📤", () => new PlaceholderPageViewModel("Отправить", "Формирование и выгрузка заявок поставщику (DBF).")),
|
||||
new("Отказы", "⛔", () => new PlaceholderPageViewModel("Отказы", "Отказные позиции по заказам.")),
|
||||
new("Отправить", "📤", () => new SendOrderViewModel()),
|
||||
new("Отказы", "⛔", () => new PlaceholderPageViewModel("Отказы",
|
||||
"Отказные позиции по заказам. Нужен серверный эндпоинт: данные (RefuseItemsCount) сейчас есть только в локальной БД WinForms-клиента.")),
|
||||
new("Справочник", "📖", () => new DirectoryViewModel()),
|
||||
};
|
||||
SelectedNav = NavItems.First(n => n.Title == "Отчёты");
|
||||
|
||||
104
src/Elfisa.Avalonia/Views/SendOrderView.axaml
Normal file
104
src/Elfisa.Avalonia/Views/SendOrderView.axaml
Normal 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>
|
||||
9
src/Elfisa.Avalonia/Views/SendOrderView.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/SendOrderView.axaml.cs
Normal 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);
|
||||
}
|
||||
@ -108,6 +108,27 @@ public sealed class ApiClient
|
||||
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>
|
||||
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
|
||||
{
|
||||
|
||||
@ -79,4 +79,29 @@ public sealed class BuyerLocationDto
|
||||
[JsonPropertyName("address")] public string? Address { get; set; }
|
||||
[JsonPropertyName("region_name")] public string? RegionName { 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; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user