elfisa-pharmacy/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs
Exest f6a1012e9b Avalonia кросс-платформенный клиент: фундамент + раздел «Отчёты»
Порт десктопа с WinForms на Avalonia (.NET 8, Win/macOS/Linux).

- Elfisa.Core: переносимая логика (ApiClient: логин/отчёт/ИИ, DTO, Session)
- Дизайн-система в бирюзе #0F766E (свет/тьма, кнопки/поля/карточки/таблица/сайдбар)
- Экран входа (реальный /auth/login), шелл с сайдбар-навигацией
- «Отчёты»: 4 типа + авто-загрузка + ИИ-запрос -> Word/Excel, динамические колонки
- Заглушки остальных разделов + README с дорожной картой портирования

Проверено запуском: логин, живые данные покупателя, тема, таблица (скриншот).
dev-автологин по ELF_DEV_USER/ELF_DEV_PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:21:10 +03:00

216 lines
9.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
public sealed class ReportColumn
{
public string Header { get; init; } = "";
public bool Numeric { get; init; }
public double Weight { get; init; } = 100; // ширина в пикселях (для нетянущихся)
public bool Stretch { get; init; } // тянуть на всё свободное место (star)
}
/// <summary>Строка отчёта: ячейки как строки, доступ по индексу для DataGrid.</summary>
public sealed class ReportRow
{
private readonly string[] _cells;
public ReportRow(string[] cells) => _cells = cells;
public string this[int i] => i >= 0 && i < _cells.Length ? _cells[i] : "";
}
public partial class ReportsViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
private List<BuyerReportLineDto> _lines = new();
/// <summary>Сигнал View пересобрать колонки DataGrid.</summary>
public event Action? ColumnsChanged;
public ObservableCollection<ReportColumn> Columns { get; } = new();
public ObservableCollection<ReportRow> Rows { get; } = new();
[ObservableProperty] private DateTimeOffset _dateFrom = DateTimeOffset.Now.AddMonths(-1);
[ObservableProperty] private DateTimeOffset _dateTo = DateTimeOffset.Now;
public string[] ReportTypes { get; } = { "Мои заказы", "По поставщикам", "По товарам", "По аптекам" };
[ObservableProperty] private int _selectedReportIndex;
public string[] AiFormats { get; } = { "Word", "Excel" };
[ObservableProperty] private int _selectedAiFormat;
[ObservableProperty] private string _aiPrompt = "";
[ObservableProperty] private string _totals = "";
[ObservableProperty] private string? _status;
[ObservableProperty] private bool _busy;
[ObservableProperty] private bool _aiBusy;
public ReportsViewModel()
{
_api.SetToken(Session.Current.Token);
if (Session.Current.IsAuthenticated)
_ = BuildAsync(); // авто-загрузка за последний месяц при открытии
}
partial void OnSelectedReportIndexChanged(int value) => Render();
[RelayCommand]
private async Task BuildAsync()
{
Status = null;
try
{
Busy = true;
_lines = await _api.GetBuyerReportAsync(DateFrom.DateTime.Date, DateTo.DateTime.Date);
Render();
if (_lines.Count == 0) Status = "За выбранный период заказов нет.";
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task GenerateAiAsync()
{
Status = null;
if (string.IsNullOrWhiteSpace(AiPrompt)) { Status = "Введите запрос для ИИ."; return; }
try
{
AiBusy = true;
var fmt = SelectedAiFormat == 1 ? "xlsx" : "docx";
var res = await _api.GenerateAiReportAsync(AiPrompt.Trim(), fmt);
var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var safe = string.Join("_", res.FileName.Split(Path.GetInvalidFileNameChars()));
var path = Path.Combine(dir, safe);
if (File.Exists(path))
{
var name = Path.GetFileNameWithoutExtension(safe);
var ext = Path.GetExtension(safe);
path = Path.Combine(dir, $"{name}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}");
}
File.WriteAllBytes(path, res.Content);
Status = "Отчёт сохранён: " + path;
OpenFile(path);
}
catch (Exception ex) { Status = ex.Message; }
finally { AiBusy = false; }
}
private void Render()
{
Columns.Clear();
Rows.Clear();
var (cols, rows) = Aggregate(_lines, SelectedReportIndex);
foreach (var c in cols) Columns.Add(c);
foreach (var r in rows) Rows.Add(new ReportRow(r));
var orders = _lines.Select(l => l.OrderId).Distinct().Count();
var sum = _lines.Sum(l => l.Sum);
Totals = $"Заказов: {orders} Позиций: {_lines.Count} Сумма: {sum:N2}";
ColumnsChanged?.Invoke();
}
private static (List<ReportColumn> cols, List<string[]> rows) Aggregate(List<BuyerReportLineDto> lines, int type)
{
string N2(decimal d) => d.ToString("N2");
string N3(decimal d) => d.ToString("N3");
string D(DateTime? dt) => dt?.ToLocalTime().ToString("dd.MM.yyyy") ?? "";
switch (type)
{
case 1: // По поставщикам
{
var cols = new List<ReportColumn>
{
new() { Header = "Поставщик", Stretch = true },
new() { Header = "Заказов", Numeric = true, Weight = 90 },
new() { Header = "Позиций", Numeric = true, Weight = 90 },
new() { Header = "Сумма", Numeric = true, Weight = 120 },
};
var rows = lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Supplier) ? "(не указан)" : l.Supplier!)
.OrderByDescending(g => g.Sum(x => x.Sum))
.Select(g => new[] { g.Key, g.Select(x => x.OrderId).Distinct().Count().ToString(),
g.Count().ToString(), N2(g.Sum(x => x.Sum)) }).ToList();
return (cols, rows);
}
case 2: // По товарам
{
var cols = new List<ReportColumn>
{
new() { Header = "Товар", Stretch = true },
new() { Header = "Код", Weight = 100 },
new() { Header = "Кол-во", Numeric = true, Weight = 100 },
new() { Header = "Сумма", Numeric = true, Weight = 120 },
};
var rows = lines.GroupBy(l => ((l.ItemName ?? "").Trim() + "|" + (l.ItemCode ?? "")))
.OrderByDescending(g => g.Sum(x => x.Sum))
.Select(g => { var f = g.First(); return new[] { f.ItemName ?? "", f.ItemCode ?? "",
N3(g.Sum(x => x.Qty)), N2(g.Sum(x => x.Sum)) }; }).ToList();
return (cols, rows);
}
case 3: // По аптекам
{
var cols = new List<ReportColumn>
{
new() { Header = "Аптека", Stretch = true },
new() { Header = "Заказов", Numeric = true, Weight = 90 },
new() { Header = "Позиций", Numeric = true, Weight = 90 },
new() { Header = "Сумма", Numeric = true, Weight = 120 },
};
var rows = lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Location) ? "(не указана)" : l.Location!)
.OrderByDescending(g => g.Sum(x => x.Sum))
.Select(g => new[] { g.Key, g.Select(x => x.OrderId).Distinct().Count().ToString(),
g.Count().ToString(), N2(g.Sum(x => x.Sum)) }).ToList();
return (cols, rows);
}
default: // Мои заказы
{
var cols = new List<ReportColumn>
{
new() { Header = "Дата", Weight = 105 },
new() { Header = "Номер", Weight = 138 },
new() { Header = "Поставщик", Weight = 150 },
new() { Header = "Аптека", Stretch = true },
new() { Header = "Позиций", Numeric = true, Weight = 95 },
new() { Header = "Сумма", Numeric = true, Weight = 125 },
new() { Header = "Статус", Weight = 100 },
};
var rows = lines.GroupBy(l => l.OrderId)
.OrderByDescending(g => g.First().OrderDate ?? DateTime.MinValue)
.Select(g =>
{
var f = g.First();
var sup = string.Join(", ", g.Select(x => x.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct());
return new[] { D(f.OrderDate), f.GlobalSign ?? "", sup, f.Location ?? "",
g.Count().ToString(), N2(g.Sum(x => x.Sum)), f.Status ?? "" };
}).ToList();
return (cols, rows);
}
}
}
private static void OpenFile(string path)
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true });
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
Process.Start("open", $"\"{path}\"");
else
Process.Start("xdg-open", $"\"{path}\"");
}
catch { /* открыть не удалось — путь показан в статусе */ }
}
}