Add API documentation and remove unused single-price endpoint.

Document all client HTTP calls in docs/API.md, link from README, and delete dead GetPriceItem code.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Magomed 2026-07-14 15:22:13 +03:00
parent 46564ee942
commit 800b2971b8
4 changed files with 426 additions and 177 deletions

View File

@ -1,67 +1,136 @@
# ЭльФиСА — Электронная Фармация # ЭльФиСА — Электронная Фармация
WinForms-клиент «Электронная Фармация» с современным UI на базе библиотеки **Elfisa.UI** (тема **PharmaTrust**). WinForms-клиент «Электронная Фармация» с современным UI на базе библиотеки **Elfisa.UI** (тема **PharmaTrust**).
## Структура ## Структура
``` ```
src/ src/
Elfisa.UI/ — UI-библиотека (ThemeManager, Modern* контролы) Elfisa.UI/ — UI-библиотека (ThemeManager, Modern* контролы)
ElectronicPharmacy/ — основное приложение (SQLite, API, бизнес-логика) ElectronicPharmacy/ — основное приложение (SQLite, API, бизнес-логика)
``` ```
## Сборка ## Сборка
Требуется .NET SDK 9+ (или MSBuild 16+) и .NET Framework 4.7.2. Требуется .NET SDK 9+ (или MSBuild 16+) и .NET Framework 4.7.2.
```powershell ```powershell
dotnet build Elfisa.sln -c Release dotnet build Elfisa.sln -c Release
``` ```
Запуск: Запуск:
```powershell ```powershell
.\src\ElectronicPharmacy\bin\Release\Электронная` Фармация.exe .\src\ElectronicPharmacy\bin\Release\Электронная` Фармация.exe
``` ```
База `efClient.db` копируется в output (`Data\efClient.db` или рядом с exe). Путь задаётся через `AppConfig.SqliteDbPath`. База `efClient.db` копируется в output (`Data\efClient.db` или рядом с exe). Путь задаётся через `AppConfig.SqliteDbPath`.
## Настройки ## Настройки
В диалоге **Авторизация** (и в `Settings`): В диалоге **Авторизация** (и в `Settings`):
- **URL API** — базовый адрес сервера (по умолчанию из `AppConfig`) - **URL API** — базовый адрес сервера (по умолчанию из `AppConfig`)
- **Location ID** — UUID точки для отправки заказов - **Location ID** — UUID точки для отправки заказов
- **Region ID** — опциональный фильтр при загрузке прайса - **Region ID** — опциональный фильтр при загрузке прайса
Переменная окружения `ELF_API_BASE_URL` переопределяет URL API. Переменная окружения `ELF_API_BASE_URL` переопределяет URL API.
Подробное описание всех HTTP-запросов — в [docs/API.md](docs/API.md).
## Архив для заказчика ## Архив для заказчика
```powershell ```powershell
.\dist\build-release.ps1 .\dist\build-release.ps1
``` ```
Создаёт `dist\Электронная-Фармация.zip` с Release-сборкой. Создаёт `dist\Электронная-Фармация.zip` с Release-сборкой.
## Основной цикл (V2) ## Основной цикл (V2)
1. Старт → проверка БД → выбор грузополучателя → прайс-лист 1. Старт → проверка БД → выбор грузополучателя → прайс-лист
2. **Загрузить** — прайс с API в SQLite, обновление открытых вкладок прайса 2. **Загрузить** — прайс с API в SQLite, обновление открытых вкладок прайса
3. Фильтр поставщика, поиск, набор в корзину с проверкой остатка 3. Фильтр поставщика, поиск, набор в корзину с проверкой остатка
4. **Сохранить заказ** — мин. сумма по поставщику, уникальный номер, грузополучатель в `Orders` 4. **Сохранить заказ** — мин. сумма по поставщику, уникальный номер, грузополучатель в `Orders`
5. **Отправить** — POST `/api/buyer/orders`, статус `ОТПРАВЛЕН` только у отправленного заказа 5. **Отправить** — POST `/api/buyer/orders`, статус `ОТПРАВЛЕН` только у отправленного заказа
6. **Заказы** — фильтры по статусу, поставщику, грузополучателю; отправка одного заказа 6. **Заказы** — фильтры по статусу, поставщику, грузополучателю; отправка одного заказа
7. **Отказы / Накладные** — синхронизация с `/api/buyer/invoices` 7. **Отказы / Накладные** — синхронизация с `/api/buyer/invoices`
## Приёмочный чек-лист ## Приёмочный чек-лист
1. Старт → БД → выбор точки → прайс 1. Старт → БД → выбор точки → прайс
2. Загрузить → прайс обновился, корзина очищена с предупреждением 2. Загрузить → прайс обновился, корзина очищена с предупреждением
3. Фильтр поставщика + поиск + количество + проверка остатка 3. Фильтр поставщика + поиск + количество + проверка остатка
4. Сохранить заказ (мин. сумма, корректный номер, грузополучатель) 4. Сохранить заказ (мин. сумма, корректный номер, грузополучатель)
5. Отправить → только выбранные заказы → статус / ошибка API 5. Отправить → только выбранные заказы → статус / ошибка API
6. Журнал: фильтры, комментарий, удаление `НОВЫЙ` 6. Журнал: фильтры, комментарий, удаление `НОВЫЙ`
7. Отказы: загрузка с API + детали позиций 7. Отказы: загрузка с API + детали позиций
8. Авторизация + `location_id` в настройках 8. Авторизация + `location_id` в настройках
9. Единый UI: `UiDialogs` / toast, без debug-панелей загрузки 9. Единый UI: `UiDialogs` / toast, без debug-панелей загрузки

290
docs/API.md Normal file
View File

@ -0,0 +1,290 @@
# API — Электронная Фармация
Документация по HTTP-запросам, которые использует десктоп-клиент.
Реализация: `src/ElectronicPharmacy/Classes/ApiClient.cs`, `DataSender.cs`, `InvoiceSyncService.cs`.
---
## Базовый URL
```
{ApiBaseUrl}
```
| Источник | Приоритет |
| -------------------------------------------------------------------- | --------------------------- |
| Настройки пользователя (`Settings.ApiBaseUrl`, окно **Авторизация**) | 1 |
| Переменная окружения `ELF_API_BASE_URL` | 2 |
| Значение по умолчанию | `http://195.34.241.84:9988` |
**Таймаут:** 30 секунд (`ApiClient`). Для отправки заказов (`DataSender`) таймаут не задан явно.
**Формат:** JSON, кодировка UTF-8.
---
## Авторизация
Все запросы, кроме логина, требуют заголовок:
```
Authorization: Bearer {JWT}
```
Токен сохраняется в `Settings.stringToken` после успешного входа.
---
## Эндпоинты
### 1. Вход
| | |
| --------------- | ------------- |
| **Метод** | `POST` |
| **Путь** | `/auth/login` |
| **Авторизация** | не требуется |
**Где в приложении:** окно **Авторизация** (`HF_Registration`), загрузка прайса (`HF_DownloadDataFromServer` — если токен отсутствует).
**Запрос:**
```json
{
"username": "логин",
"password": "пароль"
}
```
**Ответ `200`:**
```json
{
"token": "jwt..."
}
```
**Ошибка `401`:**
```json
{
"error": "описание ошибки"
}
```
---
### 2. Сводный прайс
| | |
| --------- | ---------------------------------------- |
| **Метод** | `GET` |
| **Путь** | `/api/supplier-prices/summary` |
| **Query** | `supplier_id` (опц.), `region_id` (опц.) |
**Где в приложении:** меню **Обмен → Загрузить**, кнопка обновления прайса в главном окне (`HF_DownloadDataFromServer`).
**Примеры:**
```http
GET /api/supplier-prices/summary
GET /api/supplier-prices/summary?region_id={RegionId}
GET /api/supplier-prices/summary?supplier_id={uuid}
```
**Ответ `200`:**
```json
{
"summary": [
{
"supplier_price_id": "uuid",
"guid_es": "uuid",
"supplier_id": "uuid",
"supplier_name": "Катрен",
"drug_name": "Название препарата",
"inn": "МНН",
"cure_form": "таб.",
"barcode": "...",
"price": 123.45,
"quantity": 10,
"region_id": "...",
"region_name": "...",
"last_price_date": "2026-07-14T00:00:00Z",
"match_method": "BARCODE",
"match_confidence": 0.95,
"trade_name": "...",
"dosage": "...",
"registry_price": 100.00,
"instruction_guid": "...",
"description": "...",
"storing_condition": "...",
"expiry_period": "12.2027",
"producer_name": "...",
"registry_date": "...",
"es_code": 123456,
"registry_status": "..."
}
]
}
```
**После ответа:** данные записываются в локальную SQLite-таблицу `PriceList` (полная перезапись).
**Класс:** `ApiClient.GetPriceSummaryAsync`
---
### 3. Отправка заказа
| | |
| ---------------- | ------------------- |
| **Метод** | `POST` |
| **Путь** | `/api/buyer/orders` |
| **Content-Type** | `application/json` |
**Где в приложении:**
- меню **Обмен → Отправить** — все заказы со статусом `НОВЫЙ` (`DataSender.Main`);
- страница **Заказы**, кнопка **ОТПРАВИТЬ** — один выбранный заказ (`DataSender.SendSingleOrderAsync`).
**Запрос** (один заказ = один POST):
```json
{
"location_id": "uuid точки из Settings.LocationId",
"comment": "комментарий или null",
"items": [
{
"supplier_price_id": "uuid позиции из прайса",
"qty": 5
}
]
}
```
**Условия отправки:**
- в локальной БД заказ имеет статус `НОВЫЙ`;
- у каждой позиции заполнен `supplier_price_id`;
- `qty` > 0;
- задан `location_id` в настройках.
**При успехе:** локально `Orders.OrderState``ОТПРАВЛЕН` (только для отправленного заказа).
**При ошибке:** toast с HTTP-кодом и телом ответа сервера.
**Класс:** `DataSender`
---
### 4. Накладные и отказы
| | |
| --------- | --------------------- |
| **Метод** | `GET` |
| **Путь** | `/api/buyer/invoices` |
**Где в приложении:** кнопка **Обновить с сервера** на вкладках **Накладные** и **Отказы** (`InvoiceSyncService`).
**Ответ `200`:** массив объектов
```json
[
{
"InvoiceNumber": "12345",
"InvoiceDate": "2026-07-14",
"SupplierName": "Поставщик",
"ConsigneeName": "Грузополучатель",
"InvoiceSum": "10000.00",
"RefuseSum": "500.00"
}
]
```
**После ответа:** `INSERT OR REPLACE` в локальную таблицу `Invoice`.
**Класс:** `ApiClient.GetInvoicesAsync`
---
## Сводная таблица
| Действие в UI | HTTP | Эндпоинт | Класс / метод |
| --------------------------- | ------ | ------------------------------ | -------------------------------- |
| Авторизация | `POST` | `/auth/login` | `ApiClient.LoginAsync` |
| Загрузить прайс | `GET` | `/api/supplier-prices/summary` | `ApiClient.GetPriceSummaryAsync` |
| Отправить заказ(ы) | `POST` | `/api/buyer/orders` | `DataSender.SendOrderAsync` |
| Обновить накладные / отказы | `GET` | `/api/buyer/invoices` | `ApiClient.GetInvoicesAsync` |
---
## Обработка ошибок
| Ситуация | Поведение клиента |
| ------------------ | ---------------------------------------------------------------------------- |
| `401 Unauthorized` | Сообщение об ошибке авторизации; для прайса токен сбрасывается в `ApiClient` |
| Таймаут (30 с) | `HttpRequestException` с текстом о превышении времени ожидания |
| Прочие HTTP-ошибки | Тело `{ "error": "..." }` если сервер вернул JSON |
| Нет `location_id` | Отправка заказов блокируется до заполнения в настройках |
| Нет токена | Загрузка накладных и отправка заказов блокируются |
---
## Что не реализовано
- `PUT`, `PATCH`, `DELETE` к API
- Получение статуса заказа с сервера
- Синхронизация справочников (поставщики, грузополучатели) через API
- Автоматический re-login при истечении токена
---
## Примеры curl
**Логин:**
```bash
curl -X POST "{ApiBaseUrl}/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"pass"}'
```
**Прайс:**
```bash
curl "{ApiBaseUrl}/api/supplier-prices/summary" \
-H "Authorization: Bearer {token}"
```
**Отправка заказа:**
```bash
curl -X POST "{ApiBaseUrl}/api/buyer/orders" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"location_id":"...","comment":null,"items":[{"supplier_price_id":"...","qty":1}]}'
```
**Накладные:**
```bash
curl "{ApiBaseUrl}/api/buyer/invoices" \
-H "Authorization: Bearer {token}"
```

