Release v1.0.26: раздел «Отчёты» для покупателя

Новый раздел «Отчёты» в десктопе (buyer-scoped, данные с сервера):
- Мои заказы за период, По поставщикам, По товарам, По аптекам
- фильтр по датам, агрегация клиентски из одного набора строк
- сервер: GET /api/buyer/report скоупит строки по JWT покупателя
  (клиент видит ТОЛЬКО свои заказы)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Exest 2026-08-07 14:06:41 +03:00
parent 557f8667ad
commit a72f2aee81
10 changed files with 277 additions and 2 deletions

View File

@ -29,6 +29,9 @@
<setting name="IntDefaultPercentMarkup" serializeAs="String">
<value>0</value>
</setting>
<setting name="ClientMarkupPercent" serializeAs="String">
<value>0</value>
</setting>
<setting name="stringLogin" serializeAs="String">
<value />
</setting>
@ -38,6 +41,21 @@
<setting name="stringToken" serializeAs="String">
<value />
</setting>
<setting name="InvoiceExportPath" serializeAs="String">
<value />
</setting>
<setting name="UpdateCheckBaseUrl" serializeAs="String">
<value>https://git.24pharmdata.ru</value>
</setting>
<setting name="UpdateRepo" serializeAs="String">
<value>pharmdata/elfisa-pharmacy</value>
</setting>
<setting name="UpdateApiToken" serializeAs="String">
<value />
</setting>
<setting name="UpgradeSettings" serializeAs="String">
<value>True</value>
</setting>
</Электронная_Фармация.Properties.Settings>
</userSettings>
<runtime>

View File

@ -374,6 +374,26 @@ namespace Электронная_Фармация.Classes
}
}
/// <summary>
/// Отчёт покупателя: позиции его заказов за период. Сервер скоупит по JWT покупателя.
/// </summary>
public async Task<List<BuyerReportLineDto>> 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<BuyerReportResponse>(responseBody, JsonOptions);
return response?.Lines ?? new List<BuyerReportLineDto>();
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать отчёт", ex);
throw new HttpRequestException($"Некорректный ответ сервера при формировании отчёта: {ex.Message}", ex);
}
}
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
{
EnsureAuthenticated();
@ -758,6 +778,48 @@ namespace Электронная_Фармация.Classes
public List<BuyerOrderListItemDto> Orders { get; set; }
}
public sealed class BuyerReportResponse
{
[JsonPropertyName("lines")]
public List<BuyerReportLineDto> 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")]

View File

@ -248,6 +248,9 @@
<Compile Include="UserControls\UCRefusals.cs">
<SubType>UserControl</SubType>
</Compile>
<Compile Include="UserControls\UCReports.cs">
<SubType>UserControl</SubType>
</Compile>
<Compile Include="UserControls\UCPriceList.cs">
<SubType>UserControl</SubType>
</Compile>

View File

@ -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);

View File

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

View File

@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using Электроннаяармация.Classes;
using Электроннаяармация.Properties;
namespace Электроннаяармация.UserControls
{
/// <summary>
/// Раздел «Отчёты» покупателя. Данные тянутся с сервера (/api/buyer/report),
/// который скоупит их по JWT покупателя — клиент видит ТОЛЬКО свои заказы.
/// Все 4 отчёта агрегируются на клиенте из одного набора строк.
/// </summary>
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 List<BuyerReportLineDto> _lines = new List<BuyerReportLineDto>();
public UCReports()
{
Dock = DockStyle.Fill;
var top = new Panel { Dock = DockStyle.Top, Height = 46 };
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) };
var lblTo = new Label { Text = "по", AutoSize = true, Left = 146, Top = 15 };
_dtTo = new DateTimePicker { Format = DateTimePickerFormat.Short, Width = 110, Left = 172, Top = 11, Value = DateTime.Today };
_cboReport = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 190, Left = 292, Top = 11 };
_cboReport.Items.AddRange(new object[] { "Мои заказы", "По поставщикам", "По товарам", "По аптекам" });
_cboReport.SelectedIndex = 0;
_btnBuild = new Button { Text = "Сформировать", Left = 492, Top = 10, Width = 120, Height = 26 };
_lblTotals = new Label { AutoSize = true, Left = 624, Top = 15, Text = "" };
top.Controls.AddRange(new Control[] { lblFrom, _dtFrom, lblTo, _dtTo, _cboReport, _btnBuild, _lblTotals });
_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
};
Controls.Add(_grid);
Controls.Add(top);
_btnBuild.Click += async (_, __) => await LoadAsync();
_cboReport.SelectedIndexChanged += (_, __) => Render();
}
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<BuyerReportLineDto>();
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)
{
if (c.Name == "Сумма") c.DefaultCellStyle.Format = "N2";
if (c.Name == "Кол-во") c.DefaultCellStyle.Format = "N3";
if (c.Name == "Позиций" || c.Name == "Заказов") c.Width = 80;
}
int orders = _lines.Select(l => l.OrderID).Distinct().Count();
decimal totalSum = _lines.Sum(l => l.Sum);
_lblTotals.Text = $"Заказов: {orders} Позиций: {_lines.Count} Сумма: {totalSum:N2}";
}
}
}