- База/дозировка вычисляются на клиенте (порт BuildBaseName/ExtractDosage) - Три панели: базовые наименования → дозировки (МГ) → предложения поставщиков - Колонка «Заказ»: ввод числа с клавиатуры (многозначно), Enter — добавить - Фикс: null-безопасная привязка KeyDown (падало в конструкторе → пустой экран) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
9.7 KiB
C#
251 lines
9.7 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Collections.ObjectModel;
|
||
using System.Linq;
|
||
using System.Text.RegularExpressions;
|
||
using System.Threading.Tasks;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using Elfisa.Core;
|
||
|
||
namespace Elfisa.Avalonia.ViewModels;
|
||
|
||
/// <summary>Строка предложения с редактируемым количеством «Заказ» (как колонка «Заказ» в WinForms).</summary>
|
||
public partial class OfferRow : ObservableObject
|
||
{
|
||
public PriceItem Source { get; }
|
||
public string? DrugName => Source.DrugName;
|
||
public string? SupplierName => Source.SupplierName;
|
||
public decimal Price => Source.Price;
|
||
public decimal Quantity => Source.Quantity;
|
||
|
||
[ObservableProperty] private decimal _orderQty = 1;
|
||
|
||
public OfferRow(PriceItem src) => Source = src;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Прайс-лист + сборка заказа. Каталог — как в старой программе:
|
||
/// база (название) → дозировка (МГ) → предложения поставщиков. Справа «Мой заказ».
|
||
/// «Сохранить заказ» кладёт черновик в «Заказы» (статус «Не отправлен»).
|
||
/// </summary>
|
||
public partial class PriceListViewModel : ViewModelBase
|
||
{
|
||
private readonly ApiClient _api = new();
|
||
private readonly CartService _cart = CartService.Instance;
|
||
|
||
// Плоский список предложений + предвычисленные база/МГ (как «Базовое наименование»/«МГ» в WinForms).
|
||
private readonly List<(PriceItem Item, string BaseName, string Mg)> _indexed = new();
|
||
|
||
private const string EmptyMgDisplay = "(без МГ)";
|
||
private static readonly Regex DosageRegex = new(
|
||
@"(\d+(?:[.,]\d+)?\s*(?:мг|мкг|г|мл|МЕ|ме)\b)",
|
||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||
|
||
// Три панели дрилдауна
|
||
public ObservableCollection<string> BaseNames { get; } = new();
|
||
public ObservableCollection<string> MgOptions { get; } = new();
|
||
public ObservableCollection<OfferRow> Offers { get; } = new();
|
||
|
||
public ObservableCollection<CartItem> Cart => _cart.Items;
|
||
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
|
||
|
||
[ObservableProperty] private string _search = "";
|
||
[ObservableProperty] private string? _selectedBaseName;
|
||
[ObservableProperty] private string? _selectedMg;
|
||
[ObservableProperty] private OfferRow? _selectedOffer;
|
||
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
|
||
[ObservableProperty] private string _comment = "";
|
||
[ObservableProperty] private bool _busy;
|
||
[ObservableProperty] private string? _status;
|
||
[ObservableProperty] private int _total;
|
||
[ObservableProperty] private decimal _cartTotal;
|
||
|
||
public PriceListViewModel()
|
||
{
|
||
_api.SetToken(Session.Current.Token);
|
||
_cart.Changed += Recalc;
|
||
Recalc();
|
||
if (Session.Current.IsAuthenticated)
|
||
{
|
||
_ = SearchAsync();
|
||
_ = LoadLocationsAsync();
|
||
}
|
||
}
|
||
|
||
private void Recalc() => CartTotal = _cart.Total;
|
||
|
||
private async Task LoadLocationsAsync()
|
||
{
|
||
try
|
||
{
|
||
var locs = await _api.GetBuyerLocationsAsync();
|
||
Locations.Clear();
|
||
foreach (var l in locs) Locations.Add(l);
|
||
var savedId = SettingsStore.Current.DefaultLocationId;
|
||
SelectedLocation = Locations.FirstOrDefault(l => l.BuyerLocationId == savedId)
|
||
?? Locations.FirstOrDefault(l => l.IsDefault)
|
||
?? Locations.FirstOrDefault();
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task SearchAsync()
|
||
{
|
||
Status = null;
|
||
try
|
||
{
|
||
Busy = true;
|
||
var resp = await _api.GetPriceSummaryAsync(Search, limit: 300);
|
||
RebuildIndex(resp.Summary ?? new());
|
||
Total = resp.TotalDrugs;
|
||
}
|
||
catch (Exception ex) { Status = ex.Message; }
|
||
finally { Busy = false; }
|
||
}
|
||
|
||
/// <summary>Пересобирает индекс база/МГ и список базовых наименований.</summary>
|
||
private void RebuildIndex(List<PriceItem> items)
|
||
{
|
||
_indexed.Clear();
|
||
foreach (var it in items)
|
||
{
|
||
var mg = ExtractDosage(it.DrugName);
|
||
var baseName = BuildBaseName(it.DrugName, it.TradeName, mg);
|
||
if (string.IsNullOrWhiteSpace(baseName)) continue;
|
||
_indexed.Add((it, baseName, mg));
|
||
}
|
||
|
||
// Сброс выбора и панелей
|
||
SelectedBaseName = null;
|
||
MgOptions.Clear();
|
||
Offers.Clear();
|
||
SelectedOffer = null;
|
||
|
||
var names = _indexed
|
||
.Select(x => x.BaseName)
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
BaseNames.Clear();
|
||
foreach (var n in names) BaseNames.Add(n);
|
||
}
|
||
|
||
// База выбрана → заполняем список дозировок.
|
||
partial void OnSelectedBaseNameChanged(string? value)
|
||
{
|
||
MgOptions.Clear();
|
||
Offers.Clear();
|
||
SelectedOffer = null;
|
||
SelectedMg = null;
|
||
if (string.IsNullOrEmpty(value)) return;
|
||
|
||
var mgs = _indexed
|
||
.Where(x => string.Equals(x.BaseName, value, StringComparison.OrdinalIgnoreCase))
|
||
.Select(x => string.IsNullOrEmpty(x.Mg) ? EmptyMgDisplay : x.Mg)
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(m => m, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
foreach (var m in mgs) MgOptions.Add(m);
|
||
|
||
// Если дозировка одна — сразу её выбираем (меньше кликов).
|
||
if (MgOptions.Count == 1) SelectedMg = MgOptions[0];
|
||
}
|
||
|
||
// Дозировка выбрана → показываем предложения (фильтр база + МГ).
|
||
partial void OnSelectedMgChanged(string? value)
|
||
{
|
||
Offers.Clear();
|
||
SelectedOffer = null;
|
||
if (string.IsNullOrEmpty(SelectedBaseName) || string.IsNullOrEmpty(value)) return;
|
||
|
||
var wantMg = string.Equals(value, EmptyMgDisplay, StringComparison.Ordinal) ? "" : value;
|
||
|
||
var offers = _indexed
|
||
.Where(x => string.Equals(x.BaseName, SelectedBaseName, StringComparison.OrdinalIgnoreCase)
|
||
&& string.Equals(x.Mg, wantMg, StringComparison.OrdinalIgnoreCase))
|
||
.OrderBy(x => x.Item.Price)
|
||
.Select(x => new OfferRow(x.Item))
|
||
.ToList();
|
||
|
||
foreach (var o in offers) Offers.Add(o);
|
||
SelectedOffer = Offers.FirstOrDefault();
|
||
}
|
||
|
||
private static string ExtractDosage(string? drugName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(drugName)) return string.Empty;
|
||
var m = DosageRegex.Match(drugName);
|
||
return m.Success ? m.Value.Trim() : string.Empty;
|
||
}
|
||
|
||
private static string BuildBaseName(string? drugName, string? tradeName, string dosage)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(tradeName)) return tradeName.Trim();
|
||
|
||
var name = drugName ?? string.Empty;
|
||
if (!string.IsNullOrWhiteSpace(dosage))
|
||
{
|
||
var idx = name.IndexOf(dosage, StringComparison.OrdinalIgnoreCase);
|
||
if (idx >= 0) name = name.Remove(idx, dosage.Length);
|
||
}
|
||
|
||
name = Regex.Replace(name, @"\s{2,}", " ").Trim(' ', ',', '-', '.', ';');
|
||
return string.IsNullOrWhiteSpace(name) ? (drugName ?? string.Empty).Trim() : name;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddToOrder() => AddSelectedOffer();
|
||
|
||
/// <summary>Добавляет выбранное предложение в заказ с его количеством (Enter в таблице / кнопка).</summary>
|
||
public void AddSelectedOffer()
|
||
{
|
||
if (SelectedOffer is null) { Status = "Выберите предложение в списке."; return; }
|
||
var q = SelectedOffer.OrderQty <= 0 ? 1 : SelectedOffer.OrderQty;
|
||
_cart.Add(SelectedOffer.Source, q);
|
||
SelectedOffer.OrderQty = 1;
|
||
Status = null;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
|
||
|
||
[RelayCommand]
|
||
private async Task SaveDraftAsync()
|
||
{
|
||
if (_cart.Count == 0) { Status = "Заказ пуст — добавьте позиции."; return; }
|
||
try
|
||
{
|
||
Busy = true;
|
||
// Резервируем настоящий номер заказа EX-####### (как в WinForms)
|
||
var sign = await _api.NextGlobalSignAsync();
|
||
|
||
var draft = new DraftOrder
|
||
{
|
||
Number = string.IsNullOrWhiteSpace(sign) ? $"ЧРН-{DateTime.Now:HHmmss}" : sign!,
|
||
LocationId = SelectedLocation?.BuyerLocationId,
|
||
LocationAddress = SelectedLocation?.Address,
|
||
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
|
||
Items = _cart.Items.Select(c => new DraftItem
|
||
{
|
||
SupplierPriceId = c.Source.SupplierPriceId ?? "",
|
||
Name = c.Name,
|
||
Supplier = c.Supplier,
|
||
Barcode = c.Source.Barcode,
|
||
Price = c.Price,
|
||
Qty = c.Qty
|
||
}).ToList()
|
||
};
|
||
DraftStore.Add(draft);
|
||
_cart.Clear();
|
||
Comment = "";
|
||
Status = $"Заказ сохранён: {draft.Number} → раздел «Заказы» (не отправлен).";
|
||
}
|
||
catch (Exception ex) { Status = "Ошибка сохранения: " + ex.Message; }
|
||
finally { Busy = false; }
|
||
}
|
||
}
|