diff --git a/.gitignore b/.gitignore index 5314b94..a7f8587 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ packages/ installer/output/ dist/ + +# Кросс-платформенные публикации и артефакты сборки +publish/ +build/ +installer/output/ diff --git a/Elfisa.Cross.slnx b/Elfisa.Cross.slnx new file mode 100644 index 0000000..4a27b69 --- /dev/null +++ b/Elfisa.Cross.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/dist/Электронная-Фармация/Elfisa.UI.dll b/dist/Электронная-Фармация/Elfisa.UI.dll index e17bc77..81eb4cd 100644 Binary files a/dist/Электронная-Фармация/Elfisa.UI.dll and b/dist/Электронная-Фармация/Elfisa.UI.dll differ diff --git a/dist/Электронная-Фармация/Elfisa.UI.pdb b/dist/Электронная-Фармация/Elfisa.UI.pdb index a900d5b..0d32e15 100644 Binary files a/dist/Электронная-Фармация/Elfisa.UI.pdb and b/dist/Электронная-Фармация/Elfisa.UI.pdb differ diff --git a/dist/Электронная-Фармация/Электронная Фармация.exe b/dist/Электронная-Фармация/Электронная Фармация.exe index bbda518..4c53e28 100644 Binary files a/dist/Электронная-Фармация/Электронная Фармация.exe and b/dist/Электронная-Фармация/Электронная Фармация.exe differ diff --git a/dist/Электронная-Фармация/Электронная Фармация.exe.config b/dist/Электронная-Фармация/Электронная Фармация.exe.config index 54fe3b4..2a260d9 100644 --- a/dist/Электронная-Фармация/Электронная Фармация.exe.config +++ b/dist/Электронная-Фармация/Электронная Фармация.exe.config @@ -29,6 +29,9 @@ 0 + + 0 + @@ -38,6 +41,21 @@ + + + + + https://git.24pharmdata.ru + + + pharmdata/elfisa-pharmacy + + + + + + True + diff --git a/dist/Электронная-Фармация/Электронная Фармация.pdb b/dist/Электронная-Фармация/Электронная Фармация.pdb index a442355..458d1ba 100644 Binary files a/dist/Электронная-Фармация/Электронная Фармация.pdb and b/dist/Электронная-Фармация/Электронная Фармация.pdb differ diff --git a/installer/ElfisaPharmacy.iss b/installer/ElfisaPharmacy.iss index 430daf5..01b9edb 100644 --- a/installer/ElfisaPharmacy.iss +++ b/installer/ElfisaPharmacy.iss @@ -3,7 +3,7 @@ #define MyAppName "Электронная Фармация" #define MyAppBrand "ЭльФиСА" -#define MyAppVersion "1.0.20" +#define MyAppVersion "2.0.1" #define MyAppPublisher "PharmData" #define MyAppURL "https://cdn.24pharmdata.ru" #define MyAppSupportURL "https://24pharmdata.ru" @@ -89,12 +89,10 @@ russian.BrandLine=ЭльФиСА — удобный заказ лекарств Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked [Files] -; Основной выпуск Release (без отладочных PDB и XML-документации) -Source: "..\src\ElectronicPharmacy\bin\Release\*.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\src\ElectronicPharmacy\bin\Release\*.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\src\ElectronicPharmacy\bin\Release\*.config"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\src\ElectronicPharmacy\bin\Release\x86\*"; DestDir: "{app}\x86"; Flags: ignoreversion recursesubdirs createallsubdirs skipifsourcedoesntexist -Source: "..\src\ElectronicPharmacy\bin\Release\x64\*"; DestDir: "{app}\x64"; Flags: ignoreversion recursesubdirs createallsubdirs skipifsourcedoesntexist +; Avalonia self-contained выпуск (весь рантайм .NET внутри — без зависимостей у клиента). +; Сборка кладётся в ..\publish\win-x64 скриптом build-installer.ps1, exe уже переименован +; в «Электронная Фармация.exe» (совместимо с авто-обновлением старых клиентов). +Source: "..\publish\win-x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "assets\elfisa.ico"; DestDir: "{app}"; DestName: "elfisa.ico"; Flags: ignoreversion [Icons] diff --git a/installer/assets/elfisa-1024.png b/installer/assets/elfisa-1024.png new file mode 100644 index 0000000..0d4b83f Binary files /dev/null and b/installer/assets/elfisa-1024.png differ diff --git a/installer/assets/elfisa-256.png b/installer/assets/elfisa-256.png new file mode 100644 index 0000000..72495e4 Binary files /dev/null and b/installer/assets/elfisa-256.png differ diff --git a/installer/assets/elfisa-512.png b/installer/assets/elfisa-512.png new file mode 100644 index 0000000..e1c7f1b Binary files /dev/null and b/installer/assets/elfisa-512.png differ diff --git a/installer/assets/wizard-side.bmp b/installer/assets/wizard-side.bmp index 30655e9..f306904 100644 Binary files a/installer/assets/wizard-side.bmp and b/installer/assets/wizard-side.bmp differ diff --git a/installer/assets/wizard-small.bmp b/installer/assets/wizard-small.bmp index d822bbe..e39736b 100644 Binary files a/installer/assets/wizard-small.bmp and b/installer/assets/wizard-small.bmp differ diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index d26040d..873a1cf 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -15,12 +15,23 @@ if (-not $iscc) { throw "Inno Setup 6 (ISCC.exe) не найден. Установите: winget install JRSoftware.InnoSetup" } +$pub = "publish\win-x64" if (-not $SkipBuild) { - Write-Host "Сборка Release..." -ForegroundColor Cyan - dotnet build "src\ElectronicPharmacy\ElectronicPharmacy.csproj" -c Release - if ($LASTEXITCODE -ne 0) { throw "Сборка приложения завершилась с ошибкой" } + Write-Host "Публикация Avalonia (win-x64, self-contained)..." -ForegroundColor Cyan + if (Test-Path $pub) { Remove-Item $pub -Recurse -Force } + dotnet publish "src\Elfisa.Avalonia\Elfisa.Avalonia.csproj" -c Release -r win-x64 ` + --self-contained true -p:PublishSingleFile=false -p:DebugType=none -o $pub + if ($LASTEXITCODE -ne 0) { throw "Публикация приложения завершилась с ошибкой" } } +# Имя exe, совместимое с авто-обновлением старых WinForms-клиентов +# (батч-апдейтер перезапускает файл по имени «Электронная Фармация.exe»). +# apphost находит свой .dll независимо от имени exe, поэтому переименование безопасно. +$srcExe = Join-Path $pub "Elfisa.Avalonia.exe" +$dstExe = Join-Path $pub "Электронная Фармация.exe" +if (Test-Path $srcExe) { Move-Item $srcExe $dstExe -Force } +elseif (-not (Test-Path $dstExe)) { throw "Не найден собранный exe в $pub" } + Write-Host "Сборка установщика..." -ForegroundColor Cyan & $iscc "installer\ElfisaPharmacy.iss" if ($LASTEXITCODE -ne 0) { throw "Inno Setup завершился с ошибкой" } diff --git a/src/ElectronicPharmacy/Classes/ApiClient.cs b/src/ElectronicPharmacy/Classes/ApiClient.cs index e6c263d..5abad0b 100644 --- a/src/ElectronicPharmacy/Classes/ApiClient.cs +++ b/src/ElectronicPharmacy/Classes/ApiClient.cs @@ -374,6 +374,84 @@ namespace Электронная_Фармация.Classes } } + /// + /// Отчёт покупателя: позиции его заказов за период. Сервер скоупит по JWT покупателя. + /// + public async Task> GetBuyerReportAsync(DateTime from, DateTime to) + { + EnsureAuthenticated(); + var path = $"/api/buyer/report?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}"; + var responseBody = await SendAuthorizedGetAsync(path, "формирование отчёта"); + try + { + var response = JsonSerializer.Deserialize(responseBody, JsonOptions); + return response?.Lines ?? new List(); + } + catch (Exception ex) + { + AppDebugLog.Error("ApiClient", "Не удалось разобрать отчёт", ex); + throw new HttpRequestException($"Некорректный ответ сервера при формировании отчёта: {ex.Message}", ex); + } + } + + /// + /// Отправляет свободный запрос («промт») ИИ-сервису и получает готовый + /// документ (Word/Excel). Токен покупателя уходит в теле — сервис ходит на + /// платформу от его имени и видит ТОЛЬКО его данные. + /// + public async Task GenerateAiReportAsync(string prompt, string format = "docx") + { + EnsureAuthenticated(); + if (string.IsNullOrWhiteSpace(prompt)) + { + throw new ArgumentException("Пустой запрос для ИИ.", nameof(prompt)); + } + + var aiBase = AppConfig.AiBaseUrl.TrimEnd('/'); + var fullUrl = new Uri(new Uri(aiBase + "/"), "generate").ToString(); + var payload = new AiReportRequest { Prompt = prompt, Token = _token, Format = format }; + var requestJson = JsonSerializer.Serialize(payload, JsonOptions); + var stopwatch = Stopwatch.StartNew(); + + AppDebugLog.ApiRequest("POST", fullUrl, extra: $"ai prompt len={prompt.Length}, format={format}"); + + try + { + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, fullUrl)) + { + httpRequest.Content = new StringContent(requestJson, Encoding.UTF8, "application/json"); + var response = await _httpClient.SendAsync(httpRequest); + stopwatch.Stop(); + + if (!response.IsSuccessStatusCode) + { + var errBody = await response.Content.ReadAsStringAsync(); + AppDebugLog.ApiHttpError("POST", fullUrl, (int)response.StatusCode, errBody, "ai-report"); + var detail = TryDeserializeError(errBody)?.Error ?? errBody; + throw new HttpRequestException($"ИИ-сервис вернул {(int)response.StatusCode}: {detail}"); + } + + var bytes = await response.Content.ReadAsByteArrayAsync(); + var fileName = response.Content.Headers.ContentDisposition?.FileNameStar + ?? response.Content.Headers.ContentDisposition?.FileName + ?? (format == "xlsx" ? "Отчёт.xlsx" : "Отчёт.docx"); + fileName = fileName.Trim('"'); + AppDebugLog.ApiResponse("POST", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, + $"ai file={fileName}, {bytes.Length} bytes"); + return new AiReportResult { Content = bytes, FileName = fileName }; + } + } + catch (HttpRequestException) + { + throw; + } + catch (Exception ex) + { + stopwatch.Stop(); + throw WrapNetworkError(ex, "формирование ИИ-отчёта"); + } + } + public async Task> GetBuyerLocationsAsync() { EnsureAuthenticated(); @@ -758,6 +836,66 @@ namespace Электронная_Фармация.Classes public List Orders { get; set; } } + public sealed class AiReportRequest + { + [JsonPropertyName("prompt")] + public string Prompt { get; set; } + + [JsonPropertyName("token")] + public string Token { get; set; } + + [JsonPropertyName("format")] + public string Format { get; set; } + } + + public sealed class AiReportResult + { + public byte[] Content { get; set; } + public string FileName { get; set; } + } + + public sealed class BuyerReportResponse + { + [JsonPropertyName("lines")] + public List Lines { get; set; } + } + + public sealed class BuyerReportLineDto + { + [JsonPropertyName("order_id")] + public string OrderID { get; set; } + + [JsonPropertyName("global_sign")] + public string GlobalSign { get; set; } + + [JsonPropertyName("order_date")] + public DateTime? OrderDate { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("supplier")] + public string Supplier { get; set; } + + [JsonPropertyName("location")] + public string Location { get; set; } + + [JsonPropertyName("item_name")] + public string ItemName { get; set; } + + [JsonPropertyName("item_code")] + public string ItemCode { get; set; } + + [JsonPropertyName("qty")] + public decimal Qty { get; set; } + + [JsonPropertyName("unit_price")] + public decimal UnitPrice { get; set; } + + [JsonPropertyName("sum")] + public decimal Sum { get; set; } + } + public sealed class BuyerLocationDto { [JsonPropertyName("buyer_location_id")] diff --git a/src/ElectronicPharmacy/Classes/AppConfig.cs b/src/ElectronicPharmacy/Classes/AppConfig.cs index 96bae7c..0b30586 100644 --- a/src/ElectronicPharmacy/Classes/AppConfig.cs +++ b/src/ElectronicPharmacy/Classes/AppConfig.cs @@ -17,6 +17,10 @@ namespace Электронная_Фармация.Classes public const string DefaultInstallerDownloadUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe"; private const string LegacyApiBaseUrl = "http://195.34.241.84:9988"; private const string ApiBaseUrlEnvVar = "ELF_API_BASE_URL"; + /// ИИ-сервис отчётов (проксируется Caddy на Мак-мини). Можно переопределить + /// переменной ELF_AI_BASE_URL, напр. http://192.168.95.116:8808 для теста по локальной сети. + public const string DefaultAiBaseUrl = "https://24pharmdata.ru/ai"; + private const string AiBaseUrlEnvVar = "ELF_AI_BASE_URL"; private static bool _sqliteInteropPrepared; private static readonly object _sqliteInteropLock = new object(); @@ -111,6 +115,21 @@ namespace Электронная_Фармация.Classes } } + /// Базовый URL ИИ-сервиса отчётов. + public static string AiBaseUrl + { + get + { + var fromEnv = Environment.GetEnvironmentVariable(AiBaseUrlEnvVar); + if (!string.IsNullOrWhiteSpace(fromEnv)) + { + return fromEnv.Trim().TrimEnd('/'); + } + + return DefaultAiBaseUrl; + } + } + public static string NormalizeApiBaseUrl(string url) { if (string.IsNullOrWhiteSpace(url)) diff --git a/src/ElectronicPharmacy/Classes/AutoUpdater.cs b/src/ElectronicPharmacy/Classes/AutoUpdater.cs index 45d6542..f1433f0 100644 --- a/src/ElectronicPharmacy/Classes/AutoUpdater.cs +++ b/src/ElectronicPharmacy/Classes/AutoUpdater.cs @@ -74,6 +74,24 @@ namespace Электронная_Фармация.Classes throw new InvalidOperationException("В архиве не найден exe приложения."); } + // Переносим payload в путь без спецсимволов: имя корневой папки в архиве + // бывает в чужой кодировке, и такой путь ломает аргумент SRC у robocopy + // (особенно при запуске под elevated cmd с OEM-кодовой страницей). + var asciiPayload = Path.Combine(workRoot, "payload"); + try + { + if (Directory.Exists(asciiPayload)) + { + Directory.Delete(asciiPayload, true); + } + Directory.Move(payloadDir, asciiPayload); + payloadDir = asciiPayload; + } + catch + { + // Если перенос не удался — работаем с исходным путём. + } + var needsElevation = !IsDirectoryWritable(installDir); var updaterBat = Path.Combine(workRoot, "apply-update.cmd"); WriteUpdaterScript(updaterBat); @@ -82,12 +100,12 @@ namespace Электронная_Фармация.Classes ? "Нужны права администратора для установки в Program Files..." : "Подготовка перезапуска..."); + var batchArgs = Quote(payloadDir) + " " + Quote(installDir) + " " + + Quote(Path.GetFileName(currentExe)) + " " + + Process.GetCurrentProcess().Id; + var psi = new ProcessStartInfo { - FileName = updaterBat, - Arguments = Quote(payloadDir) + " " + Quote(installDir) + " " + - Quote(Path.GetFileName(currentExe)) + " " + - Process.GetCurrentProcess().Id, UseShellExecute = true, WorkingDirectory = workRoot, WindowStyle = needsElevation ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden, @@ -95,9 +113,19 @@ namespace Электронная_Фармация.Classes }; if (needsElevation) { - // UAC prompt — без этого robocopy в Program Files молча не заменяет exe. + // ВАЖНО: повышаем права через cmd.exe (PE-исполняемый), а НЕ через + // .cmd-файл напрямую. ShellExecute с verb=runas на .cmd срабатывает не + // на всех системах (у batch может не быть runas-ассоциации), из-за чего + // elevated-процесс молча не запускается и обновление не применяется. + psi.FileName = "cmd.exe"; + psi.Arguments = "/c \"\"" + updaterBat + "\" " + batchArgs + "\""; psi.Verb = "runas"; } + else + { + psi.FileName = updaterBat; + psi.Arguments = batchArgs; + } try { diff --git a/src/ElectronicPharmacy/ElectronicPharmacy.csproj b/src/ElectronicPharmacy/ElectronicPharmacy.csproj index a5da65e..f63d868 100644 --- a/src/ElectronicPharmacy/ElectronicPharmacy.csproj +++ b/src/ElectronicPharmacy/ElectronicPharmacy.csproj @@ -248,6 +248,9 @@ UserControl + + UserControl + UserControl diff --git a/src/ElectronicPharmacy/Form1.UiShell.cs b/src/ElectronicPharmacy/Form1.UiShell.cs index 89c3382..5e92beb 100644 --- a/src/ElectronicPharmacy/Form1.UiShell.cs +++ b/src/ElectronicPharmacy/Form1.UiShell.cs @@ -61,6 +61,7 @@ namespace Электронная_Фармация new SidebarItem { Key = "price", Text = "Прайс-лист", IconGlyph = "\uE14C" }, new SidebarItem { Key = "orders", Text = "Заказы", IconGlyph = "\uE7BF" }, new SidebarItem { Key = "invoices", Text = "Накладные", IconGlyph = "\uE8A5" }, + new SidebarItem { Key = "reports", Text = "Отчёты", IconGlyph = "" }, new SidebarItem { Key = "upload", Text = "Загрузить", IconGlyph = "\uE896" }, new SidebarItem { Key = "send", Text = "Отправить", IconGlyph = "\uE725" }, new SidebarItem { Key = "refusals", Text = "Отказы", IconGlyph = "\uE7BA" }, @@ -321,6 +322,9 @@ namespace Электронная_Фармация case "invoices": OpenInvoicesTab(); break; + case "reports": + OpenReportsTab(); + break; case "upload": tsBtnDownloadData_Click(this, EventArgs.Empty); _appHeader.SetActive("upload"); @@ -499,6 +503,12 @@ namespace Электронная_Фармация OpenDocumentTab("Отказы", "refusals", refusals, allowDuplicate: false); } + private void OpenReportsTab() + { + var reports = new UCReports(); + OpenDocumentTab("Отчёты", "reports", reports, allowDuplicate: false); + } + partial void ShowShellLoading(string message) { _loadingOverlay?.Show(message); diff --git a/src/ElectronicPharmacy/Properties/AssemblyInfo.cs b/src/ElectronicPharmacy/Properties/AssemblyInfo.cs index 1702d4b..aa62581 100644 --- a/src/ElectronicPharmacy/Properties/AssemblyInfo.cs +++ b/src/ElectronicPharmacy/Properties/AssemblyInfo.cs @@ -32,6 +32,6 @@ using System.Runtime.InteropServices; // пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ "*", пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.23.0")] -[assembly: AssemblyFileVersion("1.0.23.0")] +[assembly: AssemblyVersion("1.0.29.0")] +[assembly: AssemblyFileVersion("1.0.29.0")] diff --git a/src/ElectronicPharmacy/UserControls/UCReports.cs b/src/ElectronicPharmacy/UserControls/UCReports.cs new file mode 100644 index 0000000..fc0b1f0 --- /dev/null +++ b/src/ElectronicPharmacy/UserControls/UCReports.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using Электронная_Фармация.Classes; +using Электронная_Фармация.Properties; + +namespace Электронная_Фармация.UserControls +{ + /// + /// Раздел «Отчёты» покупателя. Данные тянутся с сервера (/api/buyer/report), + /// который скоупит их по JWT покупателя — клиент видит ТОЛЬКО свои заказы. + /// Все 4 отчёта агрегируются на клиенте из одного набора строк. + /// + public class UCReports : UserControl + { + private readonly DateTimePicker _dtFrom; + private readonly DateTimePicker _dtTo; + private readonly ComboBox _cboReport; + private readonly Button _btnBuild; + private readonly Label _lblTotals; + private readonly DataGridView _grid; + private readonly TextBox _txtPrompt; + private readonly ComboBox _cboFormat; + private readonly Button _btnAi; + private List _lines = new List(); + + // Палитра под общий стиль приложения (бирюза шапки #0F766E). + private static readonly Color Accent = Color.FromArgb(15, 118, 110); + private static readonly Color AccentHover = Color.FromArgb(13, 148, 136); + private static readonly Color TextMain = Color.FromArgb(31, 41, 55); + private static readonly Color TextMuted = Color.FromArgb(120, 128, 134); + private static readonly Color RowAlt = Color.FromArgb(247, 250, 250); + private static readonly Color SelBack = Color.FromArgb(178, 240, 230); + private static readonly Color GridLine = Color.FromArgb(230, 234, 236); + + private static void StylePrimaryButton(Button b) + { + b.FlatStyle = FlatStyle.Flat; + b.FlatAppearance.BorderSize = 0; + b.FlatAppearance.MouseOverBackColor = AccentHover; + b.FlatAppearance.MouseDownBackColor = AccentHover; + b.BackColor = Accent; + b.ForeColor = Color.White; + b.Cursor = Cursors.Hand; + b.Font = new Font("Segoe UI", 9F, FontStyle.Regular); + } + + public UCReports() + { + Dock = DockStyle.Fill; + + var uiFont = new Font("Segoe UI", 9F); + var top = new Panel { Dock = DockStyle.Top, Height = 100, BackColor = Color.White }; + + // --- строка 1: период + готовый отчёт по данным --- + var lblFrom = new Label { Text = "С", AutoSize = true, Left = 12, Top = 17, ForeColor = TextMain, Font = uiFont }; + _dtFrom = new DateTimePicker { Format = DateTimePickerFormat.Short, Width = 112, Left = 32, Top = 13, Value = DateTime.Today.AddMonths(-1), Font = uiFont }; + var lblTo = new Label { Text = "по", AutoSize = true, Left = 150, Top = 17, ForeColor = TextMain, Font = uiFont }; + _dtTo = new DateTimePicker { Format = DateTimePickerFormat.Short, Width = 112, Left = 176, Top = 13, Value = DateTime.Today, Font = uiFont }; + _cboReport = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 190, Left = 298, Top = 13, Font = uiFont, FlatStyle = FlatStyle.Flat }; + _cboReport.Items.AddRange(new object[] { "Мои заказы", "По поставщикам", "По товарам", "По аптекам" }); + _cboReport.SelectedIndex = 0; + _btnBuild = new Button { Text = "Сформировать", Left = 498, Top = 12, Width = 140, Height = 28 }; + _lblTotals = new Label { AutoSize = true, Left = 654, Top = 17, Text = "", ForeColor = Accent, Font = new Font("Segoe UI", 9F, FontStyle.Bold) }; + + top.Controls.AddRange(new Control[] { lblFrom, _dtFrom, lblTo, _dtTo, _cboReport, _btnBuild, _lblTotals }); + + // --- строка 2: свободный запрос к ИИ (клиент пишет словами -> Word/Excel) --- + var lblAi = new Label { Text = "Запрос ИИ:", AutoSize = true, Left = 12, Top = 61, ForeColor = TextMain, Font = uiFont }; + _txtPrompt = new TextBox { Left = 92, Top = 57, Width = 470, Font = uiFont, BorderStyle = BorderStyle.FixedSingle }; + _cboFormat = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Left = 574, Top = 57, Width = 92, Font = uiFont, FlatStyle = FlatStyle.Flat }; + _cboFormat.Items.AddRange(new object[] { "Word", "Excel" }); + _cboFormat.SelectedIndex = 0; + _btnAi = new Button { Text = "🤖 Сформировать ИИ", Left = 676, Top = 56, Width = 170, Height = 28 }; + _btnAi.Text = "Сформировать ИИ"; // без эмодзи (криво рендерился в кнопке) + var lblHint = new Label { AutoSize = true, Left = 92, Top = 83, ForeColor = TextMuted, Font = new Font("Segoe UI", 8F), + Text = "Напр.: «закупки по поставщикам за июль», «топ товаров за 3 месяца», «заказы у Farmsnab»" }; + + StylePrimaryButton(_btnBuild); + StylePrimaryButton(_btnAi); + + var tip = new ToolTip(); + tip.SetToolTip(_txtPrompt, "Опишите нужный отчёт словами — ИИ подберёт тип, период и соберёт документ."); + + top.Controls.AddRange(new Control[] { lblAi, _txtPrompt, _cboFormat, _btnAi, lblHint }); + + _grid = new DataGridView + { + Dock = DockStyle.Fill, + ReadOnly = true, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + AllowUserToResizeRows = false, + RowHeadersVisible = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, + BorderStyle = BorderStyle.None, + BackgroundColor = Color.White, + EnableHeadersVisualStyles = false, + ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.None, + ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing, + ColumnHeadersHeight = 34, + CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, + GridColor = GridLine, + RowHeadersBorderStyle = DataGridViewHeaderBorderStyle.None + }; + _grid.ColumnHeadersDefaultCellStyle.BackColor = Accent; + _grid.ColumnHeadersDefaultCellStyle.ForeColor = Color.White; + _grid.ColumnHeadersDefaultCellStyle.Font = new Font("Segoe UI", 9.5F, FontStyle.Bold); + _grid.ColumnHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleLeft; + _grid.ColumnHeadersDefaultCellStyle.Padding = new Padding(6, 0, 0, 0); + _grid.DefaultCellStyle.Font = new Font("Segoe UI", 9.5F); + _grid.DefaultCellStyle.ForeColor = TextMain; + _grid.DefaultCellStyle.SelectionBackColor = SelBack; + _grid.DefaultCellStyle.SelectionForeColor = TextMain; + _grid.DefaultCellStyle.Padding = new Padding(6, 0, 6, 0); + _grid.AlternatingRowsDefaultCellStyle.BackColor = RowAlt; + _grid.RowTemplate.Height = 30; + + Controls.Add(_grid); + Controls.Add(top); + + _btnBuild.Click += async (_, __) => await LoadAsync(); + _cboReport.SelectedIndexChanged += (_, __) => Render(); + _btnAi.Click += async (_, __) => await BuildAiAsync(); + _txtPrompt.KeyDown += async (_, e) => + { + if (e.KeyCode == Keys.Enter) { e.SuppressKeyPress = true; await BuildAiAsync(); } + }; + } + + private async Task BuildAiAsync() + { + var prompt = _txtPrompt.Text?.Trim(); + if (string.IsNullOrWhiteSpace(prompt)) + { + ToastNotification.ShowError("Введите запрос для ИИ."); + return; + } + + var token = Settings.Default.stringToken; + if (string.IsNullOrWhiteSpace(token)) + { + ToastNotification.ShowError("Не найден токен авторизации. Войдите в систему."); + return; + } + + try + { + _btnAi.Enabled = false; + _btnAi.Text = "Формирую…"; + + var fmt = _cboFormat.SelectedIndex == 1 ? "xlsx" : "docx"; + var client = new ApiClient(); + client.SetToken(token); + var result = await client.GenerateAiReportAsync(prompt, fmt); + + var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var safe = string.Join("_", (result.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, result.Content); + ToastNotification.ShowCustom("Отчёт готов, открываю…", Color.SeaGreen, Color.White); + Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true }); + } + catch (Exception ex) + { + AppDebugLog.Error("UCReports", "Ошибка ИИ-отчёта", ex); + ToastNotification.ShowError("Ошибка ИИ-отчёта: " + ex.Message); + } + finally + { + _btnAi.Enabled = true; + _btnAi.Text = "🤖 Сформировать ИИ"; + } + } + + private async Task LoadAsync() + { + try + { + _btnBuild.Enabled = false; + var token = Settings.Default.stringToken; + if (string.IsNullOrWhiteSpace(token)) + { + ToastNotification.ShowError("Не найден токен авторизации. Войдите в систему."); + return; + } + + var client = new ApiClient(); + client.SetToken(token); + _lines = await client.GetBuyerReportAsync(_dtFrom.Value.Date, _dtTo.Value.Date) + ?? new List(); + Render(); + + if (_lines.Count == 0) + { + ToastNotification.ShowCustom("За выбранный период заказов нет.", Color.DarkOrange, Color.White); + } + } + catch (Exception ex) + { + AppDebugLog.Error("UCReports", "Ошибка формирования отчёта", ex); + ToastNotification.ShowError("Ошибка отчёта: " + ex.Message); + } + finally + { + _btnBuild.Enabled = true; + } + } + + private void Render() + { + var dt = new DataTable(); + + switch (_cboReport.SelectedIndex) + { + case 0: // Мои заказы (по заказу) + dt.Columns.Add("Дата", typeof(string)); + dt.Columns.Add("Номер", typeof(string)); + dt.Columns.Add("Поставщик", typeof(string)); + dt.Columns.Add("Аптека", typeof(string)); + dt.Columns.Add("Позиций", typeof(int)); + dt.Columns.Add("Сумма", typeof(decimal)); + dt.Columns.Add("Статус", typeof(string)); + foreach (var g in _lines.GroupBy(l => l.OrderID) + .OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue)) + { + var f = g.First(); + var suppliers = string.Join(", ", g.Select(x => x.Supplier) + .Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()); + dt.Rows.Add( + f.OrderDate?.ToLocalTime().ToString("dd.MM.yyyy") ?? "", + f.GlobalSign, + suppliers, + f.Location, + g.Count(), + g.Sum(x => x.Sum), + f.Status); + } + break; + + case 1: // По поставщикам + dt.Columns.Add("Поставщик", typeof(string)); + dt.Columns.Add("Заказов", typeof(int)); + dt.Columns.Add("Позиций", typeof(int)); + dt.Columns.Add("Сумма", typeof(decimal)); + foreach (var g in _lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Supplier) ? "(не указан)" : l.Supplier) + .OrderByDescending(x => x.Sum(y => y.Sum))) + { + dt.Rows.Add(g.Key, g.Select(x => x.OrderID).Distinct().Count(), g.Count(), g.Sum(x => x.Sum)); + } + break; + + case 2: // По товарам + dt.Columns.Add("Товар", typeof(string)); + dt.Columns.Add("Код", typeof(string)); + dt.Columns.Add("Кол-во", typeof(decimal)); + dt.Columns.Add("Сумма", typeof(decimal)); + foreach (var g in _lines.GroupBy(l => ((l.ItemName ?? "").Trim() + "|" + (l.ItemCode ?? ""))) + .OrderByDescending(x => x.Sum(y => y.Sum))) + { + var f = g.First(); + dt.Rows.Add(f.ItemName, f.ItemCode, g.Sum(x => x.Qty), g.Sum(x => x.Sum)); + } + break; + + case 3: // По аптекам (грузополучателям) + dt.Columns.Add("Аптека", typeof(string)); + dt.Columns.Add("Заказов", typeof(int)); + dt.Columns.Add("Позиций", typeof(int)); + dt.Columns.Add("Сумма", typeof(decimal)); + foreach (var g in _lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Location) ? "(не указана)" : l.Location) + .OrderByDescending(x => x.Sum(y => y.Sum))) + { + dt.Rows.Add(g.Key, g.Select(x => x.OrderID).Distinct().Count(), g.Count(), g.Sum(x => x.Sum)); + } + break; + } + + _grid.DataSource = dt; + foreach (DataGridViewColumn c in _grid.Columns) + { + bool numeric = c.Name == "Сумма" || c.Name == "Кол-во" || c.Name == "Позиций" || c.Name == "Заказов"; + if (c.Name == "Сумма") c.DefaultCellStyle.Format = "N2"; + if (c.Name == "Кол-во") c.DefaultCellStyle.Format = "N3"; + if (numeric) + { + c.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight; + c.HeaderCell.Style.Alignment = DataGridViewContentAlignment.MiddleRight; + c.HeaderCell.Style.Padding = new Padding(0, 0, 6, 0); + } + // Узкие числовые колонки, широкие — текстовые (Fill распределит по весу). + if (c.Name == "Позиций" || c.Name == "Заказов") c.FillWeight = 45; + else if (c.Name == "Сумма" || c.Name == "Кол-во") c.FillWeight = 70; + else if (c.Name == "Дата" || c.Name == "Статус" || c.Name == "Код") c.FillWeight = 75; + else if (c.Name == "Номер") c.FillWeight = 90; + else c.FillWeight = 150; // Поставщик / Товар / Аптека + } + + int orders = _lines.Select(l => l.OrderID).Distinct().Count(); + decimal totalSum = _lines.Sum(l => l.Sum); + _lblTotals.Text = $"Заказов: {orders} Позиций: {_lines.Count} Сумма: {totalSum:N2}"; + } + } +} diff --git a/src/Elfisa.Avalonia/App.axaml b/src/Elfisa.Avalonia/App.axaml new file mode 100644 index 0000000..dd9e0f1 --- /dev/null +++ b/src/Elfisa.Avalonia/App.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #0F766E + + + + + + + + + + + + + + + + + diff --git a/src/Elfisa.Avalonia/App.axaml.cs b/src/Elfisa.Avalonia/App.axaml.cs new file mode 100644 index 0000000..cecd0c5 --- /dev/null +++ b/src/Elfisa.Avalonia/App.axaml.cs @@ -0,0 +1,35 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core; +using Avalonia.Data.Core.Plugins; +using System.Linq; +using Avalonia.Markup.Xaml; +using Elfisa.Avalonia.ViewModels; +using Elfisa.Avalonia.Views; + +namespace Elfisa.Avalonia; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + RequestedThemeVariant = Elfisa.Core.SettingsStore.Current.Theme == "Dark" + ? global::Avalonia.Styling.ThemeVariant.Dark + : global::Avalonia.Styling.ThemeVariant.Light; + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new RootViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/src/Elfisa.Avalonia/Assets/avalonia-logo.ico b/src/Elfisa.Avalonia/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/src/Elfisa.Avalonia/Assets/avalonia-logo.ico differ diff --git a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj new file mode 100644 index 0000000..5c3ec40 --- /dev/null +++ b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj @@ -0,0 +1,38 @@ + + + WinExe + net8.0 + enable + app.manifest + true + 2.0.1 + 2.0.1.0 + 2.0.1.0 + ЭльФиСА — Электронная Фармация + PharmData + + ru;en + + + + + + + + + + + + + + + None + All + + + + + + + + diff --git a/src/Elfisa.Avalonia/Program.cs b/src/Elfisa.Avalonia/Program.cs new file mode 100644 index 0000000..ea1dc00 --- /dev/null +++ b/src/Elfisa.Avalonia/Program.cs @@ -0,0 +1,24 @@ +using Avalonia; +using System; + +namespace Elfisa.Avalonia; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/src/Elfisa.Avalonia/README.md b/src/Elfisa.Avalonia/README.md new file mode 100644 index 0000000..7a84296 --- /dev/null +++ b/src/Elfisa.Avalonia/README.md @@ -0,0 +1,73 @@ +# ЭльФиСА — кросс-платформенный клиент (Avalonia) + +Порт десктопного клиента с WinForms на **Avalonia UI** (.NET 8) — +работает на **Windows, macOS и Linux** из одного кода. Начат как замена +WinForms-версии (которая только под Windows). + +## Что уже готово (фундамент + эталонный экран) +- **Elfisa.Core** — переносимая бизнес-логика (без UI): `ApiClient` + (логин, отчёт покупателя, ИИ-отчёт), DTO, `AppConfig`, `Session`. +- **Дизайн-система** в фирменной бирюзе (#0F766E): светлая/тёмная тема, + стили кнопок, полей, карточек, таблицы, сайдбара (`Styles/Elfisa.axaml`). +- **Экран входа** (`LoginView`) — реальная авторизация через `/auth/login`. +- **Шелл** (`ShellView`) — сайдбар-навигация + карточка пользователя + выход. +- **Отчёты** (`ReportsView`) — ПОЛНОСТЬЮ рабочий эталонный раздел: + период, 4 типа отчёта (агрегация как в WinForms), авто-загрузка, + свободный **ИИ-запрос → Word/Excel** (через `/ai`), таблица с динамическими + колонками. Данные скоупятся по JWT — только заказы этого покупателя. +- Заглушки остальных разделов (Прайс-лист, Заказы, Накладные, Отправить, + Отказы, Справочник). + +## Архитектура +MVVM (CommunityToolkit.Mvvm) + ViewLocator (VM → View по имени). +``` +Program → App → MainWindow(RootViewModel) + RootViewModel: LoginViewModel ⇄ ShellViewModel + ShellViewModel.CurrentPage: ReportsViewModel | PlaceholderPageViewModel | … +``` +Вся сетевая логика — в `Elfisa.Core` (переиспользуется и десктопом, и +будущим мобильным/веб-клиентом Avalonia). + +## Запуск +```bash +dotnet run --project src/Elfisa.Avalonia +``` +Dev-автологин (удобно при разработке — минуя экран входа): +```bash +# Windows PowerShell +$env:ELF_DEV_USER="user@example.com"; $env:ELF_DEV_PASS="***"; dotnet run --project src/Elfisa.Avalonia +``` +Переопределение адресов (по умолчанию https://24pharmdata.ru и .../ai): +`ELF_API_BASE_URL`, `ELF_AI_BASE_URL`. + +Сборка под конкретную ОС: +```bash +dotnet publish src/Elfisa.Avalonia -c Release -r osx-arm64 --self-contained # Mac (M-серия) +dotnet publish src/Elfisa.Avalonia -c Release -r win-x64 --self-contained # Windows +``` + +## Дорожная карта портирования +Логика этих экранов частично уже есть в WinForms `ApiClient` — переносится +в `Elfisa.Core` метод за методом, UI пишется по образцу `ReportsView`. + +| Раздел | Что нужно | Сложность | +|---|---|---| +| ✅ Прайс-лист | поиск + грид (/api/supplier-prices/summary) | готово (без добавления в заказ) | +| ✅ Заказы | список + карточка заказа с позициями | готово | +| ✅ Накладные | реестр приходных документов | готово | +| ✅ Отчёты | 4 типа + ИИ → Word/Excel | готово | +| ✅ Справочник | профиль + грузополучатели | готово | +| ✅ Отправить | поиск → корзина → создание заказа (POST /api/buyer/orders) | готов UI; реальная отправка не тестировалась (создаёт заказ на проде) | +| ⏳ Отказы | список отказных позиций | нужен серверный эндпоинт (RefuseItemsCount есть только в локальной БД WinForms) | +| ⏳ Автообновление | аналог AutoUpdater под каждую ОС | не начато | + +**Прайс-лист/Заказы/Накладные** строятся на рабочих эндпоинтах +(`supplier-prices/summary`, `buyer/report`). «Родные» `buyer/orders` и +`buyer/catalog` возвращают 500 (баг квотинга PascalCase в GORM-билдере). +Фикс для **buyer/orders** готов и застейджен (`es_api_service.exe.new`, +apply-new-exe.bat) — после деплоя Заказы/Накладные перейдут на него. +Каталог не используется (берём рабочий summary), его фикс — по желанию. + +Осталось: **Отправить** (корзина + создание заказа + DBF) и **Отказы**, +плюс автообновление и сохранение сессии. Логика переносится из WinForms, +переписывается только UI (XAML). diff --git a/src/Elfisa.Avalonia/Styles/Elfisa.axaml b/src/Elfisa.Avalonia/Styles/Elfisa.axaml new file mode 100644 index 0000000..0caf786 --- /dev/null +++ b/src/Elfisa.Avalonia/Styles/Elfisa.axaml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Elfisa.Avalonia/ViewLocator.cs b/src/Elfisa.Avalonia/ViewLocator.cs new file mode 100644 index 0000000..e5eca54 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Elfisa.Avalonia.ViewModels; + +namespace Elfisa.Avalonia; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/CartService.cs b/src/Elfisa.Avalonia/ViewModels/CartService.cs new file mode 100644 index 0000000..be09fc1 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/CartService.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +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(); + } +} + +/// Единая корзина заказа — общая для «Прайс-листа» и «Отправить». +public sealed class CartService +{ + public static CartService Instance { get; } = new(); + + public ObservableCollection Items { get; } = new(); + public event Action? Changed; + + public decimal Total => Items.Sum(c => c.LineSum); + public int Count => Items.Count; + + public void Add(PriceItem src, decimal qty = 1) + { + if (src is null || string.IsNullOrEmpty(src.SupplierPriceId)) return; + var existing = Items.FirstOrDefault(c => c.Source.SupplierPriceId == src.SupplierPriceId); + if (existing != null) existing.Qty += qty; + else + { + var ci = new CartItem(src) { Qty = qty }; + ci.Changed += Raise; + Items.Add(ci); + } + Raise(); + } + + public void Remove(CartItem item) + { + if (item is null) return; + item.Changed -= Raise; + Items.Remove(item); + Raise(); + } + + public void Clear() + { + foreach (var i in Items) i.Changed -= Raise; + Items.Clear(); + Raise(); + } + + private void Raise() => Changed?.Invoke(); +} diff --git a/src/Elfisa.Avalonia/ViewModels/DirectoryViewModel.cs b/src/Elfisa.Avalonia/ViewModels/DirectoryViewModel.cs new file mode 100644 index 0000000..7b8550d --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/DirectoryViewModel.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Elfisa.Core; + +namespace Elfisa.Avalonia.ViewModels; + +/// Справочник покупателя: профиль + грузополучатели (адреса). +public partial class DirectoryViewModel : ViewModelBase +{ + private readonly ApiClient _api = new(); + + public string UserName => Session.Current.Username ?? ""; + public string RoleLabel => Session.Current.Role switch + { + "buyer" => "Покупатель", + "supplier" => "Поставщик", + "manager" => "Менеджер", + "admin" => "Администратор", + _ => Session.Current.Role ?? "" + }; + + public ObservableCollection Locations { get; } = new(); + + [ObservableProperty] private bool _busy; + [ObservableProperty] private string? _status; + + public DirectoryViewModel() + { + _api.SetToken(Session.Current.Token); + if (Session.Current.IsAuthenticated) _ = LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + Busy = true; + var locs = await _api.GetBuyerLocationsAsync(); + Locations.Clear(); + foreach (var l in locs) Locations.Add(l); + if (Locations.Count == 0) Status = "Грузополучатели не заданы."; + } + catch (Exception ex) { Status = ex.Message; } + finally { Busy = false; } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/InvoicesViewModel.cs b/src/Elfisa.Avalonia/ViewModels/InvoicesViewModel.cs new file mode 100644 index 0000000..0e75c6d --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/InvoicesViewModel.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Elfisa.Core; + +namespace Elfisa.Avalonia.ViewModels; + +public sealed class InvoiceRow +{ + public string Date { get; init; } = ""; + public string Number { get; init; } = ""; + public string Supplier { get; init; } = ""; + public string Location { get; init; } = ""; + public int ItemsCount { get; init; } + public decimal Sum { get; init; } +} + +/// +/// Накладные — реестр приходных документов. Строится из /api/buyer/report +/// (после деплоя фикса /api/buyer/orders можно перейти на «родной» источник). +/// +public partial class InvoicesViewModel : ViewModelBase +{ + private readonly ApiClient _api = new(); + + public ObservableCollection Rows { get; } = new(); + + [ObservableProperty] private bool _busy; + [ObservableProperty] private string? _status; + [ObservableProperty] private string _totals = ""; + + public InvoicesViewModel() + { + _api.SetToken(Session.Current.Token); + if (Session.Current.IsAuthenticated) _ = LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + Busy = true; + var to = DateTime.Today; + var from = to.AddMonths(-24); + var lines = await _api.GetBuyerReportAsync(from, to); + + Rows.Clear(); + foreach (var g in lines.GroupBy(l => l.OrderId) + .OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue)) + { + var f = g.First(); + Rows.Add(new InvoiceRow + { + Date = f.OrderDate?.ToLocalTime().ToString("dd.MM.yyyy") ?? "", + Number = f.GlobalSign ?? "", + Supplier = string.Join(", ", g.Select(x => x.Supplier) + .Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()), + Location = f.Location ?? "", + ItemsCount = g.Count(), + Sum = g.Sum(x => x.Sum) + }); + } + + Totals = $"Накладных: {Rows.Count} Сумма: {Rows.Sum(r => r.Sum):N2}"; + if (Rows.Count == 0) Status = "Накладных за последние 2 года нет."; + } + catch (Exception ex) { Status = ex.Message; } + finally { Busy = false; } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs b/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs new file mode 100644 index 0000000..aa5f9c9 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +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 LoadPriceViewModel : ViewModelBase +{ + private readonly ApiClient _api = new(); + + /// Прайсы, подключённые покупателю (какие именно скачиваем). + public ObservableCollection PriceLists { get; } = new(); + + [ObservableProperty] private bool _busy; + [ObservableProperty] private double _progress; // 0..100 + [ObservableProperty] private string _progressText = ""; + [ObservableProperty] private string? _result; + [ObservableProperty] private string _priceListsInfo = "Загрузка списка прайсов…"; + + public string LastLoaded => + PriceCache.LoadedAt is { } dt + ? $"Последняя загрузка: {dt:dd.MM.yyyy HH:mm} — {PriceCache.Items.Count} позиций" + : "Прайс ещё не загружался в этой сессии."; + + public LoadPriceViewModel() + { + _api.SetToken(Session.Current.Token); + if (Session.Current.IsAuthenticated) + _ = LoadPriceListsAsync(); + } + + /// Тянет список подключённых прайсов — чтобы было видно, что именно скачиваем. + private async Task LoadPriceListsAsync() + { + try + { + var lists = await _api.GetBuyerPriceListsAsync(); + PriceLists.Clear(); + foreach (var pl in lists) PriceLists.Add(pl); + PriceListsInfo = lists.Count == 0 + ? "Прайсы пока не подключены — обратитесь к менеджеру." + : $"Подключено прайсов: {lists.Count}"; + } + catch { PriceListsInfo = "Не удалось получить список подключённых прайсов."; } + } + + [RelayCommand] + private async Task LoadAsync() + { + Result = null; + try + { + Busy = true; + Progress = 0; + ProgressText = "Соединение с сервером…"; + + var all = new List(); + const int page = 500; + int offset = 0, total = 0; + + while (true) + { + var resp = await _api.GetPriceSummaryAsync(null, limit: page, offset: offset); + var batch = resp.Summary ?? new(); + if (resp.TotalDrugs > 0) total = resp.TotalDrugs; + all.AddRange(batch); + + Progress = total > 0 ? Math.Min(100.0, all.Count * 100.0 / total) : 0; + ProgressText = total > 0 + ? $"Загружено {all.Count} из {total} позиций…" + : $"Загружено {all.Count} позиций…"; + + offset += batch.Count; + if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 30000) + break; + } + + PriceCache.Items = all; + PriceCache.LoadedAt = DateTime.Now; + Progress = 100; + ProgressText = "Готово."; + + var suppliers = all.Select(x => x.SupplierName).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().Count(); + Result = $"Прайс загружен: {all.Count} позиций от {suppliers} поставщик(ов)."; + OnPropertyChanged(nameof(LastLoaded)); + } + catch (Exception ex) + { + Result = "Ошибка загрузки: " + ex.Message; + ProgressText = ""; + } + finally { Busy = false; } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs b/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..b7520be --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Elfisa.Core; + +namespace Elfisa.Avalonia.ViewModels; + +public partial class LoginViewModel : ViewModelBase +{ + private readonly Action _onSuccess; + private readonly ApiClient _api = new(); + + [ObservableProperty] private string _username = ""; + [ObservableProperty] private string _password = ""; + [ObservableProperty] private string? _error; + [ObservableProperty] private bool _busy; + + public LoginViewModel(Action onSuccess) => _onSuccess = onSuccess; + + [RelayCommand] + private async Task LoginAsync() + { + Error = null; + if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password)) + { + Error = "Введите логин и пароль."; + return; + } + + try + { + Busy = true; + var resp = await _api.LoginAsync(Username.Trim(), Password); + Session.Current.Token = resp.Token; + Session.Current.Role = resp.Role; + Session.Current.Username = Username.Trim(); + _onSuccess(); + } + catch (ApiException ex) { Error = ex.Message; } + catch (Exception ex) { Error = "Не удалось войти: " + ex.Message; } + finally { Busy = false; } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/OrdersViewModel.cs b/src/Elfisa.Avalonia/ViewModels/OrdersViewModel.cs new file mode 100644 index 0000000..5c4435f --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/OrdersViewModel.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Elfisa.Core; + +namespace Elfisa.Avalonia.ViewModels; + +public sealed class OrderItemRow +{ + public string Name { get; init; } = ""; + public string Code { get; init; } = ""; + public decimal Qty { get; init; } + public decimal Price { get; init; } + public decimal Sum { get; init; } + public string? SupplierPriceId { get; init; } + public string? Barcode { get; init; } +} + +public sealed class OrderSummary +{ + public string Date { get; init; } = ""; + public string Number { get; init; } = ""; + public string Supplier { get; init; } = ""; + public string Location { get; init; } = ""; + public string Status { get; init; } = ""; + public int ItemsCount { get; init; } + public decimal Sum { get; init; } + public ObservableCollection Items { get; init; } = new(); + + public bool IsDraft { get; init; } + public string? DraftId { get; init; } + public string? LocationId { get; init; } + public string? Comment { get; init; } +} + +/// +/// Заказы: локальные черновики (статус «Не отправлен») + серверные заказы (из /api/buyer/report). +/// Черновик можно отправить (создать реальный заказ) или удалить. +/// +public partial class OrdersViewModel : ViewModelBase +{ + private readonly ApiClient _api = new(); + + public ObservableCollection Orders { get; } = new(); + + [ObservableProperty] private OrderSummary? _selectedOrder; + [ObservableProperty] private bool _busy; + [ObservableProperty] private string? _status; + + public OrdersViewModel() + { + _api.SetToken(Session.Current.Token); + if (Session.Current.IsAuthenticated) _ = LoadAsync(); + } + + [RelayCommand] + private async Task LoadAsync() + { + Status = null; + try + { + Busy = true; + Orders.Clear(); + + // 1) Локальные черновики — вверху, статус «Не отправлен» + foreach (var d in DraftStore.All) + { + var items = new ObservableCollection( + d.Items.Select(i => new OrderItemRow + { + Name = i.Name, Qty = i.Qty, Price = i.Price, Sum = i.LineSum, + SupplierPriceId = i.SupplierPriceId, Barcode = i.Barcode + })); + Orders.Add(new OrderSummary + { + Date = d.CreatedAt.ToString("dd.MM.yyyy"), + Number = d.Number, + Supplier = string.Join(", ", d.Items.Select(i => i.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()), + Location = d.LocationAddress ?? "", + Status = "Не отправлен", + ItemsCount = d.ItemsCount, + Sum = d.Total, + Items = items, + IsDraft = true, + DraftId = d.Id, + LocationId = d.LocationId, + Comment = d.Comment + }); + } + + // 2) Серверные заказы + var to = DateTime.Today; + var from = to.AddMonths(-24); + var lines = await _api.GetBuyerReportAsync(from, to); + foreach (var g in lines.GroupBy(l => l.OrderId) + .OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue)) + { + var f = g.First(); + var items = new ObservableCollection( + g.Select(l => new OrderItemRow + { + Name = l.ItemName ?? "", Code = l.ItemCode ?? "", + Qty = l.Qty, Price = l.UnitPrice, Sum = l.Sum + })); + Orders.Add(new OrderSummary + { + Date = f.OrderDate?.ToLocalTime().ToString("dd.MM.yyyy") ?? "", + Number = f.GlobalSign ?? "", + Supplier = string.Join(", ", g.Select(x => x.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()), + Location = f.Location ?? "", + Status = f.Status ?? "", + ItemsCount = g.Count(), + Sum = g.Sum(x => x.Sum), + Items = items + }); + } + + SelectedOrder = Orders.FirstOrDefault(); + } + catch (Exception ex) { Status = ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private async Task SendDraftAsync() + { + if (SelectedOrder is not { IsDraft: true } d) return; + Status = null; + try + { + Busy = true; + var req = new BuyerOrderCreateRequest + { + GlobalSign = d.Number.StartsWith("EX-", StringComparison.OrdinalIgnoreCase) ? d.Number : null, + LocationId = d.LocationId, + Comment = d.Comment, + Items = d.Items.Select(i => new BuyerOrderItemReq + { + SupplierPriceId = i.SupplierPriceId ?? "", + Qty = (double)i.Qty, + ItemName = i.Name, + Barcode = i.Barcode + }).ToList() + }; + var resp = await _api.CreateOrderAsync(req); + if (d.DraftId is { } id) DraftStore.Remove(id); + Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} отправлен ({resp.ItemsCount} поз., {resp.TotalAmount:N2})."; + await LoadAsync(); + } + catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; } + finally { Busy = false; } + } + + [RelayCommand] + private async Task DeleteDraftAsync() + { + if (SelectedOrder is not { IsDraft: true } d || d.DraftId is not { } id) return; + DraftStore.Remove(id); + await LoadAsync(); + Status = "Черновик удалён."; + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs b/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs new file mode 100644 index 0000000..86a9ca1 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs @@ -0,0 +1,15 @@ +namespace Elfisa.Avalonia.ViewModels; + +/// Заглушка ещё не портированного раздела. +public partial class PlaceholderPageViewModel : ViewModelBase +{ + public string Title { get; } + public string Description { get; } + public string Note { get; } = "Раздел портируется на Avalonia. Пока доступен в WinForms-клиенте."; + + public PlaceholderPageViewModel(string title, string description) + { + Title = title; + Description = description; + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs b/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs new file mode 100644 index 0000000..5b1f018 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs @@ -0,0 +1,281 @@ +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(); + public ObservableCollection MyPriceLists { get; } = new(); + + [ObservableProperty] private string _search = ""; + [ObservableProperty] private BuyerPriceListDto? _selectedPriceList; + [ObservableProperty] private bool _hasMultiplePriceLists; + [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) + { + _ = LoadPriceListsAsync(); // грузит «Мои прайсы» → ставит выбор → запускает поиск + _ = LoadLocationsAsync(); + } + } + + private bool _suppressPriceListSearch; + + /// Загружает подключённые покупателю прайсы в дропдаун «Мои прайсы». + private async Task LoadPriceListsAsync() + { + List lists; + try { lists = await _api.GetBuyerPriceListsAsync(); } + catch { lists = new(); } + + MyPriceLists.Clear(); + MyPriceLists.Add(new BuyerPriceListDto { PriceListId = null, Name = "Все прайсы" }); + foreach (var pl in lists) MyPriceLists.Add(pl); + HasMultiplePriceLists = lists.Count > 1; + + _suppressPriceListSearch = true; + // Один подключён — выбираем его; несколько или ни одного — «Все прайсы». + SelectedPriceList = lists.Count == 1 ? MyPriceLists[1] : MyPriceLists[0]; + _suppressPriceListSearch = false; + + await SearchAsync(); + } + + partial void OnSelectedPriceListChanged(BuyerPriceListDto? value) + { + if (_suppressPriceListSearch) return; + _ = SearchAsync(); + } + + 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, priceListId: SelectedPriceList?.PriceListId); + 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; } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs b/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs new file mode 100644 index 0000000..b438037 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs @@ -0,0 +1,215 @@ +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) +} + +/// Строка отчёта: ячейки как строки, доступ по индексу для DataGrid. +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 _lines = new(); + + /// Сигнал View пересобрать колонки DataGrid. + public event Action? ColumnsChanged; + + public ObservableCollection Columns { get; } = new(); + public ObservableCollection 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 cols, List rows) Aggregate(List 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 + { + 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 + { + 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 + { + 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 + { + 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 { /* открыть не удалось — путь показан в статусе */ } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs new file mode 100644 index 0000000..6016726 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs @@ -0,0 +1,48 @@ +using System; +using Elfisa.Core; + +namespace Elfisa.Avalonia.ViewModels; + +/// Корневой VM: переключает экран входа и основной шелл. +public partial class RootViewModel : ViewModelBase +{ + private ViewModelBase _current = null!; + public ViewModelBase Current + { + get => _current; + private set => SetProperty(ref _current, value); + } + + public RootViewModel() + { + Current = new LoginViewModel(OnLoggedIn); + TryDevAutoLogin(); + } + + // Dev-режим: если заданы ELF_DEV_USER/ELF_DEV_PASS — входим автоматически. + // Обычных пользователей не затрагивает (переменных нет). + private async void TryDevAutoLogin() + { + var u = Environment.GetEnvironmentVariable("ELF_DEV_USER"); + var p = Environment.GetEnvironmentVariable("ELF_DEV_PASS"); + if (string.IsNullOrWhiteSpace(u) || string.IsNullOrWhiteSpace(p)) return; + try + { + var api = new ApiClient(); + var resp = await api.LoginAsync(u, p); + Session.Current.Token = resp.Token; + Session.Current.Role = resp.Role; + Session.Current.Username = u; + OnLoggedIn(); + } + catch { /* остаёмся на экране входа */ } + } + + private void OnLoggedIn() => Current = new ShellViewModel(Logout); + + private void Logout() + { + Session.Current.Clear(); + Current = new LoginViewModel(OnLoggedIn); + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs b/src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs new file mode 100644 index 0000000..bae2be9 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.ObjectModel; +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 SendOrderViewModel : ViewModelBase +{ + private readonly ApiClient _api = new(); + private readonly CartService _cart = CartService.Instance; + + public ObservableCollection Results { get; } = new(); + public ObservableCollection Cart => _cart.Items; + public ObservableCollection 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.Changed += Recalc; + Recalc(); + if (Session.Current.IsAuthenticated) _ = InitAsync(); + } + + 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; } + _cart.Add(SelectedResult); + } + + [RelayCommand] + private void RemoveFromCart(CartItem? item) => _cart.Remove(item!); + + private void Recalc() => CartTotal = _cart.Total; + + [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.Items.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(); + } + catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; } + finally { Busy = false; } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/SettingsViewModel.cs b/src/Elfisa.Avalonia/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..61db261 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/SettingsViewModel.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Elfisa.Core; + +namespace Elfisa.Avalonia.ViewModels; + +public partial class SettingsViewModel : ViewModelBase +{ + private readonly ApiClient _api = new(); + + [ObservableProperty] private string _apiBaseUrl = ""; + public string[] Themes { get; } = { "Светлая", "Тёмная" }; + [ObservableProperty] private int _themeIndex; + + public ObservableCollection Locations { get; } = new(); + [ObservableProperty] private BuyerLocationDto? _selectedLocation; + + public SettingsViewModel() + { + _api.SetToken(Session.Current.Token); + ApiBaseUrl = SettingsStore.Current.ApiBaseUrl ?? AppConfig.DefaultApiBaseUrl; + ThemeIndex = SettingsStore.Current.Theme == "Dark" ? 1 : 0; + if (Session.Current.IsAuthenticated) _ = LoadLocationsAsync(); + } + + 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 { /* локации не критичны для настроек */ } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs b/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs new file mode 100644 index 0000000..f0f0076 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Elfisa.Core; + +namespace Elfisa.Avalonia.ViewModels; + +public class NavItem +{ + public string Title { get; } + public string Icon { get; } // глиф Segoe MDL2 Assets + public Func Factory { get; } + public NavItem(string title, string icon, Func factory) + { + Title = title; Icon = icon; Factory = factory; + } +} + +/// Открытая вкладка-документ. +public sealed class DocumentTab +{ + public string Title { get; } + public string Key { get; } + public ViewModelBase Content { get; } + public DocumentTab(string title, string key, ViewModelBase content) + { + Title = title; Key = key; Content = content; + } +} + +public partial class ShellViewModel : ViewModelBase +{ + private readonly Action _logout; + private readonly ApiClient _api = new(); + private bool _sync; + + public ObservableCollection NavItems { get; } + public ObservableCollection Tabs { get; } = new(); + + [ObservableProperty] private NavItem? _selectedNav; + [ObservableProperty] private DocumentTab? _activeTab; + + public string UserName => Session.Current.Username ?? ""; + public string RoleLabel => Session.Current.Role switch + { + "buyer" => "Покупатель", + "supplier" => "Поставщик", + "manager" => "Менеджер", + "admin" => "Администратор", + _ => Session.Current.Role ?? "" + }; + + [ObservableProperty] private string _pharmacyAddress = ""; + + // Segoe MDL2 Assets — те же глифы, что в WinForms-версии + private const string IcoPrice = ""; + private const string IcoOrders = ""; + private const string IcoInvoices = ""; + private const string IcoReports = ""; + private const string IcoUpload = ""; + private const string IcoSend = ""; + private const string IcoRefusals = ""; + private const string IcoDirectory = ""; + + public ShellViewModel(Action logout) + { + _logout = logout; + _api.SetToken(Session.Current.Token); + + NavItems = new ObservableCollection + { + new("Прайс-лист", IcoPrice, () => new PriceListViewModel()), + new("Заказы", IcoOrders, () => new OrdersViewModel()), + new("Накладные", IcoInvoices, () => new InvoicesViewModel()), + new("Отчёты", IcoReports, () => new ReportsViewModel()), + new("Загрузить", IcoUpload, () => new LoadPriceViewModel()), + new("Отправить", IcoSend, () => new SendOrderViewModel()), + new("Отказы", IcoRefusals, () => new PlaceholderPageViewModel("Отказы", + "Отказные позиции по заказам. Нужен серверный эндпоинт (RefuseItemsCount сейчас только в локальной БД WinForms).")), + new("Справочник", IcoDirectory, () => new DirectoryViewModel()), + }; + + SelectedNav = NavItems.First(n => n.Title == "Отчёты"); + if (Session.Current.IsAuthenticated) _ = LoadPharmacyAsync(); + } + + private async Task LoadPharmacyAsync() + { + try + { + var locs = await _api.GetBuyerLocationsAsync(); + var def = locs.FirstOrDefault(l => l.IsDefault) ?? locs.FirstOrDefault(); + PharmacyAddress = def?.Address ?? ""; + } + catch { } + } + + partial void OnSelectedNavChanged(NavItem? value) + { + if (_sync || value is null) return; + OpenOrActivate(value); + } + + private void OpenOrActivate(NavItem nav) + { + var existing = Tabs.FirstOrDefault(t => t.Key == nav.Title); + if (existing is not null) { ActiveTab = existing; return; } + var tab = new DocumentTab(nav.Title, nav.Title, nav.Factory()); + Tabs.Add(tab); + ActiveTab = tab; + } + + partial void OnActiveTabChanged(DocumentTab? value) + { + if (value is null) return; + _sync = true; + SelectedNav = NavItems.FirstOrDefault(n => n.Title == value.Key); + _sync = false; + } + + [RelayCommand] + private void CloseTab(DocumentTab? tab) + { + if (tab is null) return; + int idx = Tabs.IndexOf(tab); + bool wasActive = ReferenceEquals(ActiveTab, tab); + Tabs.Remove(tab); + if (wasActive) + ActiveTab = Tabs.Count == 0 ? null : Tabs[Math.Min(idx, Tabs.Count - 1)]; + } + + [RelayCommand] private void Logout() => _logout(); +} diff --git a/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs b/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..da8f4b2 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Elfisa.Avalonia.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/src/Elfisa.Avalonia/Views/DirectoryView.axaml b/src/Elfisa.Avalonia/Views/DirectoryView.axaml new file mode 100644 index 0000000..0e2bcac --- /dev/null +++ b/src/Elfisa.Avalonia/Views/DirectoryView.axaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Elfisa.Avalonia/Views/DirectoryView.axaml.cs b/src/Elfisa.Avalonia/Views/DirectoryView.axaml.cs new file mode 100644 index 0000000..dae19af --- /dev/null +++ b/src/Elfisa.Avalonia/Views/DirectoryView.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Elfisa.Avalonia.Views; + +public partial class DirectoryView : UserControl +{ + public DirectoryView() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/Elfisa.Avalonia/Views/InvoicesView.axaml b/src/Elfisa.Avalonia/Views/InvoicesView.axaml new file mode 100644 index 0000000..496c5a6 --- /dev/null +++ b/src/Elfisa.Avalonia/Views/InvoicesView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Elfisa.Avalonia/Views/InvoicesView.axaml.cs b/src/Elfisa.Avalonia/Views/InvoicesView.axaml.cs new file mode 100644 index 0000000..cf8f32e --- /dev/null +++ b/src/Elfisa.Avalonia/Views/InvoicesView.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Elfisa.Avalonia.Views; + +public partial class InvoicesView : UserControl +{ + public InvoicesView() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/Elfisa.Avalonia/Views/LoadPriceView.axaml b/src/Elfisa.Avalonia/Views/LoadPriceView.axaml new file mode 100644 index 0000000..e935150 --- /dev/null +++ b/src/Elfisa.Avalonia/Views/LoadPriceView.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +