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; /// Строка предложения с редактируемым количеством «Заказ» (как колонка «Заказ» в WinForms). 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; } /// /// Прайс-лист + сборка заказа. Каталог — как в старой программе: /// база (название) → дозировка (МГ) → предложения поставщиков. Справа «Мой заказ». /// «Сохранить заказ» кладёт черновик в «Заказы» (статус «Не отправлен»). /// 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 BaseNames { get; } = new(); public ObservableCollection MgOptions { get; } = new(); public ObservableCollection Offers { get; } = new(); public ObservableCollection Cart => _cart.Items; public ObservableCollection 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; } } /// Пересобирает индекс база/МГ и список базовых наименований. private void RebuildIndex(List 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(); /// Добавляет выбранное предложение в заказ с его количеством (Enter в таблице / кнопка). 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; } } }