v1.0.27: ИИ-отчёты по запросу в разделе «Отчёты»

Клиент пишет запрос словами («закупки по поставщикам за июль»,
«топ товаров за 3 месяца», «заказы у Farmsnab»), ИИ на Мак-мини
подбирает тип/период/фильтр, платформа отдаёт данные строго этого
покупателя, собирается Word/Excel и открывается автоматически.

- UCReports: строка «Запрос ИИ» + Word/Excel + кнопка
- ApiClient.GenerateAiReportAsync -> POST {AiBase}/generate
- AppConfig.AiBaseUrl (default https://24pharmdata.ru/ai,
  override ELF_AI_BASE_URL для локального теста)

Сервер: Caddy проксирует /ai/* -> Мак-мини (Ollama qwen2.5:7b +
FastAPI). LLM только распознаёт запрос, цифры считает код.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Exest 2026-08-07 16:24:19 +03:00
parent f7fb7ed1b0
commit a2d06000e1
8 changed files with 175 additions and 3 deletions

View File

@ -394,6 +394,64 @@ namespace Электронная_Фармация.Classes
} }
} }
/// <summary>
/// Отправляет свободный запрос («промт») ИИ-сервису и получает готовый
/// документ (Word/Excel). Токен покупателя уходит в теле — сервис ходит на
/// платформу от его имени и видит ТОЛЬКО его данные.
/// </summary>
public async Task<AiReportResult> 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<List<BuyerLocationDto>> GetBuyerLocationsAsync() public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
{ {
EnsureAuthenticated(); EnsureAuthenticated();
@ -778,6 +836,24 @@ namespace Электронная_Фармация.Classes
public List<BuyerOrderListItemDto> Orders { get; set; } public List<BuyerOrderListItemDto> 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 public sealed class BuyerReportResponse
{ {
[JsonPropertyName("lines")] [JsonPropertyName("lines")]

View File

@ -17,6 +17,10 @@ namespace Электронная_Фармация.Classes
public const string DefaultInstallerDownloadUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe"; public const string DefaultInstallerDownloadUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe";
private const string LegacyApiBaseUrl = "http://195.34.241.84:9988"; private const string LegacyApiBaseUrl = "http://195.34.241.84:9988";
private const string ApiBaseUrlEnvVar = "ELF_API_BASE_URL"; private const string ApiBaseUrlEnvVar = "ELF_API_BASE_URL";
/// <summary>ИИ-сервис отчётов (проксируется Caddy на Мак-мини). Можно переопределить
/// переменной ELF_AI_BASE_URL, напр. http://192.168.95.116:8808 для теста по локальной сети.</summary>
public const string DefaultAiBaseUrl = "https://24pharmdata.ru/ai";
private const string AiBaseUrlEnvVar = "ELF_AI_BASE_URL";
private static bool _sqliteInteropPrepared; private static bool _sqliteInteropPrepared;
private static readonly object _sqliteInteropLock = new object(); private static readonly object _sqliteInteropLock = new object();
@ -111,6 +115,21 @@ namespace Электронная_Фармация.Classes
} }
} }
/// <summary>Базовый URL ИИ-сервиса отчётов.</summary>
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) public static string NormalizeApiBaseUrl(string url)
{ {
if (string.IsNullOrWhiteSpace(url)) if (string.IsNullOrWhiteSpace(url))

View File

@ -32,6 +32,6 @@ using System.Runtime.InteropServices;
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ // пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ "*", пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ: // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ "*", пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ:
// [assembly: AssemblyVersion("1.0.*")] // [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.26.0")] [assembly: AssemblyVersion("1.0.27.0")]
[assembly: AssemblyFileVersion("1.0.26.0")] [assembly: AssemblyFileVersion("1.0.27.0")]

View File

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data; using System.Data;
using System.Diagnostics;
using System.Drawing; using System.Drawing;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
@ -23,13 +25,16 @@ namespace Электронная_Фармация.UserControls
private readonly Button _btnBuild; private readonly Button _btnBuild;
private readonly Label _lblTotals; private readonly Label _lblTotals;
private readonly DataGridView _grid; private readonly DataGridView _grid;
private readonly TextBox _txtPrompt;
private readonly ComboBox _cboFormat;
private readonly Button _btnAi;
private List<BuyerReportLineDto> _lines = new List<BuyerReportLineDto>(); private List<BuyerReportLineDto> _lines = new List<BuyerReportLineDto>();
public UCReports() public UCReports()
{ {
Dock = DockStyle.Fill; Dock = DockStyle.Fill;
var top = new Panel { Dock = DockStyle.Top, Height = 46 }; var top = new Panel { Dock = DockStyle.Top, Height = 90 };
var lblFrom = new Label { Text = "С", AutoSize = true, Left = 10, Top = 15 }; var lblFrom = new Label { Text = "С", AutoSize = true, Left = 10, Top = 15 };
_dtFrom = new DateTimePicker { Format = DateTimePickerFormat.Short, Width = 110, Left = 30, Top = 11, Value = DateTime.Today.AddMonths(-1) }; _dtFrom = new DateTimePicker { Format = DateTimePickerFormat.Short, Width = 110, Left = 30, Top = 11, Value = DateTime.Today.AddMonths(-1) };
@ -43,6 +48,21 @@ namespace Электронная_Фармация.UserControls
top.Controls.AddRange(new Control[] { lblFrom, _dtFrom, lblTo, _dtTo, _cboReport, _btnBuild, _lblTotals }); top.Controls.AddRange(new Control[] { lblFrom, _dtFrom, lblTo, _dtTo, _cboReport, _btnBuild, _lblTotals });
// Вторая строка — свободный запрос к ИИ: клиент пишет что хочет, ИИ отдаёт Word/Excel.
var lblAi = new Label { Text = "Запрос ИИ:", AutoSize = true, Left = 10, Top = 57 };
_txtPrompt = new TextBox { Left = 88, Top = 53, Width = 360, Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
_cboFormat = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Left = 456, Top = 53, Width = 90, Anchor = AnchorStyles.Top | AnchorStyles.Right };
_cboFormat.Items.AddRange(new object[] { "Word", "Excel" });
_cboFormat.SelectedIndex = 0;
_btnAi = new Button { Text = "🤖 Сформировать ИИ", Left = 554, Top = 52, Width = 170, Height = 26, Anchor = AnchorStyles.Top | AnchorStyles.Right };
var lblHint = new Label { AutoSize = true, Left = 88, Top = 76, ForeColor = Color.Gray,
Text = "Напр.: «закупки по поставщикам за июль», «топ товаров за 3 месяца», «заказы у Farmsnab»" };
var tip = new ToolTip();
tip.SetToolTip(_txtPrompt, "Опишите нужный отчёт словами — ИИ подберёт тип, период и соберёт документ.");
top.Controls.AddRange(new Control[] { lblAi, _txtPrompt, _cboFormat, _btnAi, lblHint });
_grid = new DataGridView _grid = new DataGridView
{ {
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
@ -61,6 +81,63 @@ namespace Электронная_Фармация.UserControls
_btnBuild.Click += async (_, __) => await LoadAsync(); _btnBuild.Click += async (_, __) => await LoadAsync();
_cboReport.SelectedIndexChanged += (_, __) => Render(); _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() private async Task LoadAsync()