View File

@ -83,55 +83,6 @@ namespace Электронная_Фармация.Classes
/// </summary> /// </summary>
public bool IsAuthenticated => !string.IsNullOrEmpty(_token); public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
/// <summary>
/// Получает сводный прайс поставщиков
/// </summary>
/// <param name="supplierId">ID поставщика (опционально)</param>
/// <param name="regionId">ID региона (опционально)</param>
/// <returns>Сводный прайс</returns>
///
public async Task<PriceSummaryItem> GetPriceItem()
{
if (!IsAuthenticated)
{
throw new InvalidOperationException("Необходимо выполнить авторизацию перед запросом данных");
}
//var queryParams = new List<string>();
var url = "/api/supplier-prices/556EE09D-AD06-439E-91AF-D6CEBCD61AD3";
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _token);
try
{
var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var summary = await response.Content.ReadFromJsonAsync<PriceSummaryItem>();
return summary;
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_token = null; // Токен истек или невалиден
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
throw new UnauthorizedAccessException($"Сессия истекла: {error?.Error ?? "Необходимо войти заново"}");
}
else
{
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
throw new HttpRequestException($"Ошибка HTTP {response.StatusCode}: {error?.Error ?? "Неизвестная ошибка"}");
}
}
catch (TaskCanceledException)
{
throw new HttpRequestException("Превышено время ожидания ответа сервера (таймаут 30 секунд)");
}
}
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string supplierId = null, string regionId = null) public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string supplierId = null, string regionId = null)
{ {
if (!IsAuthenticated) if (!IsAuthenticated)
@ -139,7 +90,6 @@ namespace Электронная_Фармация.Classes
throw new InvalidOperationException("Необходимо выполнить авторизацию перед запросом данных"); throw new InvalidOperationException("Необходимо выполнить авторизацию перед запросом данных");
} }
// Формируем URL с параметрами
var queryParams = new List<string>(); var queryParams = new List<string>();
if (!string.IsNullOrEmpty(supplierId)) if (!string.IsNullOrEmpty(supplierId))
{ {
@ -149,20 +99,6 @@ namespace Электронная_Фармация.Classes
{ {
queryParams.Add($"region_id={Uri.EscapeDataString(regionId)}"); queryParams.Add($"region_id={Uri.EscapeDataString(regionId)}");
} }
//if (!string.IsNullOrEmpty(supplierPriceId))
//{
// queryParams.Add($"supplier_price_id={Uri.EscapeDataString(supplierPriceId)}");
//}
//var queryParams = new List<string>();
//if (!string.IsNullOrEmpty(supplierId))
//{
// queryParams.Add($"supplier_id={Uri.EscapeDataString(supplierId)}");
//}
//if (!string.IsNullOrEmpty(regionId))
//{
// queryParams.Add($"region_id={Uri.EscapeDataString(regionId)}");
//}
var url = "/api/supplier-prices/summary"; var url = "/api/supplier-prices/summary";
if (queryParams.Count > 0) if (queryParams.Count > 0)

View File

@ -60,12 +60,6 @@ namespace Электронная_Фармация.HelpForms
regionId: string.IsNullOrWhiteSpace(Settings.Default.RegionId) ? null : Settings.Default.RegionId); regionId: string.IsNullOrWhiteSpace(Settings.Default.RegionId) ? null : Settings.Default.RegionId);
rtxtDebug.Text += ($"✓ Получено {allSuppliersSummary.Summary.Count} позиций\n"); rtxtDebug.Text += ($"✓ Получено {allSuppliersSummary.Summary.Count} позиций\n");
//загружаю один товар:
//rtxtDebug.Text += ("\nЗагружаю сводный прайс для всех поставщиков...");
// var PriceItem = await client.GetPriceItem();
//rtxtDebug.Text += ($"✓ Получено {allSuppliersSummary.DrugName.Count} позиций\n");
DataTable tablePrice = new DataTable(); DataTable tablePrice = new DataTable();
tablePrice.Columns.Add("supplier_price_id"); tablePrice.Columns.Add("supplier_price_id");
@ -134,45 +128,6 @@ namespace Электронная_Фармация.HelpForms
//tablePrice.Columns.Add("RegistryStatus"); //tablePrice.Columns.Add("RegistryStatus");
#endregion #endregion
// Выводим первые 5 позиций
//Console.WriteLine("Первые 500 позиций:");
#region загрузка одного элемента
//if (PriceItem.DrugName != null | PriceItem.DrugName != "" | PriceItem.DrugName != string.Empty)
//{
// Console.WriteLine(PriceItem.ToString());
// DataRow row = tablePrice.NewRow();
// row["supplier_price_id"] = PriceItem.SupplierPriceID.ToString();
// row["guid_es"] = PriceItem.GuidEs.ToString();
// row["es_code"] = PriceItem.EsCode.ToString();
// row["supplier_id"] = PriceItem.SupplierId.ToString();
// row["DrugName"] = PriceItem.DrugName.ToString();
// row["SupplierName"] = "Катрен";//PriceItem.SupplierName.ToString();
// row["Price"] = PriceItem.Price.ToString();
// row["Quantity"] = PriceItem.Quantity.ToString();
// if (PriceItem.ExpiryPeriod == null)
// { row["ExpiryPeriod"] = DBNull.Value; }
// else { row["ExpiryPeriod"] = PriceItem.ExpiryPeriod.ToString(); }
// ;
// if (PriceItem.Description == null)
// { row["Description"] = DBNull.Value; }
// else { row["Description"] = PriceItem.Description.ToString(); }
// ;
// row["Zakaz"] = string.Empty;
// row["SummaZakaza"] = string.Empty;
// tablePrice.Rows.Add(row);
//}
#endregion
#region загрузка всего прайса РАБОЧАЯ
if (allSuppliersSummary.Summary != null) if (allSuppliersSummary.Summary != null)
{ {
@ -256,7 +211,6 @@ namespace Электронная_Фармация.HelpForms
#endregion #endregion
} }
} }
#endregion
string commandToDelete = "delete from [PriceList]"; string commandToDelete = "delete from [PriceList]";
string commandTODeleteTempOrder = "delete from [TempOrderItems]"; string commandTODeleteTempOrder = "delete from [TempOrderItems]";