Порт десктопа с 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>
216 lines
9.4 KiB
C#
216 lines
9.4 KiB
C#
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 { /* открыть не удалось — путь показан в статусе */ }
|
||
}
|
||
}
|