Compare commits

..

48 Commits

Author SHA1 Message Date
98a024ba10 Merge pull request '������� �� Avalonia 2.0 (�����-���������, ����������, �������� ��������)' (#1) from feature/avalonia-ui into master 2026-08-11 09:41:06 +03:00
Exest
f98978a5d2 2.0.1: «Мои прайсы» в Прайс-листе + подключённые прайсы в «Загрузить»
- Прайс-лист: дропдаун «Мои прайсы» (подключённые прайсы + «Все прайсы»),
  выбор фильтрует каталог по price_list_id; один прайс — выбирается сам
- Загрузить: показывает список подключённых прайсов и качает их
- Core: GetBuyerPriceListsAsync + priceListId в GetPriceSummaryAsync

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 17:42:53 +03:00
Exest
028c588d2a Релиз 2.0: кросс-платформа, брендовый установщик, страница загрузки
- Версия 2.0.0, self-contained публикация под win/osx/linux
- Установщик Inno перенастроен на Avalonia + брендовые картинки мастера
  (бирюзовый градиент, аптечный крест, ЭльФиСА)
- exe переименовывается в «Электронная Фармация.exe» (совместимо с авто-обновлением)
- Иконки приложения 256/512/1024
- Красивая страница загрузки web/download с авто-определением ОС (Win/Mac/Linux)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 12:41:14 +03:00
Exest
3fc825e54b Avalonia: Прайс-лист как в WinForms — дрилдаун Название→МГ→Предложения + ввод количества с клавиатуры
- База/дозировка вычисляются на клиенте (порт BuildBaseName/ExtractDosage)
- Три панели: базовые наименования → дозировки (МГ) → предложения поставщиков
- Колонка «Заказ»: ввод числа с клавиатуры (многозначно), Enter — добавить
- Фикс: null-безопасная привязка KeyDown (падало в конструкторе → пустой экран)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 12:02:41 +03:00
Exest
0536d480cb Avalonia: вкладки-документы + нижняя строка состояния + монохромные иконки
- Вкладки-документы: разделы открываются вкладками (несколько сразу),
  закрываются, активная синхронизирована с верхней навигацией
  (ShellViewModel.Tabs/ActiveTab, CloseTabCommand)
- Нижняя строка состояния: Пользователь | Аптека + кнопка «Настройки»
- Монохромные иконки Segoe MDL2 (те же глифы, что в WinForms):
  Прайс E14C, Заказы E7BF, Накладные E8A5, Отчёты EB05, Загрузить E896,
  Отправить E725, Отказы E7BA, Справочник E8F1
- контент показывает активную вкладку

Оболочка приведена к виду исходной программы. Проверено вживную.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 13:00:03 +03:00
Exest
cd2fb57bca Avalonia: черновики получают реальный номер EX-####### (как в WinForms)
- при «Сохранить заказ» резервируется настоящий номер через
  GET /api/buyer/global-sign/next (fallback ЧРН-… если офлайн)
- при отправке черновика этот EX-номер передаётся в заказ (global_sign),
  заказ сохраняет тот же номер
- ApiClient.NextGlobalSignAsync, BuyerOrderCreateRequest.GlobalSign
- DraftStore: номер задаёт вызывающий

Проверено: черновик = EX-0000018, статус «Не отправлен».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:54:43 +03:00
Exest
2420a71ace Avalonia: сборка заказа в Прайс-листе (панель «Мой заказ») + черновики в «Заказы»
- Прайс-лист: 2 колонки — каталог + «Мой заказ» (состав/кол-во/итог/
  грузополучатель/комментарий) + «Сохранить заказ»
- Сохранить -> локальный черновик (DraftStore, %APPDATA%)
- Заказы: черновики вверху «Не отправлен» + «Отправить заказ»/«Удалить»
- как в WinForms: собрал -> сохранил (не отправлен) -> отправил отдельно

Проверено вживую: сборка, сохранение, черновик в Заказах.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:43:47 +03:00
Exest
c677345ba6 Avalonia: общая корзина, добавление из Прайс-листа, Настройки
- CartService: единая корзина для Прайс-листа и Отправить
- Прайс-лист: выбор позиции + кол-во + «Добавить в заказ», индикатор корзины
- Настройки (диалог ⚙ в шапке): адрес API, тема (свет/тьма, применяется
  сразу), грузополучатель по умолчанию; сохранение в %APPDATA%
- SettingsStore (Core) + AppConfig читает сохранённый API-адрес
- тема применяется на старте

Проверено вживую: добавление из Прайс-листа в корзину, диалог настроек.
Реальная отправка заказа (POST) — собрана и связана, живой заказ по явному «да»
(автозащита блокирует боевую транзакцию без подтверждения).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:26:53 +03:00
Exest
43468ac693 Avalonia: раздел «Загрузить» (скачивание прайса) + уплотнение верхней панели
- Добавлена вкладка «Загрузить» (между Отчёты и Отправить, как в исходной
  программе): постраничная загрузка прайса с прогрессом, кэш в памяти
  (PriceCache), итог «N позиций от K поставщиков» + timestamp
- Верхняя навигация уплотнена под 8 вкладок (Справочник больше не обрезается)
- Core: PriceCache

Проверено вживую: все 8 вкладок влезают, загрузка прайса работает (500 поз.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:05:52 +03:00
Exest
b13a867f8d Avalonia: раздел «Отправить» (корзина + создание заказа)
- Отправить: поиск по прайсу → корзина (кол-во через NumericUpDown,
  сумма/итого) → выбор грузополучателя + комментарий → создание заказа
  POST /api/buyer/orders
- Core: CreateOrderAsync + DTO (BuyerOrderCreateRequest/ItemReq/Response)
- Отказы: заглушка с точным пояснением (нужен серверный эндпоинт;
  RefuseItemsCount живёт только в локальной SQLite WinForms)

Итог: 6/7 разделов функциональны (Прайс-лист, Заказы, Накладные, Отчёты,
Справочник, Отправить), Отказы — заглушка. Собирается.
⚠️ Реальная отправка заказа (POST) не тестировалась — создаёт заказ на
проде; проверять вживую отдельно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:30:57 +03:00
Exest
77dfb08ef4 Avalonia: Прайс-лист, Накладные, Справочник (5/7 разделов рабочие)
- Прайс-лист: поиск + грид на рабочем /api/supplier-prices/summary
  (наименование, форма, производитель, поставщик, цена)
- Накладные: реестр приходных документов (из buyer/report)
- Справочник: профиль покупателя + грузополучатели (buyer/locations)
- Core: GetPriceSummaryAsync, GetBuyerLocationsAsync + DTO
- README: статус разделов + примечание про buyer/orders 500 (фикс застейджен)

Рабочие: Прайс-лист, Заказы, Накладные, Отчёты, Справочник.
Осталось: Отправить (корзина+DBF), Отказы (нужен эндпоинт), автообновление.
Собирается; рантайм новых 3 разделов не проверен (клиент за игрой — окна не поднимал).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:20:36 +03:00
Exest
132ce1531f Avalonia: верхняя навигация (как в WinForms) + раздел «Заказы»
- Навигация перенесена из левого сайдбара в ВЕРХНЮЮ горизонтальную
  панель, как в исходной программе: логотип слева, вкладки
  (иконка+подпись) по центру, пользователь+Выйти справа
- Раздел «Заказы»: мастер-детали (список слева, состав заказа справа
  с позициями и итогом), на данных /api/buyer/report
- Числовые колонки выровнены вправо (стиль DataGridCell.num)
- Отчёты: тулбар на WrapPanel, стабильные ширины колонок

Проверено запуском (скриншоты): верхняя навигация, отчёты, заказы.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:00:57 +03:00
Exest
f6a1012e9b Avalonia кросс-платформенный клиент: фундамент + раздел «Отчёты»
Порт десктопа с WinForms на Avalonia (.NET 8, Win/macOS/Linux).

- Elfisa.Core: переносимая логика (ApiClient: логин/отчёт/ИИ, DTO, Session)
- Дизайн-система в бирюзе #0F766E (свет/тьма, кнопки/поля/карточки/таблица/сайдбар)
- Экран входа (реальный /auth/login), шелл с сайдбар-навигацией
- «Отчёты»: 4 типа + авто-загрузка + ИИ-запрос -> Word/Excel, динамические колонки
- Заглушки остальных разделов + README с дорожной картой портирования

Проверено запуском: логин, живые данные покупателя, тема, таблица (скриншот).
dev-автологин по ELF_DEV_USER/ELF_DEV_PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:21:10 +03:00
Exest
a4880d7c50 v1.0.29: раздел «Отчёты» в стиле приложения
- бирюзовые кнопки (#0F766E) как в шапке, «Сформировать» больше не обрезано
- убрана криво рендерившаяся эмодзи с кнопки ИИ
- поле «Запрос ИИ» нормальной ширины (не на весь экран), фиксированные позиции
- таблица: бирюзовая шапка с белым текстом, чередование строк,
  бирюзовое выделение, числа выровнены вправо, сбалансированные ширины колонок

Проверено рендером с тестовыми данными.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 16:57:22 +03:00
Exest
4b43907353 v1.0.28: фикс раскладки раздела «Отчёты»
Во второй строке (ИИ) убраны конфликтующие якоря — поле «Запрос ИИ»
тянется по ширине, формат и кнопка пиннятся вправо с пересчётом при
ресайзе, контролы больше не наезжают друг на друга.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 16:45:52 +03:00
Exest
4d398aca84 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>
2026-08-07 16:24:19 +03:00
Exest
f7fb7ed1b0 Release v1.0.26: раздел «Отчёты» для покупателя
Новый раздел «Отчёты» в десктопе (buyer-scoped, данные с сервера):
- Мои заказы за период, По поставщикам, По товарам, По аптекам
- фильтр по датам, агрегация клиентски из одного набора строк
- сервер: GET /api/buyer/report скоупит строки по JWT покупателя
  (клиент видит ТОЛЬКО свои заказы)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 14:06:41 +03:00
Exest
557f8667ad Release v1.0.25: fix auto-updater elevation
Апдейтер: повышение прав через cmd.exe /c вместо запуска .cmd напрямую
(ShellExecute runas на .cmd срабатывал не на всех системах — elevated
процесс молча не стартовал, обновление в Program Files не применялось,
клиент зацикливался на уведомлении). Payload переносится в ASCII-путь,
чтобы имя папки из архива не ломало аргумент SRC для robocopy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 10:38:08 +03:00
Exest
b10eba0984 Release v1.0.24
Версия десктопа поднята до 1.0.24.0. Сопровождает серверные фиксы,
которые уже на проде: отправка заказа (глобальный номер заявки),
скоуп прайса покупателя по назначенному прайс-листу.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 18:03:00 +03:00
Exest
63abfd4e31 Release v1.0.23: global order numbers EX-####### (GLOBAL_SIGN).
Desktop reserves/sends EX numbers from server; supplier DBF gets GLOBAL_SIGN column.
2026-08-05 18:59:43 +03:00
Exest
d2a1a86794 Release v1.0.22: fix auto-update loop on Program Files installs.
Auto-updater now requests UAC elevation, force-unlocks the exe, and verifies the copy before restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:09:57 +03:00
2659de14b9 Release v1.0.21: consignees from web, remove test pharmacies. 2026-08-05 15:22:44 +03:00
Exest
166a431bda Release v1.0.20: fix Orders date filter hiding new carts.
Compare OrderDate as real dates (dd.MM.yyyy storage broke across month boundaries) and bump AssemblyVersion so desktop picks up the Gitea release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:01:44 +03:00
Exest
cdb2d353a5 Release v1.0.19 desktop build.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:21:07 +03:00
Exest
0253bdf022 Bump to v1.0.18 with valid release timestamp.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:21:07 +03:00
Exest
621f6a6cf3 Bump to v1.0.17 retest after hang fix.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:18:59 +03:00
Exest
03c71b8e78 Fix update hang: allow dialog close after download; harden updater wait.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:18:11 +03:00
Exest
2230d4c73f Bump to v1.0.15 auto-update retest target.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:49:23 +03:00
Exest
28f0886b32 Fix auto-update exit crash after download (Application.Exit from async).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:48:56 +03:00
Exest
d80953baad Bump to v1.0.13 to exercise in-app auto-updater.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:45:39 +03:00
Exest
3039c324be Add in-app auto-updater that preserves local database.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:44:44 +03:00
Exest
7497ee2d4f Bump desktop to v1.0.11 for forced-update smoke test.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:39:46 +03:00
Exest
b69e1b4498 Add forced update check against Gitea releases.
Block startup when a newer Release exists; keep offline pharmacies working.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:58:36 +03:00
Magomed
09535e9b02 Optimize price-list name search for large catalogs.
Replace O(n^2) per-keystroke rebuild with indexed unique names, a single-pass filter, and debounce so typing stays responsive with ~15k items.
2026-07-30 18:36:35 +03:00
Exest
9dfdb913cb Use server final price on download without local markup math.
Server now sums price-list, region and buyer markups; desktop must not re-apply discounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 15:40:36 +03:00
Magomed
d68155b8ef Persist per-pharmacy markup percent and simplify consignee picker UI.
Store client markup on each Consignee so it survives restarts and price refresh; show only name and address in the selection dialog with a wider window.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:53:28 +03:00
Magomed
3d253a2f54 Fix SQLite BadImageFormat by disabling Prefer32Bit and syncing interop arch.
Ensure SQLite.Interop.dll next to the exe always matches process bitness from x86/x64.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:27:41 +03:00
Magomed
fd5bd18c66 Add per-pharmacy carts and client-side summed discount pricing.
Apply available buyer/price-list/region discounts to summary.price on download, keep consignee carts separate, and harden SQLite startup/migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:07:47 +03:00
Magomed
458b70e036 Add multi-pharmacy LocationId, batched price download, and SQL debug tools.
Persist last successful login only, show active pharmacy in the status bar, and keep settings compact at the bottom-right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 10:09:04 +03:00
Magomed
babc067863 Add dedicated settings dialog in main window and keep auth form login-only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:26:16 +03:00
Exest
e35e67849f v1.0.4: адаптация desktop к текущему buyer API
Перевести desktop-клиент на работу с текущими buyer orders и локальным SQLite-кешем, чтобы отправка заказов и синхронизация документов работали с уже развернутым сервером без старых MSSQL-зависимостей.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 11:41:00 +03:00
Exest
4b3b2c8ca8 v1.0.3: передача item_name и item_code при отправке заказа на сервер 2026-07-21 13:16:11 +03:00
Magomed
7f3bc2eb35 Focus offers grid on MG select so qty can be typed immediately. 2026-07-20 20:02:58 +03:00
Magomed
5c439fe780 Apply client markup_percent from price API and fix truncated download logs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 11:36:20 +03:00
Motylek13
999cfe4121
Прогресс загрузки прайса 0–100% и экспорт накладных (#1)
## Summary
- Реальный прогресс загрузки прайса 0–100%: по байтам при скачивании,
затем по позициям при обработке и сохранении в БД
- Настройка папки экспорта накладных и CSV-экспорт из контекстного меню

## Test plan
- [ ] Запустить обновление прайса и убедиться, что полоска идёт от 0 до
100 с текстом «сколько / сколько»
- [ ] В авторизации задать папку экспорта накладных
- [ ] Экспортировать накладную в CSV через контекстное меню
2026-07-18 15:47:31 +03:00
Magomed
1f6f442b21 Show real 0-100% download progress from transferred bytes and saved rows.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 15:46:14 +03:00
Magomed
9ee5561f3d Add invoice export folder setting and CSV export from context menu.
Users can choose the export directory in auth settings; right-click on an invoice saves a UTF-8 CSV into that folder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 13:34:48 +03:00
Magomed
5e24cc95b0 Add animated progress bar during price list download.
Show marquee while waiting for the API and determinate progress while saving rows so the UI no longer looks frozen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 13:27:42 +03:00
141 changed files with 11760 additions and 843 deletions

10
.gitignore vendored
View File

@ -20,3 +20,13 @@ Desktop.ini
efClient.db efClient.db
*.db-journal *.db-journal
**/user.config **/user.config
packages/
installer/output/
dist/
# Кросс-платформенные публикации и артефакты сборки
publish/
build/
installer/output/

6
Elfisa.Cross.slnx Normal file
View File

@ -0,0 +1,6 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Elfisa.Avalonia/Elfisa.Avalonia.csproj" />
<Project Path="src/Elfisa.Core/Elfisa.Core.csproj" />
</Folder>
</Solution>

View File

@ -29,6 +29,9 @@
<setting name="IntDefaultPercentMarkup" serializeAs="String"> <setting name="IntDefaultPercentMarkup" serializeAs="String">
<value>0</value> <value>0</value>
</setting> </setting>
<setting name="ClientMarkupPercent" serializeAs="String">
<value>0</value>
</setting>
<setting name="stringLogin" serializeAs="String"> <setting name="stringLogin" serializeAs="String">
<value /> <value />
</setting> </setting>
@ -38,6 +41,21 @@
<setting name="stringToken" serializeAs="String"> <setting name="stringToken" serializeAs="String">
<value /> <value />
</setting> </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> </Электронная_Фармация.Properties.Settings>
</userSettings> </userSettings>
<runtime> <runtime>

View File

@ -103,6 +103,7 @@ GET /api/supplier-prices/summary?supplier_id={uuid}
```json ```json
{ {
"markup_percent": 15.5,
"summary": [ "summary": [
{ {
"supplier_price_id": "uuid", "supplier_price_id": "uuid",
@ -114,6 +115,7 @@ GET /api/supplier-prices/summary?supplier_id={uuid}
"cure_form": "таб.", "cure_form": "таб.",
"barcode": "...", "barcode": "...",
"price": 123.45, "price": 123.45,
"markup_percent": 15.5,
"quantity": 10, "quantity": 10,
"region_id": "...", "region_id": "...",
"region_name": "...", "region_name": "...",

24
docs/gitea-updates.md Normal file
View File

@ -0,0 +1,24 @@
# Gitea + принудительные обновления десктопа
Полная инструкция по установке Gitea: в репозитории `source``docs/gitea/`.
Клиент при старте (`ElectroPharmacy_Load`) вызывает Gitea API:
`GET {UpdateCheckBaseUrl}/api/v1/repos/{UpdateRepo}/releases/latest`
Настройки (можно сменить без пересборки через user settings):
| Ключ | По умолчанию |
|------|----------------|
| `UpdateCheckBaseUrl` | `https://git.24pharmdata.ru` |
| `UpdateRepo` | `pharmdata/elfisa-pharmacy` |
| `UpdateApiToken` | пусто (нужен для private releases) |
## Регламент релиза
1. Поднять `AssemblyVersion` / `AssemblyFileVersion` в `Properties/AssemblyInfo.cs`.
2. `git tag vX.Y.Z && git push gitea master --tags`
3. `powershell -File dist\build-release.ps1`
4. Gitea → Releases → New Release → прикрепить ZIP.
Старый клиент с меньшей версией увидит модалку «Доступно новое обновление» и не пустит в работу.

View File

@ -0,0 +1,108 @@
; ЭльФиСА — установщик «Электронная Фармация»
; Кодировка: UTF-8 with BOM
#define MyAppName "Электронная Фармация"
#define MyAppBrand "ЭльФиСА"
#define MyAppVersion "2.0.1"
#define MyAppPublisher "PharmData"
#define MyAppURL "https://cdn.24pharmdata.ru"
#define MyAppSupportURL "https://24pharmdata.ru"
#define MyAppExeName "Электронная Фармация.exe"
#define MyAppId "{{A7C4E2B1-9F3D-4E6A-8B2C-1D5F7A9E0C42}"
[Setup]
AppId={#MyAppId}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppBrand} — {#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppSupportURL}
AppSupportURL={#MyAppSupportURL}
AppUpdatesURL={#MyAppURL}
; Программа — в LocalAppData (без прав админа). База/логи — тоже в профиле пользователя.
DefaultDirName={localappdata}\{#MyAppBrand}\{#MyAppName}
DefaultGroupName={#MyAppBrand}
DisableProgramGroupPage=no
LicenseFile=license.txt
InfoBeforeFile=welcome.txt
OutputDir=output
OutputBaseFilename=ElfisaPharmacy-Setup-{#MyAppVersion}
SetupIconFile=assets\elfisa.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
UninstallDisplayName={#MyAppBrand} — {#MyAppName}
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
WizardSizePercent=120
WizardImageFile=assets\wizard-side.bmp
WizardSmallImageFile=assets\wizard-small.bmp
WizardImageStretch=yes
AllowNoIcons=yes
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
ArchitecturesInstallIn64BitMode=x64compatible
VersionInfoVersion={#MyAppVersion}.0
VersionInfoCompany={#MyAppPublisher}
VersionInfoDescription=Установщик {#MyAppBrand} — {#MyAppName}
VersionInfoProductName={#MyAppName}
VersionInfoProductVersion={#MyAppVersion}
VersionInfoCopyright=Copyright (C) {#MyAppPublisher}
ShowLanguageDialog=no
DisableWelcomePage=no
DisableDirPage=no
DisableFinishedPage=no
CloseApplications=yes
RestartApplications=no
SetupLogging=yes
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
[Messages]
russian.BeveledLabel={#MyAppBrand} · PharmData
russian.WelcomeLabel1=Добро пожаловать в установку [name]
russian.WelcomeLabel2=Мастер установит [name/ver] на ваш компьютер.%n%nРекомендуется закрыть все другие приложения перед продолжением.%n%nНажмите «Далее», чтобы продолжить.
russian.FinishedLabel=Установка [name] успешно завершена.%n%nПриложение готово к работе. При первом запуске выполните вход в систему.
russian.ClickFinish=Нажмите «Готово», чтобы выйти из мастера установки.
russian.SelectTasksDesc=Выберите дополнительные задачи, затем нажмите «Далее».
russian.SelectTasksLabel2=Какие дополнительные задачи нужно выполнить?
russian.WizardSelectTasks=Дополнительные задачи
russian.WizardReady=Всё готово к установке
russian.ReadyLabel1=Мастер готов установить [name] на ваш компьютер.
russian.ReadyLabel2a=Нажмите «Установить», чтобы продолжить, или «Назад», если хотите просмотреть либо изменить параметры.
russian.InstallingLabel=Подождите, пока [name] устанавливается на ваш компьютер…
russian.StatusExtractFiles=Распаковка файлов…
russian.StatusCreateDirs=Создание папок…
russian.StatusRegisterFiles=Регистрация компонентов…
russian.ConfirmUninstall=Вы действительно хотите удалить %1 и все его компоненты?
russian.UninstalledAll=%1 успешно удалена.
russian.ExitSetupMessage=Установка не завершена. Если вы выйдете сейчас, программа не будет установлена.%n%nВы можете запустить установщик позже.%n%nВыйти из установки?
[CustomMessages]
russian.CreateDesktopIcon=Создать ярлык на рабочем столе
russian.LaunchAfterInstall=Запустить Электронную Фармацию
russian.AppWorkingDir=Рабочая папка программы
russian.AdditionalIcons=Дополнительные ярлыки:
russian.BrandLine=ЭльФиСА — удобный заказ лекарств для аптек
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
; 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]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\elfisa.ico"; Comment: "{cm:BrandLine}"
Name: "{group}\Удалить {#MyAppName}"; Filename: "{uninstallexe}"; IconFilename: "{app}\elfisa.ico"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\elfisa.ico"; Comment: "{cm:BrandLine}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchAfterInstall}"; Flags: nowait postinstall skipifsilent
[UninstallDelete]
Type: filesandordirs; Name: "{app}\Logs"
Type: files; Name: "{app}\*.pdb"

24
installer/README.md Normal file
View File

@ -0,0 +1,24 @@
# Установщик ЭльФиСА
## Публичная ссылка (CDN)
https://cdn.24pharmdata.ru
При открытии корня CDN сразу скачивается установщик.
Стабильное имя файла:
https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe
## Локальная сборка
- `installer/output/ElfisaPharmacy-Setup-1.0.20.exe`
- `installer/output/ElfisaPharmacy-Setup.exe` (копия для CDN)
## Пересборка
```powershell
.\scripts\build-installer.ps1
```
После сборки залейте `ElfisaPharmacy-Setup.exe` на CDN (или сделайте его index/default document).

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
installer/assets/elfisa.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

18
installer/license.txt Normal file
View File

@ -0,0 +1,18 @@
ЛИЦЕНЗИОННОЕ СОГЛАШЕНИЕ
Электронная Фармация (ЭльФиСА)
1. Программа предоставляется для использования в рамках договора
с поставщиком услуг PharmData / 24pharmdata.
2. Запрещается передавать третьим лицам учётные данные доступа,
а также модифицировать клиент способом, нарушающим безопасность
обмена данными.
3. Пользователь несёт ответственность за корректность оформляемых
заказов и за сохранность локальных данных на рабочем месте.
4. Устанавливая программу, вы подтверждаете согласие с условиями
использования сервиса и политикой обработки данных, действующими
у оператора сервиса.
При возникновении вопросов обратитесь в службу поддержки.

20
installer/welcome.txt Normal file
View File

@ -0,0 +1,20 @@
ЭльФиСА — Электронная Фармация
================================
Добро пожаловать в установку настольного клиента «Электронная Фармация».
Программа предназначена для работы аптек с прайс-листами поставщиков,
заказами, накладными и обменом данными с сервером PharmData.
Скачать установщик всегда можно по адресу:
https://cdn.24pharmdata.ru
Установщик:
• скопирует программу в выбранную папку;
• предложит ярлык в меню «Пуск»;
• по желанию создаст ярлык на рабочем столе.
Рекомендуется закрыть запущенную «Электронную Фармацию» перед установкой.
После установки при первом запуске выполните вход в систему.
© PharmData / ЭльФиСА

View File

@ -0,0 +1,59 @@
#Requires -Version 5.1
param([switch]$SkipBuild)
$ErrorActionPreference = "Stop"
$root = Resolve-Path (Join-Path $PSScriptRoot "..")
Set-Location $root
Write-Host "Root: $root" -ForegroundColor Cyan
$iscc = @(
"$env:LocalAppData\Programs\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe"
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $iscc) {
throw "Inno Setup 6 (ISCC.exe) не найден. Установите: winget install JRSoftware.InnoSetup"
}
$pub = "publish\win-x64"
if (-not $SkipBuild) {
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 завершился с ошибкой" }
$out = Get-ChildItem "installer\output\ElfisaPharmacy-Setup-*.exe" |
Where-Object { $_.Name -notmatch '\.bak\.' -and $_.Name -ne 'ElfisaPharmacy-Setup.exe' } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $out) {
throw "Не найден собранный Setup exe в installer\output"
}
# Стабильное имя для CDN: https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe
$stable = Join-Path $out.DirectoryName "ElfisaPharmacy-Setup.exe"
Copy-Item $out.FullName $stable -Force
Write-Host ""
Write-Host "Готово: $($out.FullName)" -ForegroundColor Green
Write-Host "CDN-файл: $stable" -ForegroundColor Green
Write-Host ("Размер: {0:N1} МБ" -f ($out.Length / 1MB))
Write-Host ""
Write-Host "Залейте на CDN:" -ForegroundColor Cyan
Write-Host " https://cdn.24pharmdata.ru/ <- корень отдаёт этот Setup"
Write-Host " или файл: ElfisaPharmacy-Setup.exe"

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<configuration> <configuration>
<configSections> <configSections>
<!-- For more information on Entity Framework configuration, visit http://go.microsoft.com/fwlink/?LinkID=237468 --> <!-- For more information on Entity Framework configuration, visit http://go.microsoft.com/fwlink/?LinkID=237468 -->
@ -27,6 +27,9 @@
<setting name="IntDefaultPercentMarkup" serializeAs="String"> <setting name="IntDefaultPercentMarkup" serializeAs="String">
<value>0</value> <value>0</value>
</setting> </setting>
<setting name="ClientMarkupPercent" serializeAs="String">
<value>0</value>
</setting>
<setting name="stringLogin" serializeAs="String"> <setting name="stringLogin" serializeAs="String">
<value /> <value />
</setting> </setting>
@ -36,6 +39,21 @@
<setting name="stringToken" serializeAs="String"> <setting name="stringToken" serializeAs="String">
<value /> <value />
</setting> </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> </Электронная_Фармация.Properties.Settings>
</userSettings> </userSettings>
</configuration> </configuration>

View File

@ -1,11 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
namespace Электроннаяармация.Classes namespace Электроннаяармация.Classes
{ {
@ -98,7 +101,13 @@ namespace Электронная_Фармация.Classes
public bool IsAuthenticated => !string.IsNullOrEmpty(_token); public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string supplierId = null, string regionId = null, string q = null, int? limit = null, int? offset = null) public async Task<PriceSummaryResponse> GetPriceSummaryAsync(
string supplierId = null,
string regionId = null,
string q = null,
int? limit = null,
int? offset = null,
IProgress<HttpTransferProgress> transferProgress = null)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
@ -130,7 +139,10 @@ namespace Электронная_Фармация.Classes
path += "?" + string.Join("&", queryParams); path += "?" + string.Join("&", queryParams);
} }
var responseBody = await SendAuthorizedGetAsync(path, "загрузку прайса"); var responseBody = transferProgress == null
? await SendAuthorizedGetAsync(path, "загрузку прайса")
: await SendAuthorizedGetWithProgressAsync(path, "загрузку прайса", transferProgress);
try try
{ {
var summary = JsonSerializer.Deserialize<PriceSummaryResponse>(responseBody, JsonOptions); var summary = JsonSerializer.Deserialize<PriceSummaryResponse>(responseBody, JsonOptions);
@ -145,21 +157,362 @@ namespace Электронная_Фармация.Classes
} }
} }
public async Task<List<InvoiceApiItem>> GetInvoicesAsync() public async Task<List<BuyerDto>> GetBuyersAsync()
{ {
EnsureAuthenticated(); EnsureAuthenticated();
var responseBody = await SendAuthorizedGetAsync("/api/buyers", "загрузку покупателей");
return DeserializeBuyers(responseBody);
}
var responseBody = await SendAuthorizedGetAsync("/api/buyer/invoices", "загрузку накладных"); public async Task<BuyerDto> GetBuyerByIdAsync(string buyerId)
{
EnsureAuthenticated();
if (string.IsNullOrWhiteSpace(buyerId))
{
throw new ArgumentException("Не указан buyer_id.", nameof(buyerId));
}
var responseBody = await SendAuthorizedGetAsync(
$"/api/buyers/{Uri.EscapeDataString(buyerId.Trim())}",
"загрузку покупателя");
try try
{ {
var items = JsonSerializer.Deserialize<List<InvoiceApiItem>>(responseBody, JsonOptions) ?? new List<InvoiceApiItem>(); return JsonSerializer.Deserialize<BuyerDto>(responseBody, JsonOptions);
AppDebugLog.Info("ApiClient", $"Накладные получены: {items.Count} шт.");
return items;
} }
catch (Exception ex) catch (Exception ex)
{ {
AppDebugLog.Error("ApiClient", "Не удалось разобрать ответ накладных", ex); AppDebugLog.Error("ApiClient", "Не удалось разобрать покупателя", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке накладных: {ex.Message}", ex); throw new HttpRequestException($"Некорректный ответ сервера при загрузке покупателя: {ex.Message}", ex);
}
}
public async Task<List<BuyerPriceListAssignmentDto>> GetBuyerPriceListsAsync(string buyerId)
{
EnsureAuthenticated();
if (string.IsNullOrWhiteSpace(buyerId))
{
throw new ArgumentException("Не указан buyer_id.", nameof(buyerId));
}
var responseBody = await SendAuthorizedGetAsync(
$"/api/buyers/{Uri.EscapeDataString(buyerId.Trim())}/price-lists",
"загрузку назначений прайсов покупателя");
return DeserializeBuyerPriceLists(responseBody);
}
public async Task<List<PriceListDto>> GetPriceListsAsync()
{
EnsureAuthenticated();
var responseBody = await SendAuthorizedGetAsync("/api/price-lists", "загрузку прайс-листов");
return DeserializePriceLists(responseBody);
}
public async Task<List<PriceListRegionDto>> GetPriceListRegionsAsync(string priceListId)
{
EnsureAuthenticated();
if (string.IsNullOrWhiteSpace(priceListId))
{
throw new ArgumentException("Не указан price_list_id.", nameof(priceListId));
}
var path = "/api/sc/price-list-regions?price_list_id=" + Uri.EscapeDataString(priceListId.Trim());
var responseBody = await SendAuthorizedGetAsync(path, "загрузку региональных наценок прайса");
return DeserializePriceListRegions(responseBody);
}
private static List<BuyerDto> DeserializeBuyers(string responseBody)
{
try
{
using (var doc = JsonDocument.Parse(responseBody))
{
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return JsonSerializer.Deserialize<List<BuyerDto>>(responseBody, JsonOptions)
?? new List<BuyerDto>();
}
var wrapped = JsonSerializer.Deserialize<BuyersResponse>(responseBody, JsonOptions);
return wrapped?.Buyers ?? wrapped?.Items ?? new List<BuyerDto>();
}
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать список покупателей", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке покупателей: {ex.Message}", ex);
}
}
private static List<BuyerPriceListAssignmentDto> DeserializeBuyerPriceLists(string responseBody)
{
try
{
using (var doc = JsonDocument.Parse(responseBody))
{
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return JsonSerializer.Deserialize<List<BuyerPriceListAssignmentDto>>(responseBody, JsonOptions)
?? new List<BuyerPriceListAssignmentDto>();
}
var wrapped = JsonSerializer.Deserialize<BuyerPriceListsResponse>(responseBody, JsonOptions);
return wrapped?.Items ?? wrapped?.PriceLists ?? new List<BuyerPriceListAssignmentDto>();
}
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать назначения прайсов покупателя", ex);
throw new HttpRequestException(
$"Некорректный ответ сервера при загрузке назначений прайсов: {ex.Message}",
ex);
}
}
private static List<PriceListDto> DeserializePriceLists(string responseBody)
{
try
{
using (var doc = JsonDocument.Parse(responseBody))
{
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return JsonSerializer.Deserialize<List<PriceListDto>>(responseBody, JsonOptions)
?? new List<PriceListDto>();
}
var wrapped = JsonSerializer.Deserialize<PriceListsResponse>(responseBody, JsonOptions);
return wrapped?.PriceLists ?? wrapped?.Items ?? new List<PriceListDto>();
}
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать прайс-листы", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке прайс-листов: {ex.Message}", ex);
}
}
private static List<PriceListRegionDto> DeserializePriceListRegions(string responseBody)
{
try
{
using (var doc = JsonDocument.Parse(responseBody))
{
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return JsonSerializer.Deserialize<List<PriceListRegionDto>>(responseBody, JsonOptions)
?? new List<PriceListRegionDto>();
}
}
return new List<PriceListRegionDto>();
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать региональные наценки прайса", ex);
throw new HttpRequestException(
$"Некорректный ответ сервера при загрузке региональных наценок: {ex.Message}",
ex);
}
}
public async Task<List<InvoiceApiItem>> GetInvoicesAsync()
{
var orders = await GetBuyerOrdersAsync(status: "Placed", limit: 200);
var result = new List<InvoiceApiItem>();
foreach (var order in orders)
{
var detail = await GetBuyerOrderByIdAsync(order.OrderID);
if (detail == null)
{
continue;
}
result.Add(InvoiceApiItem.FromBuyerOrder(detail));
}
AppDebugLog.Info("ApiClient", $"Накладные (через buyer/orders) получены: {result.Count} шт.");
return result;
}
public async Task<List<BuyerOrderListItemDto>> GetBuyerOrdersAsync(string status = null, int limit = 200, int offset = 0)
{
EnsureAuthenticated();
var query = new List<string>();
if (!string.IsNullOrWhiteSpace(status))
{
query.Add($"status={Uri.EscapeDataString(status)}");
}
if (limit > 0)
{
query.Add($"limit={limit}");
}
if (offset > 0)
{
query.Add($"offset={offset}");
}
var path = "/api/buyer/orders";
if (query.Count > 0)
{
path += "?" + string.Join("&", query);
}
var responseBody = await SendAuthorizedGetAsync(path, "загрузку заказов");
try
{
var response = JsonSerializer.Deserialize<BuyerOrdersResponse>(responseBody, JsonOptions);
return response?.Orders ?? new List<BuyerOrderListItemDto>();
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать список заказов", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке заказов: {ex.Message}", ex);
}
}
/// <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);
}
}
/// <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()
{
EnsureAuthenticated();
var responseBody = await SendAuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей");
try
{
using (var doc = JsonDocument.Parse(responseBody))
{
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return JsonSerializer.Deserialize<List<BuyerLocationDto>>(responseBody, JsonOptions)
?? new List<BuyerLocationDto>();
}
}
return new List<BuyerLocationDto>();
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать грузополучателей", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке грузополучателей: {ex.Message}", ex);
}
}
public async Task<BuyerOrderDetailDto> GetBuyerOrderByIdAsync(string orderId)
{
EnsureAuthenticated();
if (string.IsNullOrWhiteSpace(orderId))
{
throw new ArgumentException("Не указан ID заказа.", nameof(orderId));
}
var responseBody = await SendAuthorizedGetAsync($"/api/buyer/orders/{Uri.EscapeDataString(orderId)}", "загрузку заказа");
try
{
return JsonSerializer.Deserialize<BuyerOrderDetailDto>(responseBody, JsonOptions);
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", $"Не удалось разобрать заказ {orderId}", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке заказа: {ex.Message}", ex);
}
}
public async Task<BuyerOrderDetailDto> PlaceBuyerOrderAsync(string orderId)
{
EnsureAuthenticated();
if (string.IsNullOrWhiteSpace(orderId))
{
throw new ArgumentException("Не указан ID заказа.", nameof(orderId));
}
var responseBody = await SendAuthorizedPostAsync($"/api/buyer/orders/{Uri.EscapeDataString(orderId)}/place", string.Empty, "оформление заказа");
try
{
return JsonSerializer.Deserialize<BuyerOrderDetailDto>(responseBody, JsonOptions);
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", $"Не удалось разобрать ответ оформления заказа {orderId}", ex);
throw new HttpRequestException($"Некорректный ответ сервера при оформлении заказа: {ex.Message}", ex);
} }
} }
@ -174,6 +527,71 @@ namespace Электронная_Фармация.Classes
} }
private async Task<string> SendAuthorizedGetAsync(string path, string operationName) private async Task<string> SendAuthorizedGetAsync(string path, string operationName)
{
return await SendAuthorizedGetWithProgressAsync(path, operationName, null);
}
private async Task<string> SendAuthorizedPostAsync(string path, string requestBody, string operationName)
{
var fullUrl = BuildFullUrl(path);
var stopwatch = Stopwatch.StartNew();
AppDebugLog.ApiRequest("POST", fullUrl, requestBody, $"token={AppDebugLog.MaskToken(_token)}");
try
{
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, fullUrl))
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
httpRequest.Content = new StringContent(requestBody ?? string.Empty, Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
stopwatch.Stop();
AppDebugLog.ApiResponse("POST", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
if (response.IsSuccessStatusCode)
{
return responseBody;
}
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_token = null;
var error = TryDeserializeError(responseBody);
var message = $"Сессия истекла: {error?.Error ?? "Необходимо войти заново"}";
AppDebugLog.ApiHttpError("POST", fullUrl, (int)response.StatusCode, responseBody, operationName);
throw new UnauthorizedAccessException(message);
}
var httpError = TryDeserializeError(responseBody);
var httpMessage = $"Ошибка HTTP {response.StatusCode}: {httpError?.Error ?? responseBody}";
AppDebugLog.ApiHttpError("POST", fullUrl, (int)response.StatusCode, responseBody, operationName);
throw new HttpRequestException(httpMessage);
}
}
catch (UnauthorizedAccessException)
{
throw;
}
catch (HttpRequestException ex) when (!ex.Message.StartsWith("Ошибка HTTP"))
{
stopwatch.Stop();
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw WrapNetworkError(ex, operationName);
}
catch (TaskCanceledException ex)
{
stopwatch.Stop();
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw WrapNetworkError(ex, operationName);
}
}
private async Task<string> SendAuthorizedGetWithProgressAsync(
string path,
string operationName,
IProgress<HttpTransferProgress> transferProgress)
{ {
var fullUrl = BuildFullUrl(path); var fullUrl = BuildFullUrl(path);
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
@ -185,8 +603,8 @@ namespace Электронная_Фармация.Classes
using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, fullUrl)) using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, fullUrl))
{ {
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
var response = await _httpClient.SendAsync(httpRequest); var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await ReadContentWithProgressAsync(response, transferProgress);
stopwatch.Stop(); stopwatch.Stop();
AppDebugLog.ApiResponse("GET", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody); AppDebugLog.ApiResponse("GET", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseBody);
@ -243,6 +661,29 @@ namespace Электронная_Фармация.Classes
} }
} }
private static async Task<string> ReadContentWithProgressAsync(
HttpResponseMessage response,
IProgress<HttpTransferProgress> transferProgress)
{
var totalBytes = response.Content.Headers.ContentLength;
using (var stream = await response.Content.ReadAsStreamAsync())
using (var memory = new System.IO.MemoryStream())
{
var buffer = new byte[81920];
long received = 0;
int read;
while ((read = await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
{
memory.Write(buffer, 0, read);
received += read;
transferProgress?.Report(new HttpTransferProgress(received, totalBytes));
}
transferProgress?.Report(new HttpTransferProgress(received, totalBytes ?? received));
return Encoding.UTF8.GetString(memory.ToArray());
}
}
private string ParseLoginResponse(HttpResponseMessage response, string responseBody) private string ParseLoginResponse(HttpResponseMessage response, string responseBody)
{ {
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
@ -332,5 +773,232 @@ namespace Электронная_Фармация.Classes
public string ConsigneeName { get; set; } public string ConsigneeName { get; set; }
public string InvoiceSum { get; set; } public string InvoiceSum { get; set; }
public string RefuseSum { get; set; } public string RefuseSum { get; set; }
public string SourceOrderId { get; set; }
public List<InvoiceApiOrderItem> Items { get; set; } = new List<InvoiceApiOrderItem>();
public static InvoiceApiItem FromBuyerOrder(BuyerOrderDetailDto detail)
{
var supplierNames = detail.Items == null
? string.Empty
: string.Join(", ", detail.Items
.Select(i => i.Supplier)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct());
var invoiceDate = detail.PlacedAt ?? detail.CreatedAt;
var invoice = new InvoiceApiItem
{
InvoiceNumber = string.IsNullOrWhiteSpace(detail.LocalOrderNumber)
? detail.OrderID
: detail.LocalOrderNumber,
InvoiceDate = invoiceDate?.ToString("yyyy-MM-dd") ?? string.Empty,
SupplierName = supplierNames,
ConsigneeName = detail.LocationAddress ?? string.Empty,
InvoiceSum = (detail.TotalAmount ?? 0m).ToString(System.Globalization.CultureInfo.InvariantCulture),
RefuseSum = "0",
SourceOrderId = detail.OrderID
};
if (detail.Items != null)
{
foreach (var item in detail.Items)
{
invoice.Items.Add(new InvoiceApiOrderItem
{
GoodCode = item.ItemCode ?? string.Empty,
GoodName = item.Name ?? string.Empty,
SupplierName = item.Supplier ?? string.Empty,
Qty = item.Qty,
Price = item.UnitPrice,
Total = item.Total
});
}
}
return invoice;
}
}
public sealed class InvoiceApiOrderItem
{
public string GoodCode { get; set; }
public string GoodName { get; set; }
public string SupplierName { get; set; }
public decimal Qty { get; set; }
public decimal Price { get; set; }
public decimal Total { get; set; }
}
public sealed class BuyerOrdersResponse
{
[JsonPropertyName("orders")]
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
{
[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")]
public string BuyerLocationID { get; set; }
[JsonPropertyName("buyer_id")]
public string BuyerID { get; set; }
[JsonPropertyName("address")]
public string Address { get; set; }
[JsonPropertyName("region_id")]
public string RegionID { get; set; }
[JsonPropertyName("is_default")]
public bool IsDefault { get; set; }
[JsonPropertyName("region_name")]
public string RegionName { get; set; }
}
public sealed class BuyerOrderListItemDto
{
[JsonPropertyName("order_id")]
public string OrderID { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
}
public sealed class BuyerOrderDetailDto
{
[JsonPropertyName("order_id")]
public string OrderID { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("total_amount")]
public decimal? TotalAmount { get; set; }
[JsonPropertyName("created_at")]
public DateTime? CreatedAt { get; set; }
[JsonPropertyName("placed_at")]
public DateTime? PlacedAt { get; set; }
[JsonPropertyName("comment")]
public string Comment { get; set; }
[JsonPropertyName("location_address")]
public string LocationAddress { get; set; }
[JsonPropertyName("items")]
public List<BuyerOrderItemDetailDto> Items { get; set; }
[JsonIgnore]
public string LocalOrderNumber { get; set; }
}
public sealed class BuyerOrderItemDetailDto
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("supplier")]
public string Supplier { get; set; }
[JsonPropertyName("qty")]
public decimal Qty { get; set; }
[JsonPropertyName("unit_price")]
public decimal UnitPrice { get; set; }
[JsonPropertyName("total")]
public decimal Total { get; set; }
[JsonPropertyName("item_code")]
public string ItemCode { get; set; }
}
public sealed class HttpTransferProgress
{
public HttpTransferProgress(long bytesReceived, long? totalBytes)
{
BytesReceived = bytesReceived;
TotalBytes = totalBytes;
}
public long BytesReceived { get; }
public long? TotalBytes { get; }
public int Percent
{
get
{
if (!TotalBytes.HasValue || TotalBytes.Value <= 0)
{
return 0;
}
return (int)Math.Max(0, Math.Min(100, (100.0 * BytesReceived) / TotalBytes.Value));
}
}
} }
} }

View File

@ -10,12 +10,72 @@ namespace Электронная_Фармация.Classes
public static class AppConfig public static class AppConfig
{ {
public const string DefaultApiBaseUrl = "https://24pharmdata.ru"; public const string DefaultApiBaseUrl = "https://24pharmdata.ru";
public const string DefaultUpdateCheckBaseUrl = "https://git.24pharmdata.ru";
public const string DefaultUpdateRepo = "pharmdata/elfisa-pharmacy";
/// <summary>Публичная раздача установщика (корень CDN отдаёт Setup.exe).</summary>
public const string DefaultInstallerCdnUrl = "https://cdn.24pharmdata.ru";
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 readonly object _sqliteInteropLock = new object();
public static string InstallerDownloadUrl
{
get
{
var fromEnv = Environment.GetEnvironmentVariable("ELF_INSTALLER_URL");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv.Trim();
}
return DefaultInstallerDownloadUrl;
}
}
public static string InstallerCdnUrl
{
get
{
var fromEnv = Environment.GetEnvironmentVariable("ELF_CDN_URL");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv.Trim().TrimEnd('/');
}
return DefaultInstallerCdnUrl;
}
}
/// <summary> /// <summary>
/// Replaces known dead API URLs in saved user settings. /// Replaces known dead API URLs in saved user settings.
/// </summary> /// </summary>
/// <summary>
/// Carries user settings across AssemblyVersion bumps (new user.config folder).
/// </summary>
public static void UpgradeUserSettingsIfNeeded()
{
try
{
if (Settings.Default.UpgradeSettings)
{
Settings.Default.Upgrade();
Settings.Default.UpgradeSettings = false;
Settings.Default.Save();
AppDebugLog.Info("AppConfig", "User settings upgraded from previous version");
}
}
catch (Exception ex)
{
AppDebugLog.Info("AppConfig", $"Upgrade settings skipped: {ex.Message}");
}
}
public static void MigrateLegacySettings() public static void MigrateLegacySettings()
{ {
var current = Settings.Default.ApiBaseUrl; var current = Settings.Default.ApiBaseUrl;
@ -55,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))
@ -72,30 +147,87 @@ namespace Электронная_Фармация.Classes
return url; return url;
} }
/// <summary>
/// Writable per-user data (DB, logs). Program Files is read-only for normal users.
/// </summary>
public static string UserDataDirectory
{
get
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ЭльФиСА",
"Электронная Фармация");
try
{
Directory.CreateDirectory(dir);
}
catch
{
// Caller will surface a clearer error when opening the DB.
}
return dir;
}
}
public static string LogsDirectory => Path.Combine(UserDataDirectory, "Logs");
public static string SqliteDbPath public static string SqliteDbPath
{ {
get get
{ {
MigrateLegacySqliteDatabaseIfNeeded();
return Path.Combine(UserDataDirectory, "efClient.db");
}
}
/// <summary>
/// One-time copy from install folder (old portable layout) into LocalAppData.
/// </summary>
private static void MigrateLegacySqliteDatabaseIfNeeded()
{
try
{
var target = Path.Combine(UserDataDirectory, "efClient.db");
if (File.Exists(target))
{
return;
}
var baseDir = AppDomain.CurrentDomain.BaseDirectory; var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var root = Path.Combine(baseDir, "efClient.db"); var candidates = new[]
var data = Path.Combine(baseDir, "Data", "efClient.db");
if (File.Exists(root))
{ {
return root; Path.Combine(baseDir, "efClient.db"),
Path.Combine(baseDir, "Data", "efClient.db")
};
foreach (var source in candidates)
{
if (!File.Exists(source))
{
continue;
} }
if (File.Exists(data)) File.Copy(source, target, overwrite: false);
{ AppDebugLog.Info("AppConfig", $"SQLite DB migrated: {source} -> {target}");
return data; return;
} }
}
return root; catch (Exception ex)
{
AppDebugLog.Info("AppConfig", $"SQLite DB migrate skipped: {ex.Message}");
} }
} }
public static string SqliteConnectionString public static string SqliteConnectionString
{ {
get { return $"Data Source={SqliteDbPath};Version=3;New=False;"; } get
{
EnsureSQLiteInteropInBaseDir();
return $"Data Source={SqliteDbPath};Version=3;New=False;";
}
} }
public static string LocationId public static string LocationId
@ -108,5 +240,124 @@ namespace Электронная_Фармация.Classes
Settings.Default.LocationId = (locationId ?? string.Empty).Trim(); Settings.Default.LocationId = (locationId ?? string.Empty).Trim();
Settings.Default.Save(); Settings.Default.Save();
} }
public static string InvoiceExportPath
{
get { return Settings.Default.InvoiceExportPath ?? string.Empty; }
}
public static void SetInvoiceExportPath(string path)
{
Settings.Default.InvoiceExportPath = (path ?? string.Empty).Trim();
Settings.Default.Save();
}
/// <summary>
/// Gitea base URL for forced update checks (no trailing slash).
/// </summary>
public static string UpdateCheckBaseUrl
{
get
{
var fromEnv = Environment.GetEnvironmentVariable("ELF_UPDATE_CHECK_BASE_URL");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return NormalizeApiBaseUrl(fromEnv);
}
var fromSettings = Settings.Default.UpdateCheckBaseUrl;
if (!string.IsNullOrWhiteSpace(fromSettings))
{
return NormalizeApiBaseUrl(fromSettings);
}
return DefaultUpdateCheckBaseUrl;
}
}
/// <summary>
/// owner/repo on Gitea, e.g. pharmdata/elfisa-pharmacy.
/// </summary>
public static string UpdateRepo
{
get
{
var fromEnv = Environment.GetEnvironmentVariable("ELF_UPDATE_REPO");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv.Trim().Trim('/');
}
var fromSettings = Settings.Default.UpdateRepo;
if (!string.IsNullOrWhiteSpace(fromSettings))
{
return fromSettings.Trim().Trim('/');
}
return DefaultUpdateRepo;
}
}
/// <summary>
/// Optional read-only Gitea token for private release repos.
/// </summary>
public static string UpdateApiToken
{
get
{
var fromEnv = Environment.GetEnvironmentVariable("ELF_UPDATE_API_TOKEN");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv.Trim();
}
return Settings.Default.UpdateApiToken ?? string.Empty;
}
}
private static void EnsureSQLiteInteropInBaseDir()
{
// System.Data.SQLite ожидает SQLite.Interop.dll рядом с exe.
// Важно: разрядность DLL должна совпадать с процессом (Prefer32Bit => x86).
if (_sqliteInteropPrepared)
{
return;
}
lock (_sqliteInteropLock)
{
if (_sqliteInteropPrepared)
{
return;
}
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var targetDll = Path.Combine(baseDir, "SQLite.Interop.dll");
var archFolder = Environment.Is64BitProcess ? "x64" : "x86";
var sourceDll = Path.Combine(baseDir, archFolder, "SQLite.Interop.dll");
var sourcePdb = Path.Combine(baseDir, archFolder, "SQLite.Interop.pdb");
try
{
if (File.Exists(sourceDll))
{
// Всегда подкладываем DLL нужной разрядности (даже если в корне уже лежит "чужая").
File.Copy(sourceDll, targetDll, overwrite: true);
var targetPdb = Path.Combine(baseDir, "SQLite.Interop.pdb");
if (File.Exists(sourcePdb))
{
File.Copy(sourcePdb, targetPdb, overwrite: true);
}
}
}
catch
{
// Если копирование не удалось (права/readonly/и т.п.), дальше будет явное исключение от SQLite.
}
_sqliteInteropPrepared = true;
}
}
} }
} }

View File

@ -8,7 +8,7 @@ namespace Электронная_Фармация.Classes
{ {
/// <summary> /// <summary>
/// File logger for API/auth/network debugging. /// File logger for API/auth/network debugging.
/// Logs are written to Logs/api-debug-YYYY-MM-DD.log next to the executable. /// Logs are written to %LocalAppData%\ЭльФиСА\...\Logs\api-debug-YYYY-MM-DD.log.
/// </summary> /// </summary>
public static class AppDebugLog public static class AppDebugLog
{ {
@ -33,8 +33,7 @@ namespace Электронная_Фармация.Classes
} }
} }
public static string LogDirectory => public static string LogDirectory => AppConfig.LogsDirectory;
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
public static string CurrentLogFilePath => public static string CurrentLogFilePath =>
Path.Combine(LogDirectory, $"api-debug-{DateTime.Now:yyyy-MM-dd}.log"); Path.Combine(LogDirectory, $"api-debug-{DateTime.Now:yyyy-MM-dd}.log");

View File

@ -0,0 +1,287 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
namespace Электроннаяармация.Classes
{
/// <summary>
/// Downloads a Gitea release ZIP and applies it via an external batch updater
/// so the running exe can be replaced. Preserves local databases and Logs.
/// </summary>
public static class AutoUpdater
{
public static async Task ApplyUpdateAsync(
UpdateCheckResult update,
IProgress<string> status = null)
{
if (update == null)
{
throw new ArgumentNullException(nameof(update));
}
var downloadUrl = !string.IsNullOrWhiteSpace(update.DownloadUrl)
? update.DownloadUrl
: update.ReleaseUrl;
if (string.IsNullOrWhiteSpace(downloadUrl) ||
!downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
if (!string.IsNullOrWhiteSpace(downloadUrl) &&
!downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"В релизе нет ZIP-сборки. Прикрепите ElectronicPharmacy-vX.Y.Z.zip к Release.");
}
throw new InvalidOperationException("Нет ссылки на ZIP обновления.");
}
var installDir = AppDomain.CurrentDomain.BaseDirectory.TrimEnd(
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var currentExe = Process.GetCurrentProcess().MainModule?.FileName;
if (string.IsNullOrWhiteSpace(currentExe) || !File.Exists(currentExe))
{
currentExe = Directory.GetFiles(installDir, "*.exe")
.FirstOrDefault(p => !p.EndsWith(".vshost.exe", StringComparison.OrdinalIgnoreCase));
}
if (string.IsNullOrWhiteSpace(currentExe))
{
throw new InvalidOperationException("Не найден текущий exe.");
}
var workRoot = Path.Combine(Path.GetTempPath(), "elfisa-update-" + Guid.NewGuid().ToString("N"));
var zipPath = Path.Combine(workRoot, "update.zip");
var extractDir = Path.Combine(workRoot, "extract");
Directory.CreateDirectory(extractDir);
try
{
Report(status, "Скачивание обновления...");
await DownloadAsync(downloadUrl, zipPath).ConfigureAwait(false);
Report(status, "Распаковка...");
ZipFile.ExtractToDirectory(zipPath, extractDir);
var payloadDir = FindPayloadDirectory(extractDir);
if (payloadDir == null)
{
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);
Report(status, needsElevation
? "Нужны права администратора для установки в Program Files..."
: "Подготовка перезапуска...");
var batchArgs = Quote(payloadDir) + " " + Quote(installDir) + " " +
Quote(Path.GetFileName(currentExe)) + " " +
Process.GetCurrentProcess().Id;
var psi = new ProcessStartInfo
{
UseShellExecute = true,
WorkingDirectory = workRoot,
WindowStyle = needsElevation ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden,
CreateNoWindow = !needsElevation
};
if (needsElevation)
{
// ВАЖНО: повышаем права через 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
{
Process.Start(psi);
}
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
// ERROR_CANCELLED — пользователь отклонил UAC
throw new InvalidOperationException(
"Обновление отменено: нужны права администратора " +
"(программа установлена в Program Files).",
ex);
}
AppDebugLog.Info("AutoUpdater",
$"Updater started. elevate={needsElevation}, payload={payloadDir}, install={installDir}, ver={update.LatestVersion}");
}
catch
{
try
{
if (Directory.Exists(workRoot))
{
Directory.Delete(workRoot, true);
}
}
catch
{
// ignore cleanup errors
}
throw;
}
}
private static bool IsDirectoryWritable(string directory)
{
try
{
var probe = Path.Combine(directory, ".elfisa-write-" + Guid.NewGuid().ToString("N"));
File.WriteAllText(probe, "ok");
File.Delete(probe);
return true;
}
catch
{
return false;
}
}
private static async Task DownloadAsync(string url, string zipPath)
{
using (var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) })
{
var token = AppConfig.UpdateApiToken;
if (!string.IsNullOrWhiteSpace(token))
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("token", token.Trim());
}
using (var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead)
.ConfigureAwait(false))
{
response.EnsureSuccessStatusCode();
using (var fs = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await response.Content.CopyToAsync(fs).ConfigureAwait(false);
}
}
}
}
private static string FindPayloadDirectory(string extractRoot)
{
// ZIP may contain a single root folder (Compress-Archive of a directory).
var exe = Directory.GetFiles(extractRoot, "*.exe", SearchOption.AllDirectories)
.FirstOrDefault(p =>
{
var name = Path.GetFileName(p) ?? string.Empty;
return !name.EndsWith(".vshost.exe", StringComparison.OrdinalIgnoreCase) &&
!name.Equals("UpdateCheckSmoke.exe", StringComparison.OrdinalIgnoreCase) &&
!name.StartsWith("unins", StringComparison.OrdinalIgnoreCase);
});
return exe == null ? null : Path.GetDirectoryName(exe);
}
private static void WriteUpdaterScript(string path)
{
// Waits for app exit, force-kills leftovers, renames locked exe,
// copies with robocopy, verifies, restarts. Log: %TEMP%\elfisa-updater.log
var sb = new StringBuilder();
sb.AppendLine("@echo off");
sb.AppendLine("setlocal EnableDelayedExpansion");
sb.AppendLine("set \"SRC=%~1\"");
sb.AppendLine("set \"DST=%~2\"");
sb.AppendLine("set \"EXENAME=%~3\"");
sb.AppendLine("set \"PID=%~4\"");
sb.AppendLine("set \"LOG=%TEMP%\\elfisa-updater.log\"");
sb.AppendLine("echo ===== %DATE% %TIME% ===== > \"%LOG%\"");
sb.AppendLine("echo SRC=%SRC%>> \"%LOG%\"");
sb.AppendLine("echo DST=%DST%>> \"%LOG%\"");
sb.AppendLine("echo EXE=%EXENAME% PID=%PID%>> \"%LOG%\"");
sb.AppendLine("set \"N=0\"");
sb.AppendLine("echo Waiting for PID %PID% ... >> \"%LOG%\"");
sb.AppendLine(":waitloop");
sb.AppendLine("tasklist /FI \"PID eq %PID%\" 2>nul | findstr /C:\" %PID% \" >nul");
sb.AppendLine("if errorlevel 1 goto donewait");
sb.AppendLine("set /a N+=1");
sb.AppendLine("if !N! GEQ 40 (");
sb.AppendLine(" echo force kill PID %PID% >> \"%LOG%\"");
sb.AppendLine(" taskkill /PID %PID% /F >nul 2>&1");
sb.AppendLine(" goto donewait");
sb.AppendLine(")");
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
sb.AppendLine("goto waitloop");
sb.AppendLine(":donewait");
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
// Kill any leftover instances by image name (locks Program Files exe).
sb.AppendLine("echo taskkill by name >> \"%LOG%\"");
sb.AppendLine("taskkill /F /IM \"%EXENAME%\" >nul 2>&1");
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
// Rename locked/old exe so copy can proceed even if handle lingered.
sb.AppendLine("if exist \"%DST%\\%EXENAME%.old\" del /F /Q \"%DST%\\%EXENAME%.old\" >nul 2>&1");
sb.AppendLine("if exist \"%DST%\\%EXENAME%\" (");
sb.AppendLine(" move /Y \"%DST%\\%EXENAME%\" \"%DST%\\%EXENAME%.old\" >> \"%LOG%\" 2>&1");
sb.AppendLine(")");
sb.AppendLine("echo Copying update... >> \"%LOG%\"");
// /IS /IT — force overwrite even if timestamps look the same
sb.AppendLine("robocopy \"%SRC%\" \"%DST%\" /E /IS /IT /R:5 /W:1 /NFL /NDL /NJH /NJS /XF efClient.db *.db /XD Logs");
sb.AppendLine("set \"RC=!ERRORLEVEL!\"");
sb.AppendLine("echo robocopy=!RC! >> \"%LOG%\"");
sb.AppendLine("if not exist \"%DST%\\%EXENAME%\" (");
sb.AppendLine(" echo COPY FAILED — restoring old exe >> \"%LOG%\"");
sb.AppendLine(" if exist \"%DST%\\%EXENAME%.old\" move /Y \"%DST%\\%EXENAME%.old\" \"%DST%\\%EXENAME%\" >nul 2>&1");
sb.AppendLine(" echo Не удалось заменить файлы программы. См. %TEMP%\\elfisa-updater.log");
sb.AppendLine(" pause");
sb.AppendLine(" exit /b 1");
sb.AppendLine(")");
sb.AppendLine("if exist \"%DST%\\%EXENAME%.old\" del /F /Q \"%DST%\\%EXENAME%.old\" >nul 2>&1");
sb.AppendLine("echo Starting \"%DST%\\%EXENAME%\" >> \"%LOG%\"");
sb.AppendLine("start \"\" /D \"%DST%\" \"%DST%\\%EXENAME%\"");
sb.AppendLine("exit /b 0");
File.WriteAllText(path, sb.ToString(), Encoding.Default);
}
private static string Quote(string value)
{
return "\"" + (value ?? string.Empty).Replace("\"", string.Empty) + "\"";
}
private static void Report(IProgress<string> status, string message)
{
AppDebugLog.Info("AutoUpdater", message);
status?.Report(message);
}
}
}

View File

@ -0,0 +1,137 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Электроннаяармация.Classes
{
public sealed class BuyerDto
{
[JsonPropertyName("buyer_id")]
public string BuyerId { get; set; }
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; }
[JsonPropertyName("login")]
public string Login { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
public string ResolvedId =>
!string.IsNullOrWhiteSpace(BuyerId) ? BuyerId.Trim() :
!string.IsNullOrWhiteSpace(Id) ? Id.Trim() :
string.Empty;
}
public sealed class BuyersResponse
{
[JsonPropertyName("buyers")]
public List<BuyerDto> Buyers { get; set; }
[JsonPropertyName("items")]
public List<BuyerDto> Items { get; set; }
}
public sealed class BuyerPriceListAssignmentDto
{
[JsonPropertyName("price_list_id")]
public string PriceListId { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("supplier_id")]
public string SupplierId { get; set; }
[JsonPropertyName("supplier_name")]
public string SupplierName { get; set; }
/// <summary>Клиентская скидка на этот прайс.</summary>
[JsonPropertyName("markup_pct")]
public decimal? MarkupPct { get; set; }
/// <summary>Базовая скидка самого прайса.</summary>
[JsonPropertyName("default_markup_pct")]
public decimal? DefaultMarkupPct { get; set; }
}
public sealed class BuyerPriceListsResponse
{
[JsonPropertyName("items")]
public List<BuyerPriceListAssignmentDto> Items { get; set; }
[JsonPropertyName("price_lists")]
public List<BuyerPriceListAssignmentDto> PriceLists { get; set; }
}
public sealed class PriceListDto
{
[JsonPropertyName("price_list_id")]
public string PriceListId { get; set; }
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("supplier_id")]
public string SupplierId { get; set; }
[JsonPropertyName("supplier_name")]
public string SupplierName { get; set; }
[JsonPropertyName("default_markup_pct")]
public decimal? DefaultMarkupPct { get; set; }
public string ResolvedId =>
!string.IsNullOrWhiteSpace(PriceListId) ? PriceListId.Trim() :
!string.IsNullOrWhiteSpace(Id) ? Id.Trim() :
string.Empty;
}
public sealed class PriceListsResponse
{
[JsonPropertyName("price_lists")]
public List<PriceListDto> PriceLists { get; set; }
[JsonPropertyName("items")]
public List<PriceListDto> Items { get; set; }
}
public sealed class PriceListRegionDto
{
[JsonPropertyName("region_id")]
public string RegionId { get; set; }
[JsonPropertyName("region_name")]
public string RegionName { get; set; }
[JsonPropertyName("in_work")]
public bool? InWork { get; set; }
[JsonPropertyName("markup_pct")]
public decimal? MarkupPct { get; set; }
[JsonPropertyName("base_price_type")]
public string BasePriceType { get; set; }
}
/// <summary>
/// Скидки, доступные для одной позиции прайса.
/// Отсутствующий источник = 0.
/// </summary>
public sealed class ItemDiscountParts
{
public string PriceListId { get; set; }
public decimal PriceListDiscountPct { get; set; }
public decimal RegionDiscountPct { get; set; }
public decimal ClientDiscountPct { get; set; }
public decimal TotalDiscountPct =>
PriceListDiscountPct + RegionDiscountPct + ClientDiscountPct;
}
}

View File

@ -0,0 +1,506 @@
using System;
using System.Collections.Generic;
using System.Data.SQLite;
namespace Электроннаяармация.Classes
{
/// <summary>
/// Аптека = грузополучатель + LocationId. Прайс общий, заказы уходят с location_id выбранной аптеки.
/// </summary>
public static class ConsigneeHelper
{
public static string GetDefaultConsigneeName()
{
try
{
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var command = new SQLiteCommand(
@"SELECT ConsigneesName
FROM Consignees
ORDER BY ConsigneesUseAsDefault DESC, ConsigneesName
LIMIT 1",
connection))
{
var result = command.ExecuteScalar();
return result == null || result == DBNull.Value
? string.Empty
: result.ToString().Trim();
}
}
}
catch
{
return string.Empty;
}
}
public static string GetLocationIdByName(string consigneeName)
{
if (string.IsNullOrWhiteSpace(consigneeName))
{
return string.Empty;
}
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var command = new SQLiteCommand(
@"SELECT ifnull(LocationId, '')
FROM Consignees
WHERE ConsigneesName = @name
LIMIT 1",
connection))
{
command.Parameters.AddWithValue("@name", consigneeName.Trim());
var result = command.ExecuteScalar();
return result == null || result == DBNull.Value
? string.Empty
: result.ToString().Trim();
}
}
}
public static string ResolveLocationId(string consigneeName)
{
var fromConsignee = GetLocationIdByName(consigneeName);
if (!string.IsNullOrWhiteSpace(fromConsignee))
{
return fromConsignee;
}
return AppConfig.LocationId ?? string.Empty;
}
/// <summary>
/// Помечает аптеку активной и синхронизирует AppConfig.LocationId.
/// </summary>
public static void SetActiveConsignee(string consigneeName)
{
if (string.IsNullOrWhiteSpace(consigneeName))
{
return;
}
var name = consigneeName.Trim();
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var tx = connection.BeginTransaction())
{
using (var clear = new SQLiteCommand(
"UPDATE Consignees SET ConsigneesUseAsDefault = 0",
connection,
tx))
{
clear.ExecuteNonQuery();
}
using (var setDefault = new SQLiteCommand(
@"UPDATE Consignees
SET ConsigneesUseAsDefault = 1
WHERE ConsigneesName = @name",
connection,
tx))
{
setDefault.Parameters.AddWithValue("@name", name);
setDefault.ExecuteNonQuery();
}
tx.Commit();
}
var locationId = GetLocationIdByName(name);
if (!string.IsNullOrWhiteSpace(locationId))
{
AppConfig.SetLocationId(locationId);
}
}
}
public static void SetLocationId(string consigneeName, string locationId)
{
if (string.IsNullOrWhiteSpace(consigneeName))
{
return;
}
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var command = new SQLiteCommand(
@"UPDATE Consignees
SET LocationId = @loc
WHERE ConsigneesName = @name",
connection))
{
command.Parameters.AddWithValue("@loc", (locationId ?? string.Empty).Trim());
command.Parameters.AddWithValue("@name", consigneeName.Trim());
command.ExecuteNonQuery();
}
}
}
public static void MigrateGlobalLocationIdIfNeeded(SQLiteConnection connection)
{
var global = AppConfig.LocationId;
if (string.IsNullOrWhiteSpace(global))
{
return;
}
using (var command = new SQLiteCommand(
@"UPDATE Consignees
SET LocationId = @loc
WHERE (LocationId IS NULL OR trim(LocationId) = '')
AND (
ConsigneesUseAsDefault = 1
OR (SELECT COUNT(*) FROM Consignees) = 1
)",
connection))
{
command.Parameters.AddWithValue("@loc", global.Trim());
command.ExecuteNonQuery();
}
}
public static void ApplyLocationIdToEmptyConsignees(string locationId)
{
if (string.IsNullOrWhiteSpace(locationId))
{
return;
}
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var command = new SQLiteCommand(
@"UPDATE Consignees
SET LocationId = @loc
WHERE LocationId IS NULL OR trim(LocationId) = ''",
connection))
{
command.Parameters.AddWithValue("@loc", locationId.Trim());
command.ExecuteNonQuery();
}
}
}
public static IList<string> GetKnownSupplierIds()
{
var result = new List<string>();
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var command = new SQLiteCommand(
@"SELECT DISTINCT trim(supplier_id)
FROM PriceList
WHERE supplier_id IS NOT NULL AND trim(supplier_id) <> ''
ORDER BY 1",
connection))
using (var reader = command.ExecuteReader())
{
while (reader.Read())
{
var id = reader.GetString(0);
if (!string.IsNullOrWhiteSpace(id))
{
result.Add(id);
}
}
}
}
return result;
}
/// <summary>
/// Процент наценки, который клиент задал вручную для аптеки.
/// null = ещё не сохраняли, брать fallback.
/// </summary>
public static decimal? GetClientMarkupPercent(string consigneeName)
{
if (string.IsNullOrWhiteSpace(consigneeName))
{
return null;
}
try
{
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var command = new SQLiteCommand(
@"SELECT ClientMarkupPercent
FROM Consignees
WHERE ConsigneesName = @name
LIMIT 1",
connection))
{
command.Parameters.AddWithValue("@name", consigneeName.Trim());
var result = command.ExecuteScalar();
if (result == null || result == DBNull.Value)
{
return null;
}
if (result is decimal d)
{
return d;
}
if (decimal.TryParse(
Convert.ToString(result, System.Globalization.CultureInfo.InvariantCulture),
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture,
out var parsed))
{
return parsed;
}
return null;
}
}
}
catch
{
return null;
}
}
/// <summary>
/// Сохраняет процент наценки клиента для конкретной аптеки.
/// Не сбрасывается при обновлении прайса.
/// </summary>
public static void SetClientMarkupPercent(string consigneeName, decimal markupPercent)
{
if (string.IsNullOrWhiteSpace(consigneeName))
{
return;
}
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var command = new SQLiteCommand(
@"UPDATE Consignees
SET ClientMarkupPercent = @pct
WHERE ConsigneesName = @name",
connection))
{
command.Parameters.AddWithValue("@pct", markupPercent);
command.Parameters.AddWithValue("@name", consigneeName.Trim());
command.ExecuteNonQuery();
}
}
}
/// <summary>
/// Если у аптеки ещё нет сохранённого процента — записать стартовое значение с сервера.
/// Уже введённые клиентом значения не трогаем.
/// </summary>
public static void SeedClientMarkupPercentIfEmpty(decimal? serverMarkupPercent)
{
if (!serverMarkupPercent.HasValue)
{
return;
}
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var command = new SQLiteCommand(
@"UPDATE Consignees
SET ClientMarkupPercent = @pct
WHERE ClientMarkupPercent IS NULL",
connection))
{
command.Parameters.AddWithValue("@pct", serverMarkupPercent.Value);
command.ExecuteNonQuery();
}
}
}
/// <summary>
/// Подтягивает грузополучателей с сервера (BuyerLocation) в локальную таблицу Consignees.
/// Источник истины — веб-кабинет; локально только кэш + выбор активной аптеки.
/// </summary>
public static int SyncFromServer(IList<BuyerLocationDto> locations)
{
if (locations == null || locations.Count == 0)
{
return 0;
}
var upserted = 0;
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var tx = connection.BeginTransaction())
{
// Сбрасываем default — выставим по данным сервера.
using (var clearDefault = new SQLiteCommand(
"UPDATE Consignees SET ConsigneesUseAsDefault = 0",
connection,
tx))
{
clearDefault.ExecuteNonQuery();
}
int nextCode;
using (var nextCodeCmd = new SQLiteCommand(
"SELECT ifnull(MAX(codeConsignees), 0) + 1 FROM Consignees",
connection,
tx))
{
nextCode = Convert.ToInt32(nextCodeCmd.ExecuteScalar());
}
foreach (var loc in locations)
{
var locationId = (loc.BuyerLocationID ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(locationId))
{
continue;
}
var address = (loc.Address ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(address))
{
address = "Грузополучатель";
}
var name = address;
if (name.Length > 120)
{
name = name.Substring(0, 120);
}
long? existingId = null;
using (var find = new SQLiteCommand(
@"SELECT idConsignees FROM Consignees
WHERE lower(trim(LocationId)) = lower(@loc)
LIMIT 1",
connection,
tx))
{
find.Parameters.AddWithValue("@loc", locationId);
var found = find.ExecuteScalar();
if (found != null && found != DBNull.Value)
{
existingId = Convert.ToInt64(found);
}
}
if (existingId.HasValue)
{
using (var upd = new SQLiteCommand(
@"UPDATE Consignees
SET ConsigneesName = @name,
ConsigneesAddress = @address,
ConsigneesUseAsDefault = @isDefault,
LocationId = @loc
WHERE idConsignees = @id",
connection,
tx))
{
upd.Parameters.AddWithValue("@name", name);
upd.Parameters.AddWithValue("@address", address);
upd.Parameters.AddWithValue("@isDefault", loc.IsDefault ? 1 : 0);
upd.Parameters.AddWithValue("@loc", locationId);
upd.Parameters.AddWithValue("@id", existingId.Value);
upd.ExecuteNonQuery();
}
}
else
{
using (var ins = new SQLiteCommand(
@"INSERT INTO Consignees
(codeConsignees, ConsigneesName, ConsigneesAddress, ConsigneesUseAsDefault, LocationId)
VALUES (@code, @name, @address, @isDefault, @loc)",
connection,
tx))
{
ins.Parameters.AddWithValue("@code", nextCode++);
ins.Parameters.AddWithValue("@name", name);
ins.Parameters.AddWithValue("@address", address);
ins.Parameters.AddWithValue("@isDefault", loc.IsDefault ? 1 : 0);
ins.Parameters.AddWithValue("@loc", locationId);
ins.ExecuteNonQuery();
}
}
upserted++;
}
tx.Commit();
}
}
var active = GetDefaultConsigneeName();
if (!string.IsNullOrWhiteSpace(active))
{
SetActiveConsignee(active);
}
return upserted;
}
/// <summary>
/// Синхронизирует поля заказа в `PriceList` (Zakaz/SummaZakaza) с корзиной выбранной аптеки.
/// Данные корзины хранятся в TempOrderItems с фильтром по ConsigneeName, а PriceList обновляется
/// только для текущего активного просмотра.
/// </summary>
public static void SyncCartToPriceList(string consigneeName)
{
var consignee = (consigneeName ?? string.Empty).Trim().Replace("'", "''");
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var tx = connection.BeginTransaction())
{
using (var clear = new SQLiteCommand(
@"UPDATE [PriceList]
SET [Zakaz] = NULL, [SummaZakaza] = NULL;", connection, tx))
{
clear.ExecuteNonQuery();
}
using (var sync = new SQLiteCommand(
@"UPDATE [PriceList]
SET
[Zakaz] = (
SELECT t.[Zakaz]
FROM [TempOrderItems] t
WHERE t.[ConsigneeName] = @consignee
AND t.[id_PriceList_Item] = [PriceList].[id_PriceList_Item]
LIMIT 1
),
[SummaZakaza] = (
SELECT t.[SummaZakaza]
FROM [TempOrderItems] t
WHERE t.[ConsigneeName] = @consignee
AND t.[id_PriceList_Item] = [PriceList].[id_PriceList_Item]
LIMIT 1
)
WHERE EXISTS (
SELECT 1
FROM [TempOrderItems] t
WHERE t.[ConsigneeName] = @consignee
AND t.[id_PriceList_Item] = [PriceList].[id_PriceList_Item]
);",
connection,
tx))
{
sync.Parameters.AddWithValue("@consignee", consignee);
sync.ExecuteNonQuery();
}
tx.Commit();
}
}
}
}
}

View File

@ -35,20 +35,6 @@ namespace Электронная_Фармация.Classes
return; return;
} }
if (string.IsNullOrWhiteSpace(AppConfig.LocationId))
{
if (!HF_LocationId.PromptAndSave(Form.ActiveForm)
|| string.IsNullOrWhiteSpace(AppConfig.LocationId))
{
AppDebugLog.Error("DataSender", "Отправка заказов: location_id не указан");
ToastNotification.ShowCustom(
"Не указан location_id. Заполните его после авторизации.",
Color.DarkOrange,
Color.White);
return;
}
}
List<BuyerOrderRequest> orders; List<BuyerOrderRequest> orders;
try try
{ {
@ -66,6 +52,11 @@ namespace Электронная_Фармация.Classes
return; return;
} }
if (!EnsureOrdersHaveLocationId(orders))
{
return;
}
int successCount = 0; int successCount = 0;
int failCount = 0; int failCount = 0;
@ -110,20 +101,6 @@ namespace Электронная_Фармация.Classes
return false; return false;
} }
if (string.IsNullOrWhiteSpace(AppConfig.LocationId))
{
if (!HF_LocationId.PromptAndSave(Form.ActiveForm)
|| string.IsNullOrWhiteSpace(AppConfig.LocationId))
{
AppDebugLog.Error("DataSender", $"Отправка заказа {localOrderId}: location_id не указан");
ToastNotification.ShowCustom(
"Не указан location_id. Заполните его после авторизации.",
Color.DarkOrange,
Color.White);
return false;
}
}
List<BuyerOrderRequest> orders; List<BuyerOrderRequest> orders;
try try
{ {
@ -141,6 +118,11 @@ namespace Электронная_Фармация.Classes
return false; return false;
} }
if (!EnsureOrdersHaveLocationId(orders))
{
return false;
}
bool ok = await SendOrderAsync(orders[0]); bool ok = await SendOrderAsync(orders[0]);
if (ok) if (ok)
{ {
@ -153,7 +135,7 @@ namespace Электронная_Фармация.Classes
public List<BuyerOrderRequest> FetchDataFromSql(string onlyOrderId) public List<BuyerOrderRequest> FetchDataFromSql(string onlyOrderId)
{ {
var orders = new Dictionary<string, BuyerOrderRequest>(); var orders = new Dictionary<string, BuyerOrderRequest>();
string locationId = AppConfig.LocationId; var locationCache = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString)) using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{ {
@ -161,7 +143,11 @@ namespace Электронная_Фармация.Classes
string query = @" string query = @"
SELECT SELECT
Orders.Id_Order, Orders.Id_Order,
Orders.OrderNumber,
Orders.ConsigneeName,
OrderItems.supplier_price_id, OrderItems.supplier_price_id,
OrderItems.DrugName,
OrderItems.es_code,
OrderItems.Zakaz, OrderItems.Zakaz,
Orders.Comment Orders.Comment
FROM Orders FROM Orders
@ -187,7 +173,15 @@ WHERE Orders.OrderState = 'НОВЫЙ'
while (reader.Read()) while (reader.Read())
{ {
string orderId = reader["Id_Order"].ToString().Trim(); string orderId = reader["Id_Order"].ToString().Trim();
string orderNumber = reader["OrderNumber"] == DBNull.Value
? string.Empty
: reader["OrderNumber"].ToString().Trim();
string consigneeName = reader["ConsigneeName"] == DBNull.Value
? string.Empty
: reader["ConsigneeName"].ToString().Trim();
string supplierPriceId = reader["supplier_price_id"].ToString().Trim(); string supplierPriceId = reader["supplier_price_id"].ToString().Trim();
string drugName = reader["DrugName"] == DBNull.Value ? null : reader["DrugName"].ToString().Trim();
string esCode = reader["es_code"] == DBNull.Value ? null : reader["es_code"].ToString().Trim();
decimal qty = ReadDecimal(reader["Zakaz"]); decimal qty = ReadDecimal(reader["Zakaz"]);
if (string.IsNullOrWhiteSpace(supplierPriceId)) if (string.IsNullOrWhiteSpace(supplierPriceId))
@ -202,9 +196,13 @@ WHERE Orders.OrderState = 'НОВЫЙ'
if (!orders.TryGetValue(orderId, out BuyerOrderRequest order)) if (!orders.TryGetValue(orderId, out BuyerOrderRequest order))
{ {
string locationId = ResolveLocationIdCached(consigneeName, locationCache);
order = new BuyerOrderRequest order = new BuyerOrderRequest
{ {
LocalOrderId = orderId, LocalOrderId = orderId,
GlobalSign = OrderNumberHelper.IsValidEx(orderNumber)
? OrderNumberHelper.Normalize(orderNumber)
: null,
LocationId = locationId, LocationId = locationId,
Comment = reader["Comment"] == DBNull.Value ? null : reader["Comment"].ToString() Comment = reader["Comment"] == DBNull.Value ? null : reader["Comment"].ToString()
}; };
@ -214,7 +212,9 @@ WHERE Orders.OrderState = 'НОВЫЙ'
order.Items.Add(new BuyerOrderItem order.Items.Add(new BuyerOrderItem
{ {
SupplierPriceId = supplierPriceId, SupplierPriceId = supplierPriceId,
Qty = qty Qty = qty,
ItemName = string.IsNullOrWhiteSpace(drugName) ? null : drugName,
ItemCode = string.IsNullOrWhiteSpace(esCode) ? null : esCode
}); });
} }
} }
@ -224,6 +224,65 @@ WHERE Orders.OrderState = 'НОВЫЙ'
return orders.Values.ToList(); return orders.Values.ToList();
} }
private static string ResolveLocationIdCached(string consigneeName, Dictionary<string, string> cache)
{
var key = consigneeName ?? string.Empty;
if (cache.TryGetValue(key, out var cached))
{
return cached;
}
var locationId = ConsigneeHelper.ResolveLocationId(consigneeName);
cache[key] = locationId ?? string.Empty;
return cache[key];
}
private bool EnsureOrdersHaveLocationId(List<BuyerOrderRequest> orders)
{
var missing = orders
.Where(o => string.IsNullOrWhiteSpace(o.LocationId))
.Select(o => o.LocalOrderId)
.Distinct()
.ToList();
if (missing.Count == 0)
{
return true;
}
if (string.IsNullOrWhiteSpace(AppConfig.LocationId))
{
if (!HF_LocationId.PromptAndSave(Form.ActiveForm)
|| string.IsNullOrWhiteSpace(AppConfig.LocationId))
{
AppDebugLog.Error("DataSender", "Отправка заказов: location_id не указан");
ToastNotification.ShowCustom(
"Не указан Location ID у аптеки заказа. Задайте его в «Грузополучатели» или в настройках.",
Color.DarkOrange,
Color.White);
return false;
}
ConsigneeHelper.ApplyLocationIdToEmptyConsignees(AppConfig.LocationId);
}
foreach (var order in orders.Where(o => string.IsNullOrWhiteSpace(o.LocationId)))
{
order.LocationId = AppConfig.LocationId;
}
if (orders.Any(o => string.IsNullOrWhiteSpace(o.LocationId)))
{
ToastNotification.ShowCustom(
"У части заказов нет Location ID. Укажите ID у грузополучателя.",
Color.DarkOrange,
Color.White);
return false;
}
return true;
}
private async Task<bool> SendOrderAsync(BuyerOrderRequest order) private async Task<bool> SendOrderAsync(BuyerOrderRequest order)
{ {
var options = new JsonSerializerOptions var options = new JsonSerializerOptions
@ -266,10 +325,31 @@ WHERE Orders.OrderState = 'НОВЫЙ'
return false; return false;
} }
MarkOrderSent(order.LocalOrderId); var createdOrder = JsonSerializer.Deserialize<BuyerOrderCreateResult>(responseString, options);
AppDebugLog.Info("DataSender", $"Заказ {order.LocalOrderId} отправлен"); if (createdOrder == null || string.IsNullOrWhiteSpace(createdOrder.OrderID))
{
throw new InvalidOperationException("Сервер не вернул order_id созданного заказа.");
}
// Старые серверы создавали Draft — дожимаем до Placed.
var remoteStatus = createdOrder.Status;
if (!string.Equals(remoteStatus, "Placed", StringComparison.OrdinalIgnoreCase))
{
remoteStatus = await PlaceOrderAsync(client, createdOrder.OrderID) ?? remoteStatus;
}
MarkOrderSent(order.LocalOrderId, createdOrder.OrderID, remoteStatus, createdOrder.GlobalSign);
AppDebugLog.Info("DataSender",
$"Заказ {order.LocalOrderId} отправлен, remote={createdOrder.OrderID}, status={remoteStatus}, sign={createdOrder.GlobalSign}");
return true; return true;
} }
catch (InvalidOperationException ex)
{
stopwatch.Stop();
AppDebugLog.Error("DataSender", $"Ошибка отправки заказа {order.LocalOrderId}", ex);
ToastNotification.ShowError(ex.Message);
return false;
}
catch (Exception ex) catch (Exception ex)
{ {
stopwatch.Stop(); stopwatch.Stop();
@ -280,7 +360,67 @@ WHERE Orders.OrderState = 'НОВЫЙ'
} }
} }
private static void MarkOrderSent(string localOrderId) private async Task<string> PlaceOrderAsync(HttpClient client, string remoteOrderId)
{
var placePath = $"{BuyerOrdersEndpoint}/{Uri.EscapeDataString(remoteOrderId)}/place";
var fullUrl = new Uri(new Uri(_baseUrl.TrimEnd('/') + "/"), placePath.TrimStart('/')).ToString();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
AppDebugLog.ApiRequest("POST", fullUrl, string.Empty, $"place={remoteOrderId}");
try
{
using (var content = new StringContent(string.Empty, Encoding.UTF8, "application/json"))
using (HttpResponseMessage response = await client.PostAsync(placePath, content))
{
string responseString = await response.Content.ReadAsStringAsync();
stopwatch.Stop();
AppDebugLog.ApiResponse("POST", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseString);
if (!response.IsSuccessStatusCode)
{
AppDebugLog.ApiHttpError(
"POST",
fullUrl,
(int)response.StatusCode,
responseString,
$"place={remoteOrderId}");
throw new InvalidOperationException(
$"Не удалось оформить заказ на сервере (HTTP {(int)response.StatusCode}). " +
"Заказ остался черновиком (Draft).");
}
try
{
using (var doc = JsonDocument.Parse(responseString))
{
if (doc.RootElement.TryGetProperty("status", out var statusEl))
{
return statusEl.GetString();
}
}
}
catch
{
// ignore parse — place succeeded
}
return "Placed";
}
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
stopwatch.Stop();
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw new InvalidOperationException(
$"Не удалось оформить заказ на сервере: {ex.Message}", ex);
}
}
private static void MarkOrderSent(string localOrderId, string remoteOrderId, string remoteStatus, string globalSign)
{ {
if (string.IsNullOrWhiteSpace(localOrderId)) if (string.IsNullOrWhiteSpace(localOrderId))
{ {
@ -291,9 +431,20 @@ WHERE Orders.OrderState = 'НОВЫЙ'
{ {
con.Open(); con.Open();
using (var cmd = new SQLiteCommand( using (var cmd = new SQLiteCommand(
"UPDATE [Orders] SET [OrderState] = 'ОТПРАВЛЕН' WHERE [Id_Order] = @id AND [OrderState] = 'НОВЫЙ'", @"UPDATE [Orders]
SET [OrderState] = @state,
[RemoteOrderId] = @remoteOrderId,
[OrderNumber] = CASE
WHEN @globalSign IS NOT NULL AND length(trim(@globalSign)) > 0 THEN @globalSign
ELSE [OrderNumber]
END
WHERE [Id_Order] = @id AND [OrderState] = 'НОВЫЙ'",
con)) con))
{ {
cmd.Parameters.AddWithValue("@state", "ОТПРАВЛЕН");
cmd.Parameters.AddWithValue("@remoteOrderId", remoteOrderId ?? string.Empty);
cmd.Parameters.AddWithValue("@globalSign",
string.IsNullOrWhiteSpace(globalSign) ? (object)DBNull.Value : globalSign.Trim());
cmd.Parameters.AddWithValue("@id", localOrderId); cmd.Parameters.AddWithValue("@id", localOrderId);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
@ -330,6 +481,9 @@ WHERE Orders.OrderState = 'НОВЫЙ'
[JsonPropertyName("location_id")] [JsonPropertyName("location_id")]
public string LocationId { get; set; } public string LocationId { get; set; }
[JsonPropertyName("global_sign")]
public string GlobalSign { get; set; }
[JsonPropertyName("comment")] [JsonPropertyName("comment")]
public string Comment { get; set; } public string Comment { get; set; }
@ -344,5 +498,23 @@ WHERE Orders.OrderState = 'НОВЫЙ'
[JsonPropertyName("qty")] [JsonPropertyName("qty")]
public decimal Qty { get; set; } public decimal Qty { get; set; }
[JsonPropertyName("item_name")]
public string ItemName { get; set; }
[JsonPropertyName("item_code")]
public string ItemCode { get; set; }
}
public sealed class BuyerOrderCreateResult
{
[JsonPropertyName("order_id")]
public string OrderID { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("global_sign")]
public string GlobalSign { get; set; }
} }
} }

View File

@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Электроннаяармация.Properties;
namespace Электроннаяармация.Classes
{
/// <summary>
/// Собирает доступные скидки покупателя/прайса/региона и вешает на позицию то, что пришло.
/// Отсутствующий источник = 0.
/// </summary>
public static class DiscountResolver
{
public static async Task<DiscountLookup> LoadAsync(ApiClient client, string loginHint)
{
var lookup = new DiscountLookup();
if (client == null)
{
return lookup;
}
var regionId = (Settings.Default.RegionId ?? string.Empty).Trim();
lookup.RegionId = regionId;
string buyerId = null;
try
{
var buyers = await client.GetBuyersAsync();
buyerId = ResolveBuyerId(buyers, loginHint);
lookup.BuyerId = buyerId;
AppDebugLog.Info(
"Discount",
string.IsNullOrWhiteSpace(buyerId)
? "buyer_id не найден — клиентская скидка будет 0"
: $"buyer_id={buyerId}");
}
catch (Exception ex)
{
AppDebugLog.Warning("Discount", $"Не удалось загрузить покупателей: {ex.Message}");
}
List<BuyerPriceListAssignmentDto> assignments = null;
if (!string.IsNullOrWhiteSpace(buyerId))
{
try
{
assignments = await client.GetBuyerPriceListsAsync(buyerId);
AppDebugLog.Info("Discount", $"Назначений прайсов покупателя: {assignments?.Count ?? 0}");
}
catch (Exception ex)
{
AppDebugLog.Warning("Discount", $"Не удалось загрузить buyer price-lists: {ex.Message}");
}
}
List<PriceListDto> priceLists = null;
try
{
priceLists = await client.GetPriceListsAsync();
AppDebugLog.Info("Discount", $"Прайс-листов: {priceLists?.Count ?? 0}");
}
catch (Exception ex)
{
AppDebugLog.Warning("Discount", $"Не удалось загрузить price-lists: {ex.Message}");
}
EnrichAssignmentsFromPriceLists(assignments, priceLists);
if (assignments != null)
{
foreach (var a in assignments)
{
if (string.IsNullOrWhiteSpace(a?.PriceListId))
{
continue;
}
var plId = a.PriceListId.Trim();
lookup.ByPriceListId[plId] = new ItemDiscountParts
{
PriceListId = plId,
ClientDiscountPct = PositiveOrZero(a.MarkupPct),
PriceListDiscountPct = PositiveOrZero(a.DefaultMarkupPct),
RegionDiscountPct = 0m
};
IndexSupplierKeys(lookup, a.SupplierId, a.SupplierName, plId);
}
}
if (priceLists != null)
{
foreach (var pl in priceLists)
{
var plId = pl?.ResolvedId;
if (string.IsNullOrWhiteSpace(plId))
{
continue;
}
if (!lookup.ByPriceListId.TryGetValue(plId, out var parts))
{
parts = new ItemDiscountParts { PriceListId = plId };
lookup.ByPriceListId[plId] = parts;
}
if (parts.PriceListDiscountPct <= 0m && pl.DefaultMarkupPct.HasValue && pl.DefaultMarkupPct.Value > 0m)
{
parts.PriceListDiscountPct = pl.DefaultMarkupPct.Value;
}
IndexSupplierKeys(lookup, pl.SupplierId, pl.SupplierName, plId);
}
}
foreach (var plId in lookup.ByPriceListId.Keys.ToList())
{
try
{
var regions = await client.GetPriceListRegionsAsync(plId);
var regionPct = ResolveRegionDiscount(regions, regionId);
lookup.ByPriceListId[plId].RegionDiscountPct = regionPct;
}
catch (Exception ex)
{
AppDebugLog.Warning("Discount", $"Регионы для прайса {plId}: {ex.Message}");
}
}
AppDebugLog.Info(
"Discount",
$"Готово: прайсов={lookup.ByPriceListId.Count}, привязок по поставщику={lookup.BySupplierKey.Count}, region={regionId}");
return lookup;
}
public static ItemDiscountParts ResolveForItem(DiscountLookup lookup, PriceSummaryItem item)
{
if (lookup == null || item == null)
{
return new ItemDiscountParts();
}
var priceListId = (item.PriceListId ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(priceListId) &&
lookup.ByPriceListId.TryGetValue(priceListId, out var byId))
{
return Clone(byId);
}
var supplierId = (item.SupplierId ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(supplierId) &&
lookup.BySupplierKey.TryGetValue(NormalizeKey(supplierId), out var plFromSupplier) &&
lookup.ByPriceListId.TryGetValue(plFromSupplier, out var bySupplier))
{
return Clone(bySupplier);
}
var supplierName = (item.SupplierName ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(supplierName) &&
lookup.BySupplierKey.TryGetValue(NormalizeKey(supplierName), out var plFromName) &&
lookup.ByPriceListId.TryGetValue(plFromName, out var byName))
{
return Clone(byName);
}
return new ItemDiscountParts();
}
private static string ResolveBuyerId(List<BuyerDto> buyers, string loginHint)
{
if (buyers == null || buyers.Count == 0)
{
return null;
}
var login = (loginHint ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(login))
{
var matched = buyers.FirstOrDefault(b =>
string.Equals((b.Username ?? string.Empty).Trim(), login, StringComparison.OrdinalIgnoreCase) ||
string.Equals((b.Login ?? string.Empty).Trim(), login, StringComparison.OrdinalIgnoreCase) ||
string.Equals((b.Name ?? string.Empty).Trim(), login, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(matched?.ResolvedId))
{
return matched.ResolvedId;
}
}
if (buyers.Count == 1 && !string.IsNullOrWhiteSpace(buyers[0].ResolvedId))
{
return buyers[0].ResolvedId;
}
return buyers
.Select(b => b.ResolvedId)
.FirstOrDefault(id => !string.IsNullOrWhiteSpace(id));
}
private static void EnrichAssignmentsFromPriceLists(
List<BuyerPriceListAssignmentDto> assignments,
List<PriceListDto> priceLists)
{
if (assignments == null || priceLists == null || priceLists.Count == 0)
{
return;
}
var byId = priceLists
.Where(p => !string.IsNullOrWhiteSpace(p?.ResolvedId))
.GroupBy(p => p.ResolvedId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
foreach (var a in assignments)
{
if (a == null || string.IsNullOrWhiteSpace(a.PriceListId))
{
continue;
}
if (!byId.TryGetValue(a.PriceListId.Trim(), out var pl))
{
continue;
}
if (string.IsNullOrWhiteSpace(a.SupplierId))
{
a.SupplierId = pl.SupplierId;
}
if (string.IsNullOrWhiteSpace(a.SupplierName))
{
a.SupplierName = pl.SupplierName;
}
if ((!a.DefaultMarkupPct.HasValue || a.DefaultMarkupPct.Value <= 0m) &&
pl.DefaultMarkupPct.HasValue)
{
a.DefaultMarkupPct = pl.DefaultMarkupPct;
}
}
}
private static decimal ResolveRegionDiscount(List<PriceListRegionDto> regions, string regionId)
{
if (regions == null || regions.Count == 0)
{
return 0m;
}
PriceListRegionDto match = null;
if (!string.IsNullOrWhiteSpace(regionId))
{
match = regions.FirstOrDefault(r =>
string.Equals((r.RegionId ?? string.Empty).Trim(), regionId, StringComparison.OrdinalIgnoreCase) &&
(r.InWork == null || r.InWork.Value));
}
if (match == null)
{
match = regions.FirstOrDefault(r => r.InWork == true) ?? regions.FirstOrDefault();
}
return PositiveOrZero(match?.MarkupPct);
}
private static void IndexSupplierKeys(DiscountLookup lookup, string supplierId, string supplierName, string priceListId)
{
if (lookup == null || string.IsNullOrWhiteSpace(priceListId))
{
return;
}
if (!string.IsNullOrWhiteSpace(supplierId))
{
lookup.BySupplierKey[NormalizeKey(supplierId)] = priceListId;
}
if (!string.IsNullOrWhiteSpace(supplierName))
{
lookup.BySupplierKey[NormalizeKey(supplierName)] = priceListId;
}
}
private static ItemDiscountParts Clone(ItemDiscountParts source)
{
if (source == null)
{
return new ItemDiscountParts();
}
return new ItemDiscountParts
{
PriceListId = source.PriceListId,
PriceListDiscountPct = source.PriceListDiscountPct,
RegionDiscountPct = source.RegionDiscountPct,
ClientDiscountPct = source.ClientDiscountPct
};
}
private static decimal PositiveOrZero(decimal? value)
{
return value.HasValue && value.Value > 0m ? value.Value : 0m;
}
private static string NormalizeKey(string value)
{
return (value ?? string.Empty).Trim().ToLowerInvariant();
}
}
public sealed class DiscountLookup
{
public string BuyerId { get; set; }
public string RegionId { get; set; }
public Dictionary<string, ItemDiscountParts> ByPriceListId { get; } =
new Dictionary<string, ItemDiscountParts>(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string> BySupplierKey { get; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}

View File

@ -0,0 +1,194 @@
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Windows.Forms;
using System.Data.SQLite;
namespace Электроннаяармация.Classes
{
/// <summary>
/// Exports a local invoice (header + items) to a CSV file in the configured folder.
/// </summary>
public sealed class InvoiceExportService
{
public string ExportInvoice(string invoiceId, IWin32Window owner = null)
{
if (string.IsNullOrWhiteSpace(invoiceId))
{
throw new ArgumentException("Не указана накладная для экспорта.", nameof(invoiceId));
}
var folder = ResolveExportFolder(owner);
if (string.IsNullOrWhiteSpace(folder))
{
throw new InvalidOperationException("Не указана папка для экспорта накладных.");
}
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
InvoiceHeader header;
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
con.Open();
header = LoadHeader(con, invoiceId);
var itemsSql = @"
select
ifnull([GoodCode], '') as [GoodCode],
ifnull([Good], '') as [Good],
ifnull([ProducerName], '') as [ProducerName],
ifnull([GoodsGount], 0) as [Qty],
ifnull([PriceSupplierWithNDS], ifnull([PriceSupplierWithoutNDS], 0)) as [Price],
ifnull([SumWithNDS], ifnull([SumWithoutNDS], 0)) as [Sum],
ifnull([BestBefore], '') as [BestBefore],
ifnull([Serial], '') as [Serial]
from [InvoiceItem]
where [idInvoice] = @invoiceId
order by [Good];";
var fileName = BuildFileName(header);
var filePath = Path.Combine(folder, fileName);
var sb = new StringBuilder();
sb.AppendLine("Номер накладной;Дата;Поставщик;Грузополучатель;Номер заказа;Сумма");
sb.AppendLine(string.Join(";",
Csv(header.Number),
Csv(header.Date),
Csv(header.Supplier),
Csv(header.Consignee),
Csv(header.OrderNumber),
Csv(header.Sum)));
sb.AppendLine();
sb.AppendLine("Код товара;Товар;Производитель;Количество;Цена;Сумма;Срок годности;Серия");
using (var cmd = new SQLiteCommand(itemsSql, con))
{
cmd.Parameters.AddWithValue("@invoiceId", invoiceId);
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
sb.AppendLine(string.Join(";",
Csv(reader["GoodCode"]),
Csv(reader["Good"]),
Csv(reader["ProducerName"]),
Csv(reader["Qty"]),
Csv(reader["Price"]),
Csv(reader["Sum"]),
Csv(reader["BestBefore"]),
Csv(reader["Serial"])));
}
}
}
// UTF-8 BOM — Excel корректно открывает кириллицу.
File.WriteAllText(filePath, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
AppDebugLog.Info("InvoiceExport", $"Накладная {header.Number} экспортирована: {filePath}");
return filePath;
}
}
public static string ResolveExportFolder(IWin32Window owner = null)
{
var folder = AppConfig.InvoiceExportPath;
if (!string.IsNullOrWhiteSpace(folder) && Directory.Exists(folder))
{
return folder;
}
using (var dialog = new FolderBrowserDialog())
{
dialog.Description = "Выберите папку для экспорта накладных";
dialog.ShowNewFolderButton = true;
if (!string.IsNullOrWhiteSpace(folder) && Directory.Exists(folder))
{
dialog.SelectedPath = folder;
}
if (dialog.ShowDialog(owner) != DialogResult.OK || string.IsNullOrWhiteSpace(dialog.SelectedPath))
{
return string.Empty;
}
AppConfig.SetInvoiceExportPath(dialog.SelectedPath);
return dialog.SelectedPath;
}
}
private static InvoiceHeader LoadHeader(SQLiteConnection con, string invoiceId)
{
using (var cmd = new SQLiteCommand(@"
select
ifnull([InvoiceNumber], '') as [Number],
ifnull([InvoiceDate], '') as [Date],
ifnull([SupplierName], '') as [Supplier],
ifnull([ConsigneesName], '') as [Consignee],
ifnull([SourceOrderNumber], '') as [OrderNumber],
ifnull([InvoiceSum], ifnull([SumWithNDS], 0)) as [Sum]
from [Invoice]
where [idInvoice] = @invoiceId
limit 1;", con))
{
cmd.Parameters.AddWithValue("@invoiceId", invoiceId);
using (var reader = cmd.ExecuteReader())
{
if (!reader.Read())
{
throw new InvalidOperationException("Накладная не найдена.");
}
return new InvoiceHeader
{
Number = reader["Number"]?.ToString() ?? string.Empty,
Date = reader["Date"]?.ToString() ?? string.Empty,
Supplier = reader["Supplier"]?.ToString() ?? string.Empty,
Consignee = reader["Consignee"]?.ToString() ?? string.Empty,
OrderNumber = reader["OrderNumber"]?.ToString() ?? string.Empty,
Sum = reader["Sum"]?.ToString() ?? "0"
};
}
}
}
private static string BuildFileName(InvoiceHeader header)
{
var number = SanitizeFileName(string.IsNullOrWhiteSpace(header.Number) ? "invoice" : header.Number);
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
return $"Накладная_{number}_{stamp}.csv";
}
private static string SanitizeFileName(string value)
{
foreach (var c in Path.GetInvalidFileNameChars())
{
value = value.Replace(c, '_');
}
return value.Trim();
}
private static string Csv(object value)
{
var text = value?.ToString() ?? string.Empty;
if (text.IndexOfAny(new[] { ';', '"', '\r', '\n' }) >= 0)
{
return "\"" + text.Replace("\"", "\"\"") + "\"";
}
return text;
}
private sealed class InvoiceHeader
{
public string Number { get; set; }
public string Date { get; set; }
public string Supplier { get; set; }
public string Consignee { get; set; }
public string OrderNumber { get; set; }
public string Sum { get; set; }
}
}
}

View File

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data.SQLite; using System.Data.SQLite;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Электроннаяармация.Properties; using Электроннаяармация.Properties;
@ -26,7 +28,29 @@ namespace Электронная_Фармация.Classes
var client = new ApiClient(); var client = new ApiClient();
client.SetToken(token); client.SetToken(token);
AppDebugLog.Info("InvoiceSync", $"Старт синхронизации накладных. Сервер={client.BaseUrl}"); AppDebugLog.Info("InvoiceSync", $"Старт синхронизации накладных. Сервер={client.BaseUrl}");
var items = await client.GetInvoicesAsync(); var orders = await client.GetBuyerOrdersAsync(limit: 200);
var items = new List<InvoiceApiItem>();
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
con.Open();
foreach (var order in orders)
{
if (string.Equals(order.Status, "Cancelled", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var detail = await client.GetBuyerOrderByIdAsync(order.OrderID);
if (detail == null)
{
continue;
}
detail.LocalOrderNumber = TryGetLocalOrderNumber(con, detail.OrderID);
items.Add(InvoiceApiItem.FromBuyerOrder(detail));
}
}
var saved = SaveInvoices(items); var saved = SaveInvoices(items);
AppDebugLog.Info("InvoiceSync", $"Накладные сохранены локально: {saved} шт."); AppDebugLog.Info("InvoiceSync", $"Накладные сохранены локально: {saved} шт.");
return saved; return saved;
@ -40,30 +64,32 @@ namespace Электронная_Фармация.Classes
private static int SaveInvoices(IReadOnlyList<InvoiceApiItem> items) private static int SaveInvoices(IReadOnlyList<InvoiceApiItem> items)
{ {
if (items.Count == 0)
{
return 0;
}
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString)) using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{ {
con.Open(); con.Open();
using (var tx = con.BeginTransaction()) using (var tx = con.BeginTransaction())
{ {
using (var cleanupItems = new SQLiteCommand(@"
DELETE FROM InvoiceItem
WHERE idInvoice IN (
SELECT idInvoice FROM Invoice WHERE ifnull(IsRequestedInvoice, 0) = 0
);", con, tx))
{
cleanupItems.ExecuteNonQuery();
}
using (var cleanupInvoices = new SQLiteCommand(
"DELETE FROM Invoice WHERE ifnull(IsRequestedInvoice, 0) = 0;",
con,
tx))
{
cleanupInvoices.ExecuteNonQuery();
}
foreach (var item in items) foreach (var item in items)
{ {
using (var cmd = new SQLiteCommand(@" var invoiceId = UpsertInvoice(con, tx, item);
INSERT OR REPLACE INTO Invoice (InvoiceNumber, invoiceDate, SupplierName, ConsigneesName, InvoiceSum, RefuseSum) SaveInvoiceItems(con, tx, invoiceId, item);
VALUES (@number, @date, @supplier, @consignee, @sum, @refuseSum)", con, tx))
{
cmd.Parameters.AddWithValue("@number", item.InvoiceNumber ?? string.Empty);
cmd.Parameters.AddWithValue("@date", item.InvoiceDate ?? string.Empty);
cmd.Parameters.AddWithValue("@supplier", item.SupplierName ?? string.Empty);
cmd.Parameters.AddWithValue("@consignee", item.ConsigneeName ?? string.Empty);
cmd.Parameters.AddWithValue("@sum", item.InvoiceSum ?? string.Empty);
cmd.Parameters.AddWithValue("@refuseSum", item.RefuseSum ?? string.Empty);
cmd.ExecuteNonQuery();
}
} }
tx.Commit(); tx.Commit();
@ -72,5 +98,192 @@ VALUES (@number, @date, @supplier, @consignee, @sum, @refuseSum)", con, tx))
return items.Count; return items.Count;
} }
private static int UpsertInvoice(SQLiteConnection con, SQLiteTransaction tx, InvoiceApiItem item)
{
using (var findCmd = new SQLiteCommand(@"
SELECT [idInvoice]
FROM [Invoice]
WHERE cast([SourceOrderId] as text) = @sourceOrderId
LIMIT 1;", con, tx))
{
findCmd.Parameters.AddWithValue("@sourceOrderId", item.SourceOrderId ?? string.Empty);
var existing = findCmd.ExecuteScalar();
if (existing != null && existing != DBNull.Value)
{
var invoiceId = Convert.ToInt32(existing, CultureInfo.InvariantCulture);
using (var updateCmd = new SQLiteCommand(@"
UPDATE [Invoice]
SET [InvoiceNumber] = @number,
[InvoiceDate] = @date,
[SupplierName] = @supplier,
[ConsigneesName] = @consignee,
[InvoiceSum] = @sum,
[RefuseSum] = @refuseSum,
[SourceOrderNumber] = @sourceOrderNumber,
[IsRequestedInvoice] = 0
WHERE [idInvoice] = @invoiceId;", con, tx))
{
updateCmd.Parameters.AddWithValue("@number", item.InvoiceNumber ?? string.Empty);
updateCmd.Parameters.AddWithValue("@date", item.InvoiceDate ?? string.Empty);
updateCmd.Parameters.AddWithValue("@supplier", item.SupplierName ?? string.Empty);
updateCmd.Parameters.AddWithValue("@consignee", item.ConsigneeName ?? string.Empty);
updateCmd.Parameters.AddWithValue("@sum", item.InvoiceSum ?? "0");
updateCmd.Parameters.AddWithValue("@refuseSum", item.RefuseSum ?? "0");
updateCmd.Parameters.AddWithValue("@sourceOrderNumber", item.InvoiceNumber ?? string.Empty);
updateCmd.Parameters.AddWithValue("@invoiceId", invoiceId);
updateCmd.ExecuteNonQuery();
}
using (var deleteItems = new SQLiteCommand("DELETE FROM [InvoiceItem] WHERE [idInvoice] = @invoiceId;", con, tx))
{
deleteItems.Parameters.AddWithValue("@invoiceId", invoiceId);
deleteItems.ExecuteNonQuery();
}
return invoiceId;
}
}
using (var insertCmd = new SQLiteCommand(@"
INSERT INTO [Invoice]
(
[InvoiceNumber],
[InvoiceDate],
[CodeSupplier],
[CodeConsignees],
[SumWithoutNDS],
[SumWithNDS],
[SumNDS],
[SupplierName],
[ConsigneesName],
[InvoiceSum],
[RefuseSum],
[SourceOrderId],
[SourceOrderNumber],
[IsRequestedInvoice]
)
VALUES
(
@number,
@date,
0,
0,
@sum,
@sum,
0,
@supplier,
@consignee,
@sum,
@refuseSum,
@sourceOrderId,
@sourceOrderNumber,
0
);
SELECT last_insert_rowid();", con, tx))
{
insertCmd.Parameters.AddWithValue("@number", item.InvoiceNumber ?? string.Empty);
insertCmd.Parameters.AddWithValue("@date", item.InvoiceDate ?? string.Empty);
insertCmd.Parameters.AddWithValue("@sum", item.InvoiceSum ?? "0");
insertCmd.Parameters.AddWithValue("@supplier", item.SupplierName ?? string.Empty);
insertCmd.Parameters.AddWithValue("@consignee", item.ConsigneeName ?? string.Empty);
insertCmd.Parameters.AddWithValue("@refuseSum", item.RefuseSum ?? "0");
insertCmd.Parameters.AddWithValue("@sourceOrderId", item.SourceOrderId ?? string.Empty);
insertCmd.Parameters.AddWithValue("@sourceOrderNumber", item.InvoiceNumber ?? string.Empty);
return Convert.ToInt32(insertCmd.ExecuteScalar(), CultureInfo.InvariantCulture);
}
}
private static void SaveInvoiceItems(SQLiteConnection con, SQLiteTransaction tx, int invoiceId, InvoiceApiItem item)
{
foreach (var invoiceItem in item.Items ?? Enumerable.Empty<InvoiceApiOrderItem>())
{
using (var cmd = new SQLiteCommand(@"
INSERT INTO [InvoiceItem]
(
[idGood],
[GoodCode],
[Good],
[ProducerName],
[CountryName],
[idInvoice],
[GoodsGount],
[PriceSupplierWithoutNDS],
[PriceSupplierWithNDS],
[PriceProducerWithoutNDS],
[PriceProducerWithNDS],
[NDS],
[JNVLS],
[PriceReestr],
[SumWithoutNDS],
[SumWithNDS],
[SumNDS],
[Serial],
[BestBefore],
[Sertificate],
[Marked],
[GTIN]
)
VALUES
(
null,
@goodCode,
@good,
'',
'',
@invoiceId,
@qty,
@price,
@price,
null,
null,
0,
0,
null,
@sum,
@sum,
0,
'',
'',
'',
0,
''
)", con, tx))
{
cmd.Parameters.AddWithValue("@goodCode", ParseGoodCode(invoiceItem.GoodCode));
cmd.Parameters.AddWithValue("@good", invoiceItem.GoodName ?? string.Empty);
cmd.Parameters.AddWithValue("@invoiceId", invoiceId);
cmd.Parameters.AddWithValue("@qty", invoiceItem.Qty);
cmd.Parameters.AddWithValue("@price", invoiceItem.Price);
cmd.Parameters.AddWithValue("@sum", invoiceItem.Total);
cmd.ExecuteNonQuery();
}
}
}
private static int ParseGoodCode(string value)
{
int parsed;
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed) ? parsed : 0;
}
private static string TryGetLocalOrderNumber(SQLiteConnection con, string remoteOrderId)
{
if (string.IsNullOrWhiteSpace(remoteOrderId))
{
return null;
}
using (var cmd = new SQLiteCommand(@"
SELECT [OrderNumber]
FROM [Orders]
WHERE [RemoteOrderId] = @remoteOrderId
LIMIT 1;", con))
{
cmd.Parameters.AddWithValue("@remoteOrderId", remoteOrderId);
var value = cmd.ExecuteScalar();
return value == null || value == DBNull.Value ? null : value.ToString();
}
}
} }
} }

View File

@ -0,0 +1,98 @@
using System;
namespace Электроннаяармация.Classes
{
public static class MarkupHelper
{
public const decimal FallbackMarkupPercent = 0m;
/// <summary>
/// Сумма доступных скидок. null / отрицательные значения считаются 0.
/// </summary>
public static decimal SumDiscountPercents(params decimal?[] parts)
{
decimal total = 0m;
if (parts == null)
{
return total;
}
foreach (var part in parts)
{
if (part.HasValue && part.Value > 0m)
{
total += part.Value;
}
}
return total;
}
public static decimal ResolveMarkupPercent(decimal? itemMarkup, decimal? clientMarkup, decimal fallbackMarkup)
{
var total = SumDiscountPercents(itemMarkup, clientMarkup);
if (total > 0m)
{
return total;
}
return fallbackMarkup > 0m ? fallbackMarkup : FallbackMarkupPercent;
}
public static decimal ApplyMarkup(decimal basePrice, decimal markupPercent)
{
return ApplyDiscount(basePrice, markupPercent);
}
/// <summary>
/// итоговая = base * (1 - discount/100), минимум 0. Без округления.
/// </summary>
public static decimal ApplyDiscount(decimal basePrice, decimal discountPercent)
{
var effectiveDiscount = Math.Max(0m, discountPercent);
var result = basePrice * (1m - effectiveDiscount / 100m);
return result < 0m ? 0m : result;
}
public static bool TryParseMarkup(object value, out decimal markupPercent)
{
markupPercent = 0m;
if (value == null || value == DBNull.Value)
{
return false;
}
if (value is decimal decimalValue)
{
markupPercent = decimalValue;
return true;
}
if (value is double doubleValue)
{
markupPercent = (decimal)doubleValue;
return true;
}
if (value is float floatValue)
{
markupPercent = (decimal)floatValue;
return true;
}
if (value is int intValue)
{
markupPercent = intValue;
return true;
}
var text = value.ToString()?.Trim().Replace(',', '.');
return !string.IsNullOrWhiteSpace(text) &&
decimal.TryParse(
text,
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture,
out markupPercent);
}
}
}

View File

@ -0,0 +1,157 @@
using System;
using System.Data.SQLite;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Электроннаяармация.Properties;
namespace Электроннаяармация.Classes
{
/// <summary>
/// Глобальные номера заказов вида EX-0000001.
/// Источник истины — сервер (никогда не повторяются между аптеками).
/// Offline fallback — локальный счётчик EX- (потом сервер может заменить при отправке).
/// </summary>
public static class OrderNumberHelper
{
private static readonly Regex ExPattern = new Regex(@"^EX-(\d{1,7})$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Выделяет номер для нового локального заказа.
/// Сначала пробует сервер /api/buyer/global-sign/next, иначе локальный EX-#######.
/// </summary>
public static string AllocateForNewOrder()
{
try
{
var fromServer = AllocateFromServerAsync().GetAwaiter().GetResult();
if (!string.IsNullOrWhiteSpace(fromServer))
{
return fromServer;
}
}
catch (Exception ex)
{
AppDebugLog.Info("OrderNumber", $"Сервер недоступен для GlobalSign: {ex.Message}");
}
return AllocateLocalFallback();
}
public static async Task<string> AllocateFromServerAsync()
{
var token = Settings.Default.stringToken;
var baseUrl = AppConfig.ApiBaseUrl;
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(baseUrl))
{
return null;
}
using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) })
{
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Trim());
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using (var response = await client.PostAsync("api/buyer/global-sign/next", new StringContent(string.Empty)).ConfigureAwait(false))
{
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
AppDebugLog.Info("OrderNumber", $"global-sign/next HTTP {(int)response.StatusCode}: {body}");
return null;
}
using (var doc = JsonDocument.Parse(body))
{
if (doc.RootElement.TryGetProperty("global_sign", out var el))
{
var sign = el.GetString();
if (IsValidEx(sign))
{
return Normalize(sign);
}
}
}
}
}
return null;
}
public static string AllocateLocalFallback()
{
long max = 0;
try
{
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
con.Open();
using (var cmd = new SQLiteCommand(
"SELECT OrderNumber FROM Orders WHERE OrderNumber LIKE 'EX-%'", con))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var n = ParseExNumber(reader[0]?.ToString());
if (n > max)
{
max = n;
}
}
}
}
}
catch (Exception ex)
{
AppDebugLog.Info("OrderNumber", $"Локальный max EX-: {ex.Message}");
}
return FormatEx(max + 1);
}
public static bool IsValidEx(string value)
{
return ParseExNumber(value) > 0;
}
public static string Normalize(string value)
{
var n = ParseExNumber(value);
return n > 0 ? FormatEx(n) : value?.Trim();
}
public static string FormatEx(long n)
{
if (n < 1)
{
n = 1;
}
if (n > 9999999)
{
return "EX-" + n.ToString();
}
return "EX-" + n.ToString("D7");
}
public static long ParseExNumber(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return 0;
}
var m = ExPattern.Match(value.Trim());
if (!m.Success)
{
return 0;
}
return long.TryParse(m.Groups[1].Value, out var n) ? n : 0;
}
}
}

View File

@ -16,6 +16,9 @@ namespace Электронная_Фармация.Classes
[JsonPropertyName("supplier_price_id")] [JsonPropertyName("supplier_price_id")]
public string SupplierPriceID { get; set; } public string SupplierPriceID { get; set; }
[JsonPropertyName("price_list_id")]
public string PriceListId { get; set; }
[JsonPropertyName("guid_es")] [JsonPropertyName("guid_es")]
public string GuidEs { get; set; } public string GuidEs { get; set; }
@ -56,11 +59,17 @@ namespace Электронная_Фармация.Classes
public string Barcode { get; set; } public string Barcode { get; set; }
/// <summary> /// <summary>
/// Финальная цена с учетом наценки (рассчитана на сервере) /// Базовая цена поставщика (без розничной наценки клиента).
/// </summary> /// </summary>
[JsonPropertyName("price")] [JsonPropertyName("price")]
public decimal? Price { get; set; } public decimal? Price { get; set; }
/// <summary>
/// Процент наценки для позиции. Если не задан — используется markup_percent из ответа прайса.
/// </summary>
[JsonPropertyName("markup_percent")]
public decimal? MarkupPercent { get; set; }
/// <summary> /// <summary>
/// Количество товара на складе /// Количество товара на складе
/// </summary> /// </summary>

View File

@ -9,6 +9,12 @@ namespace Электронная_Фармация.Classes
{ {
public class PriceSummaryResponse public class PriceSummaryResponse
{ {
/// <summary>
/// Процент наценки для текущего клиента (покупателя), приходит вместе с прайсом.
/// </summary>
[JsonPropertyName("markup_percent")]
public decimal? MarkupPercent { get; set; }
[JsonPropertyName("summary")] [JsonPropertyName("summary")]
public List<PriceSummaryItem> Summary { get; set; } public List<PriceSummaryItem> Summary { get; set; }
} }

View File

@ -0,0 +1,153 @@
using System;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
namespace Электроннаяармация.Classes
{
/// <summary>
/// Лёгкая подсветка SQL в RichTextBox (ключевые слова, строки, комментарии, числа, параметры).
/// </summary>
public static class SqlSyntaxHighlighter
{
private static readonly Regex TokenRegex = new Regex(
@"(--[^\r\n]*)|(/\*[\s\S]*?\*/)|('(?:''|[^'])*')|(""(?:""""|[^""])*"")|(\[[^\]]*\])|(@[A-Za-z_][A-Za-z0-9_]*)|(\{\{[A-Za-z_][A-Za-z0-9_]*\}\})|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_][A-Za-z0-9_]*\b)",
RegexOptions.Compiled);
private static readonly System.Collections.Generic.HashSet<string> Keywords =
new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "IS", "NULL", "AS",
"JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "ON", "USING",
"GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", "DISTINCT", "ALL",
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "REPLACE",
"CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", "IF", "EXISTS",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "DEFAULT", "CHECK",
"BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", "PRAGMA", "EXPLAIN", "WITH",
"CASE", "WHEN", "THEN", "ELSE", "END", "BETWEEN", "LIKE", "GLOB", "ESCAPE",
"UNION", "INTERSECT", "EXCEPT", "CAST", "COLLATE", "ASC", "DESC",
"COUNT", "SUM", "AVG", "MIN", "MAX", "IFNULL", "COALESCE", "NULLIF",
"LENGTH", "TRIM", "UPPER", "LOWER", "SUBSTR", "REPLACE", "ROUND", "ABS",
"DATETIME", "DATE", "TIME", "STRFTIME", "ADD", "COLUMN", "RENAME", "TO"
};
private const int WM_SETREDRAW = 0x000B;
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
public static void Apply(RichTextBox box, bool darkTheme)
{
if (box == null || box.IsDisposed)
{
return;
}
string text = box.Text ?? string.Empty;
int selStart = box.SelectionStart;
int selLength = box.SelectionLength;
int firstVisibleChar = box.GetCharIndexFromPosition(new Point(1, 1));
Color defaultFg = darkTheme ? Color.FromArgb(220, 220, 220) : Color.FromArgb(30, 30, 30);
Color keyword = darkTheme ? Color.FromArgb(86, 156, 214) : Color.FromArgb(0, 0, 180);
Color str = darkTheme ? Color.FromArgb(206, 145, 120) : Color.FromArgb(163, 21, 21);
Color comment = darkTheme ? Color.FromArgb(106, 153, 85) : Color.FromArgb(0, 128, 0);
Color number = darkTheme ? Color.FromArgb(181, 206, 168) : Color.FromArgb(9, 134, 88);
Color param = darkTheme ? Color.FromArgb(197, 134, 192) : Color.FromArgb(128, 0, 128);
Color ident = darkTheme ? Color.FromArgb(156, 220, 254) : Color.FromArgb(0, 100, 120);
SuspendRedraw(box);
try
{
box.SelectAll();
box.SelectionColor = defaultFg;
box.SelectionFont = box.Font;
foreach (Match match in TokenRegex.Matches(text))
{
Color color = defaultFg;
bool bold = false;
if (match.Groups[1].Success || match.Groups[2].Success)
{
color = comment;
}
else if (match.Groups[3].Success || match.Groups[4].Success || match.Groups[5].Success)
{
color = str;
}
else if (match.Groups[6].Success || match.Groups[7].Success)
{
color = param;
bold = true;
}
else if (match.Groups[8].Success)
{
color = number;
}
else if (match.Groups[9].Success)
{
var word = match.Groups[9].Value;
if (Keywords.Contains(word))
{
color = keyword;
bold = true;
}
else
{
color = ident;
}
}
if (color == defaultFg && !bold)
{
continue;
}
box.Select(match.Index, match.Length);
box.SelectionColor = color;
if (bold)
{
box.SelectionFont = new Font(box.Font, FontStyle.Bold);
}
else
{
box.SelectionFont = box.Font;
}
}
}
finally
{
box.Select(selStart, selLength);
try
{
box.Select(firstVisibleChar, 0);
var pt = box.GetPositionFromCharIndex(firstVisibleChar);
if (pt.Y < 0)
{
// keep best-effort scroll
}
}
catch
{
// ignore scroll restore issues
}
box.Select(selStart, selLength);
ResumeRedraw(box);
box.Invalidate();
}
}
private static void SuspendRedraw(Control control)
{
SendMessage(control.Handle, WM_SETREDRAW, IntPtr.Zero, IntPtr.Zero);
}
private static void ResumeRedraw(Control control)
{
SendMessage(control.Handle, WM_SETREDRAW, new IntPtr(1), IntPtr.Zero);
}
}
}

View File

@ -125,15 +125,36 @@ namespace Электронная_Фармация.Classes
} }
else if (control is Panel || control is UserControl || control is TableLayoutPanel) else if (control is Panel || control is UserControl || control is TableLayoutPanel)
{ {
if (control.Tag as string == "keep-bg") var tag = control.Tag as string;
if (tag == "keep-bg")
{ {
// preserve custom background // preserve custom background
} }
else if (control.Tag as string == "chrome-child") else if (tag == "chrome-child")
{ {
control.BackColor = theme.BackgroundPrimary; control.BackColor = theme.BackgroundPrimary;
control.ForeColor = theme.TextPrimary; control.ForeColor = theme.TextPrimary;
} }
else if (tag == "toolbar")
{
control.BackColor = theme.Surface;
control.ForeColor = theme.TextPrimary;
}
else if (tag == "status-footer")
{
control.BackColor = theme.CartHeaderBackground;
control.ForeColor = theme.TextPrimary;
}
else if (tag == "surface-bar")
{
control.BackColor = theme.SectionHeaderBackground;
control.ForeColor = theme.TextPrimary;
}
else if (tag == "markup-bar")
{
control.BackColor = theme.AccentPrimarySoft;
control.ForeColor = theme.TextPrimary;
}
else else
{ {
control.BackColor = theme.BackgroundSecondary; control.BackColor = theme.BackgroundSecondary;

View File

@ -0,0 +1,302 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Электроннаяармация.Classes
{
public sealed class UpdateCheckResult
{
public bool CheckedOk { get; set; }
public bool UpdateRequired { get; set; }
public string LocalVersion { get; set; }
public string LatestVersion { get; set; }
public string ReleaseUrl { get; set; }
public string DownloadUrl { get; set; }
public string ErrorMessage { get; set; }
}
/// <summary>
/// Checks Gitea Releases API for a newer desktop build.
/// </summary>
public static class UpdateChecker
{
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
public static UpdateCheckResult CheckForUpdate()
{
return CheckForUpdate(null, null, null, null);
}
/// <summary>
/// Overload for tests / smoke scripts (null = use AppConfig).
/// </summary>
public static UpdateCheckResult CheckForUpdate(
string baseUrl,
string repo,
string token,
string localVersionOverride)
{
try
{
return CheckForUpdateAsync(baseUrl, repo, token, localVersionOverride)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
AppDebugLog.Info("UpdateChecker", $"Проверка обновлений пропущена: {ex.Message}");
return new UpdateCheckResult
{
CheckedOk = false,
UpdateRequired = false,
LocalVersion = localVersionOverride ?? GetLocalVersionString(),
ErrorMessage = ex.Message
};
}
}
public static async Task<UpdateCheckResult> CheckForUpdateAsync()
{
return await CheckForUpdateAsync(null, null, null, null).ConfigureAwait(false);
}
public static async Task<UpdateCheckResult> CheckForUpdateAsync(
string baseUrlOverride,
string repoOverride,
string tokenOverride,
string localVersionOverride)
{
var localVersion = string.IsNullOrWhiteSpace(localVersionOverride)
? GetLocalVersionString()
: NormalizeVersion(localVersionOverride);
var result = new UpdateCheckResult
{
LocalVersion = localVersion
};
var baseUrl = (baseUrlOverride ?? AppConfig.UpdateCheckBaseUrl).TrimEnd('/');
var repo = (repoOverride ?? AppConfig.UpdateRepo).Trim('/');
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(repo))
{
result.CheckedOk = false;
result.ErrorMessage = "UpdateCheckBaseUrl/UpdateRepo не заданы";
return result;
}
var url = $"{baseUrl}/api/v1/repos/{repo}/releases/latest";
using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) })
{
var token = tokenOverride ?? AppConfig.UpdateApiToken;
if (!string.IsNullOrWhiteSpace(token))
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("token", token.Trim());
}
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
AppDebugLog.Info("UpdateChecker", $"GET {url}");
HttpResponseMessage response;
try
{
response = await client.GetAsync(url).ConfigureAwait(false);
}
catch (Exception ex)
{
// Offline pharmacies must continue working.
AppDebugLog.Info("UpdateChecker", $"Сеть недоступна, обновление не блокируем: {ex.Message}");
result.CheckedOk = false;
result.ErrorMessage = ex.Message;
return result;
}
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
AppDebugLog.Info("UpdateChecker",
$"releases/latest HTTP {(int)response.StatusCode}: {Truncate(body, 200)}");
result.CheckedOk = false;
result.ErrorMessage = $"HTTP {(int)response.StatusCode}";
return result;
}
var release = JsonSerializer.Deserialize<GiteaRelease>(body, JsonOptions);
if (release == null || string.IsNullOrWhiteSpace(release.TagName))
{
result.CheckedOk = false;
result.ErrorMessage = "Пустой ответ releases/latest";
return result;
}
var latest = NormalizeVersion(release.TagName);
result.LatestVersion = latest;
result.ReleaseUrl = string.IsNullOrWhiteSpace(release.HtmlUrl)
? $"{baseUrl}/{repo}/releases/tag/{release.TagName}"
: release.HtmlUrl;
result.DownloadUrl = PickZipAssetUrl(release) ?? result.ReleaseUrl;
result.CheckedOk = true;
result.UpdateRequired = IsRemoteNewer(latest, localVersion);
AppDebugLog.Info("UpdateChecker",
$"Локально={localVersion}, latest={latest}, updateRequired={result.UpdateRequired}");
return result;
}
}
public static string GetLocalVersionString()
{
try
{
var asm = Assembly.GetExecutingAssembly();
var file = asm.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version;
if (!string.IsNullOrWhiteSpace(file))
{
return NormalizeVersion(file);
}
return NormalizeVersion(asm.GetName().Version?.ToString() ?? "0.0.0.0");
}
catch
{
return "0.0.0.0";
}
}
/// <summary>
/// Returns true when remoteTag is strictly newer than localVersion.
/// Accepts "v1.0.9", "1.0.9", "1.0.9.0".
/// </summary>
public static bool IsRemoteNewer(string remoteTag, string localVersion)
{
if (!TryParseVersion(remoteTag, out var remote) ||
!TryParseVersion(localVersion, out var local))
{
return false;
}
return remote > local;
}
public static string NormalizeVersion(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "0.0.0.0";
}
value = value.Trim();
if (value.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
value = value.Substring(1);
}
// Drop pre-release suffix: 1.0.9-beta -> 1.0.9
var dash = value.IndexOf('-');
if (dash > 0)
{
value = value.Substring(0, dash);
}
if (TryParseVersion(value, out var v))
{
return v.ToString();
}
return value;
}
public static bool TryParseVersion(string value, out Version version)
{
version = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
value = value.Trim();
if (value.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
value = value.Substring(1);
}
var dash = value.IndexOf('-');
if (dash > 0)
{
value = value.Substring(0, dash);
}
var parts = value.Split('.');
if (parts.Length == 1)
{
value = value + ".0.0.0";
}
else if (parts.Length == 2)
{
value = value + ".0.0";
}
else if (parts.Length == 3)
{
value = value + ".0";
}
return Version.TryParse(value, out version);
}
private static string PickZipAssetUrl(GiteaRelease release)
{
if (release?.Assets == null || release.Assets.Length == 0)
{
return null;
}
var zip = release.Assets.FirstOrDefault(a =>
!string.IsNullOrWhiteSpace(a?.Name) &&
a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
if (zip == null)
{
zip = release.Assets.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a?.BrowserDownloadUrl));
}
return zip?.BrowserDownloadUrl;
}
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max)
{
return s ?? string.Empty;
}
return s.Substring(0, max) + "...";
}
private sealed class GiteaRelease
{
[JsonPropertyName("tag_name")]
public string TagName { get; set; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; }
[JsonPropertyName("assets")]
public GiteaAsset[] Assets { get; set; }
}
private sealed class GiteaAsset
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; }
}
}
}

View File

@ -17,9 +17,11 @@
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources> <GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
<NuGetPackageImportStamp> <NuGetPackageImportStamp>
</NuGetPackageImportStamp> </NuGetPackageImportStamp>
<ApplicationIcon>Resources\elfisa.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<DebugSymbols>true</DebugSymbols> <DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType> <DebugType>full</DebugType>
<Optimize>false</Optimize> <Optimize>false</Optimize>
@ -30,6 +32,7 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<DebugType>pdbonly</DebugType> <DebugType>pdbonly</DebugType>
<Optimize>true</Optimize> <Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath> <OutputPath>bin\Release\</OutputPath>
@ -96,6 +99,8 @@
<Reference Include="System.Data" /> <Reference Include="System.Data" />
<Reference Include="System.Deployment" /> <Reference Include="System.Deployment" />
<Reference Include="System.Drawing" /> <Reference Include="System.Drawing" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
<Reference Include="System.Windows.Forms" /> <Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" /> <Reference Include="System.Xml" />
@ -104,12 +109,20 @@
<Compile Include="Classes\ApiClient.cs" /> <Compile Include="Classes\ApiClient.cs" />
<Compile Include="Classes\AppConfig.cs" /> <Compile Include="Classes\AppConfig.cs" />
<Compile Include="Classes\AppDebugLog.cs" /> <Compile Include="Classes\AppDebugLog.cs" />
<Compile Include="Classes\AutoUpdater.cs" />
<Compile Include="Classes\ConsigneeHelper.cs" />
<Compile Include="Classes\SqlSyntaxHighlighter.cs" />
<Compile Include="Classes\DataSender.cs" /> <Compile Include="Classes\DataSender.cs" />
<Compile Include="Classes\OrderNumberHelper.cs" />
<Compile Include="Classes\ErrorResponse.cs" /> <Compile Include="Classes\ErrorResponse.cs" />
<Compile Include="Classes\InvoiceExportService.cs" />
<Compile Include="Classes\InvoiceRequestService.cs" /> <Compile Include="Classes\InvoiceRequestService.cs" />
<Compile Include="Classes\InvoiceSyncService.cs" /> <Compile Include="Classes\InvoiceSyncService.cs" />
<Compile Include="Classes\LoginRequest.cs" /> <Compile Include="Classes\LoginRequest.cs" />
<Compile Include="Classes\LoginResponse.cs" /> <Compile Include="Classes\LoginResponse.cs" />
<Compile Include="Classes\MarkupHelper.cs" />
<Compile Include="Classes\BuyerDiscountDtos.cs" />
<Compile Include="Classes\DiscountResolver.cs" />
<Compile Include="Classes\AppServices.cs" /> <Compile Include="Classes\AppServices.cs" />
<Compile Include="Classes\UiDialogs.cs" /> <Compile Include="Classes\UiDialogs.cs" />
<Compile Include="Classes\UiThemeHelper.cs" /> <Compile Include="Classes\UiThemeHelper.cs" />
@ -121,6 +134,7 @@
<Compile Include="Classes\SuppliersItem.cs" /> <Compile Include="Classes\SuppliersItem.cs" />
<Compile Include="Classes\SupplierResponse.cs" /> <Compile Include="Classes\SupplierResponse.cs" />
<Compile Include="Classes\ToastNotification.cs" /> <Compile Include="Classes\ToastNotification.cs" />
<Compile Include="Classes\UpdateChecker.cs" />
<Compile Include="Form1.cs"> <Compile Include="Form1.cs">
<SubType>Form</SubType> <SubType>Form</SubType>
</Compile> </Compile>
@ -187,6 +201,18 @@
<Compile Include="HelpForms\HF_Registration.Designer.cs"> <Compile Include="HelpForms\HF_Registration.Designer.cs">
<DependentUpon>HF_Registration.cs</DependentUpon> <DependentUpon>HF_Registration.cs</DependentUpon>
</Compile> </Compile>
<Compile Include="HelpForms\HF_Settings.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="HelpForms\HF_Settings.Designer.cs">
<DependentUpon>HF_Settings.cs</DependentUpon>
</Compile>
<Compile Include="HelpForms\HF_UpdateAvailable.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="HelpForms\HF_UpdateAvailable.Designer.cs">
<DependentUpon>HF_UpdateAvailable.cs</DependentUpon>
</Compile>
<Compile Include="HelpForms\HF_UploadDataToServer.cs"> <Compile Include="HelpForms\HF_UploadDataToServer.cs">
<SubType>Form</SubType> <SubType>Form</SubType>
</Compile> </Compile>
@ -222,6 +248,9 @@
<Compile Include="UserControls\UCRefusals.cs"> <Compile Include="UserControls\UCRefusals.cs">
<SubType>UserControl</SubType> <SubType>UserControl</SubType>
</Compile> </Compile>
<Compile Include="UserControls\UCReports.cs">
<SubType>UserControl</SubType>
</Compile>
<Compile Include="UserControls\UCPriceList.cs"> <Compile Include="UserControls\UCPriceList.cs">
<SubType>UserControl</SubType> <SubType>UserControl</SubType>
</Compile> </Compile>
@ -299,9 +328,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="App.config" /> <None Include="App.config" />
<Content Include="Data\efClient.db">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="Resources\JVMarkOfGood.jpg" /> <None Include="Resources\JVMarkOfGood.jpg" />
@ -332,4 +358,7 @@
<Import Project="packages\EntityFramework.6.4.4\build\EntityFramework.targets" Condition="Exists('packages\EntityFramework.6.4.4\build\EntityFramework.targets')" /> <Import Project="packages\EntityFramework.6.4.4\build\EntityFramework.targets" Condition="Exists('packages\EntityFramework.6.4.4\build\EntityFramework.targets')" />
<Import Project="packages\System.ValueTuple.4.6.1\build\net471\System.ValueTuple.targets" Condition="Exists('packages\System.ValueTuple.4.6.1\build\net471\System.ValueTuple.targets')" /> <Import Project="packages\System.ValueTuple.4.6.1\build\net471\System.ValueTuple.targets" Condition="Exists('packages\System.ValueTuple.4.6.1\build\net471\System.ValueTuple.targets')" />
<Import Project="packages\System.Resources.Extensions.8.0.0\buildTransitive\net462\System.Resources.Extensions.targets" Condition="Exists('packages\System.Resources.Extensions.8.0.0\buildTransitive\net462\System.Resources.Extensions.targets')" /> <Import Project="packages\System.Resources.Extensions.8.0.0\buildTransitive\net462\System.Resources.Extensions.targets" Condition="Exists('packages\System.Resources.Extensions.8.0.0\buildTransitive\net462\System.Resources.Extensions.targets')" />
<ItemGroup>
<None Include="Resources\elfisa.ico" />
</ItemGroup>
</Project> </Project>

View File

@ -1,4 +1,4 @@
namespace Электроннаяармация namespace Электроннаяармация
{ {
partial class ElectroPharmacy partial class ElectroPharmacy
{ {
@ -381,8 +381,11 @@
this.ClientSize = new System.Drawing.Size(1089, 554); this.ClientSize = new System.Drawing.Size(1089, 554);
this.Controls.Add(this.bindControl); this.Controls.Add(this.bindControl);
this.Controls.Add(this.tabContent); this.Controls.Add(this.tabContent);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
this.MaximizeBox = true;
this.MinimizeBox = true;
this.Margin = new System.Windows.Forms.Padding(4); this.Margin = new System.Windows.Forms.Padding(4);
this.MinimumSize = new System.Drawing.Size(900, 620);
this.Name = "ElectroPharmacy"; this.Name = "ElectroPharmacy";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "Электронная Фармация"; this.Text = "Электронная Фармация";

View File

@ -23,10 +23,12 @@ namespace Электронная_Фармация
private ToastService _toastService; private ToastService _toastService;
private LoadingOverlay _loadingOverlay; private LoadingOverlay _loadingOverlay;
private ModernAppHeader _appHeader; private ModernAppHeader _appHeader;
private ModernTitleBar _titleBar;
private ModernDocumentTabStrip _documentTabStrip; private ModernDocumentTabStrip _documentTabStrip;
private Panel _documentHost; private Panel _documentHost;
private Panel _statusFooter; private Panel _statusFooter;
private Label _lblCurrentUser; private Label _lblCurrentUser;
private ModernButton _btnSettings;
private ContextMenuStrip _directoryMenu; private ContextMenuStrip _directoryMenu;
private ContextMenuStrip _documentTabMenu; private ContextMenuStrip _documentTabMenu;
private ToolStripMenuItem _documentCloseTabMenuItem; private ToolStripMenuItem _documentCloseTabMenuItem;
@ -37,6 +39,9 @@ namespace Электронная_Фармация
Font = new Font("Segoe UI", 12f); Font = new Font("Segoe UI", 12f);
AutoScaleMode = AutoScaleMode.Dpi; AutoScaleMode = AutoScaleMode.Dpi;
KeyPreview = true; KeyPreview = true;
FormBorderStyle = FormBorderStyle.None;
MaximizeBox = true;
MinimizeBox = true;
MinimumSize = new Size(900, 620); MinimumSize = new Size(900, 620);
bindControl.Visible = false; bindControl.Visible = false;
@ -46,17 +51,22 @@ namespace Электронная_Фармация
AppServices.Initialize(_toastService); AppServices.Initialize(_toastService);
_loadingOverlay = new LoadingOverlay { Name = "loadingOverlay", Dock = DockStyle.Fill }; _loadingOverlay = new LoadingOverlay { Name = "loadingOverlay", Dock = DockStyle.Fill };
_titleBar = new ModernTitleBar { Name = "titleBar" };
_titleBar.Attach(this);
_titleBar.ApplyTheme(ThemeManager.Colors);
_appHeader = new ModernAppHeader { Name = "appHeader", Dock = DockStyle.Top }; _appHeader = new ModernAppHeader { Name = "appHeader", Dock = DockStyle.Top };
_appHeader.SetItems(new[] _appHeader.SetItems(new[]
{ {
new SidebarItem { Key = "price", Text = "ПРАЙС-ЛИСТ", IconGlyph = "\uE8FD" }, new SidebarItem { Key = "price", Text = "Прайс-лист", IconGlyph = "\uE14C" },
new SidebarItem { Key = "orders", Text = "ЗАКАЗЫ", IconGlyph = "\uE719" }, new SidebarItem { Key = "orders", Text = "Заказы", IconGlyph = "\uE7BF" },
new SidebarItem { Key = "invoices", Text = "НАКЛАДНЫЕ", IconGlyph = "\uE8A5" }, new SidebarItem { Key = "invoices", Text = "Накладные", IconGlyph = "\uE8A5" },
new SidebarItem { Key = "upload", Text = "ЗАГРУЗИТЬ", IconGlyph = "\uE896" }, new SidebarItem { Key = "reports", Text = "Отчёты", IconGlyph = "" },
new SidebarItem { Key = "send", Text = "ОТПРАВИТЬ", IconGlyph = "\uE725" }, new SidebarItem { Key = "upload", Text = "Загрузить", IconGlyph = "\uE896" },
new SidebarItem { Key = "refusals", Text = "ОТКАЗЫ", IconGlyph = "\uE711" }, new SidebarItem { Key = "send", Text = "Отправить", IconGlyph = "\uE725" },
new SidebarItem { Key = "directory", Text = "СПРАВОЧНИК", IconGlyph = "\uE8FD" }, new SidebarItem { Key = "refusals", Text = "Отказы", IconGlyph = "\uE7BA" },
new SidebarItem { Key = "auth", Text = "АВТОРИЗАЦИЯ", IconGlyph = "\uE192" } new SidebarItem { Key = "directory", Text = "Справочник", IconGlyph = "\uE8F1" },
new SidebarItem { Key = "auth", Text = "Вход", IconGlyph = "\uE192" }
}); });
_documentTabStrip = new ModernDocumentTabStrip { Name = "documentTabStrip", Dock = DockStyle.Top }; _documentTabStrip = new ModernDocumentTabStrip { Name = "documentTabStrip", Dock = DockStyle.Top };
@ -66,16 +76,22 @@ namespace Электронная_Фармация
{ {
Name = "statusFooter", Name = "statusFooter",
Dock = DockStyle.Bottom, Dock = DockStyle.Bottom,
Height = 30, Height = 32,
Tag = "status-footer", Tag = "status-footer",
Padding = new Padding(12, 0, 16, 0) Padding = new Padding(12, 0, 8, 0)
}; };
_statusFooter.Paint += (_, e) => _statusFooter.Paint += (_, e) =>
{ {
var border = ThemeManager.Colors.Border; var theme = ThemeManager.Colors;
using (var pen = new Pen(border)) var g = e.Graphics;
using (var fill = new SolidBrush(theme.CartHeaderBackground))
{ {
e.Graphics.DrawLine(pen, 0, 0, _statusFooter.Width, 0); g.FillRectangle(fill, _statusFooter.ClientRectangle);
}
using (var pen = new Pen(theme.AccentPrimaryBorder))
{
g.DrawLine(pen, 0, 0, _statusFooter.Width, 0);
} }
}; };
@ -83,23 +99,49 @@ namespace Электронная_Фармация
{ {
Name = "lblCurrentUser", Name = "lblCurrentUser",
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleRight, TextAlign = ContentAlignment.MiddleLeft,
AutoEllipsis = true, AutoEllipsis = true,
Tag = "muted" Tag = "muted",
Padding = new Padding(0, 0, 4, 0)
}; };
_btnSettings = new ModernButton
{
Name = "btnSettings",
Dock = DockStyle.Right,
Margin = new Padding(0, 2, 2, 2),
Text = "Настройки",
CompactIconMode = false,
AllowTextEllipsis = false,
MinimumSize = new Size(88, 22),
Size = new Size(88, 22),
Padding = new Padding(6, 0, 6, 0),
Font = new Font("Segoe UI Semibold", 8.5f, FontStyle.Bold),
TabIndex = 0
};
_btnSettings.Click += (_, __) => OpenSettingsDialog();
// Сначала Fill (пользователь), потом Right (кнопка) — кнопка справа, текст не перекрывается.
_statusFooter.Controls.Add(_lblCurrentUser); _statusFooter.Controls.Add(_lblCurrentUser);
_statusFooter.Controls.Add(_btnSettings);
// Клик по строке "аптека" открывает выбор грузополучателя без перезапуска приложения.
// Т.к. метка у нас одна (Dock=Fill), обработчик навешиваем на весь Label.
_lblCurrentUser.Cursor = Cursors.Hand;
_lblCurrentUser.Click += (_, __) => SwitchConsignee();
Controls.Add(_loadingOverlay); Controls.Add(_loadingOverlay);
Controls.Add(_documentHost); Controls.Add(_documentHost);
Controls.Add(_statusFooter); Controls.Add(_statusFooter);
Controls.Add(_documentTabStrip); Controls.Add(_documentTabStrip);
Controls.Add(_appHeader); Controls.Add(_appHeader);
Controls.Add(_titleBar);
_directoryMenu = new ContextMenuStrip(); _directoryMenu = new ContextMenuStrip();
var contractorsItem = new ToolStripMenuItem("Поставщики"); var contractorsItem = new ToolStripMenuItem("Поставщики");
contractorsItem.Click += (_, __) => tsItemContractors_Click(_, __); contractorsItem.Click += (_, __) => tsItemContractors_Click(_, __);
var consigneesItem = new ToolStripMenuItem("Грузополучатели"); var consigneesItem = new ToolStripMenuItem("Грузополучатели");
consigneesItem.Click += (_, __) => tsConsignee_Click(_, __); consigneesItem.Click += (_, __) => tsConsignee_Click(_, __);
var switchConsigneeItem = new ToolStripMenuItem("Сменить грузополучателя");
switchConsigneeItem.Click += (_, __) => SwitchConsignee();
var invoicesItem = new ToolStripMenuItem("Накладные"); var invoicesItem = new ToolStripMenuItem("Накладные");
invoicesItem.Click += (_, __) => tsItemInvoices_Click(_, __); invoicesItem.Click += (_, __) => tsItemInvoices_Click(_, __);
var debugItem = new ToolStripMenuItem("Отладка"); var debugItem = new ToolStripMenuItem("Отладка");
@ -108,6 +150,7 @@ namespace Электронная_Фармация
{ {
contractorsItem, contractorsItem,
consigneesItem, consigneesItem,
switchConsigneeItem,
invoicesItem, invoicesItem,
new ToolStripSeparator(), new ToolStripSeparator(),
debugItem debugItem
@ -140,12 +183,24 @@ namespace Электронная_Фармация
{ {
UiThemeHelper.ApplyToControlTree(this); UiThemeHelper.ApplyToControlTree(this);
UpdateThemeToggleState(); UpdateThemeToggleState();
_titleBar?.ApplyTheme(ThemeManager.Colors);
WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors); WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
}; };
HandleCreated += (_, __) => WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors); HandleCreated += (_, __) => WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
} }
protected override void WndProc(ref Message m)
{
if (FormBorderStyle == FormBorderStyle.None
&& WindowChromeHelper.TryHandleNcHitTest(this, ref m))
{
return;
}
base.WndProc(ref m);
}
private void UpdateThemeToggleState() private void UpdateThemeToggleState()
{ {
if (_documentTabStrip != null) if (_documentTabStrip != null)
@ -166,14 +221,49 @@ namespace Электронная_Фармация
{ {
ThemeManager.CurrentVariant = Settings.Default.UseDarkTheme ThemeManager.CurrentVariant = Settings.Default.UseDarkTheme
? ThemeVariant.DarkProductivity ? ThemeVariant.DarkProductivity
: ThemeVariant.PharmaTrust; : ThemeVariant.ClinicalPro;
_themeService.Initialize(this); _themeService.Initialize(this);
UpdateThemeToggleState(); UpdateThemeToggleState();
UiThemeHelper.ApplyToControlTree(this); UiThemeHelper.ApplyToControlTree(this);
_titleBar?.ApplyTheme(ThemeManager.Colors);
WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors); WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
try
{
Icon = CreateAppIcon();
}
catch
{
// keep default
}
RefreshCurrentUserStatus(); RefreshCurrentUserStatus();
} }
private static Icon CreateAppIcon()
{
using (var bmp = new Bitmap(32, 32))
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
g.Clear(Color.Transparent);
using (var brush = new SolidBrush(Color.FromArgb(15, 118, 110)))
{
g.FillEllipse(brush, 1, 1, 30, 30);
}
using (var pen = new Pen(Color.White, 3f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round
})
{
g.DrawLine(pen, 16, 8, 16, 24);
g.DrawLine(pen, 8, 16, 24, 16);
}
return Icon.FromHandle(bmp.GetHicon());
}
}
private void RefreshCurrentUserStatus() private void RefreshCurrentUserStatus()
{ {
if (_lblCurrentUser == null) if (_lblCurrentUser == null)
@ -183,18 +273,39 @@ namespace Электронная_Фармация
var login = Settings.Default.stringLogin?.Trim(); var login = Settings.Default.stringLogin?.Trim();
var hasToken = !string.IsNullOrWhiteSpace(Settings.Default.stringToken); var hasToken = !string.IsNullOrWhiteSpace(Settings.Default.stringToken);
var pharmacy = ConsigneeName?.Trim();
if (hasToken &&
(string.IsNullOrWhiteSpace(pharmacy) || pharmacy == "Прайс-Лист"))
{
pharmacy = ConsigneeHelper.GetDefaultConsigneeName();
if (!string.IsNullOrWhiteSpace(pharmacy) &&
(string.IsNullOrWhiteSpace(ConsigneeName) || ConsigneeName == "Прайс-Лист"))
{
ConsigneeName = pharmacy;
}
}
string userPart;
if (!string.IsNullOrWhiteSpace(login) && hasToken) if (!string.IsNullOrWhiteSpace(login) && hasToken)
{ {
_lblCurrentUser.Text = $"Пользователь: {login}"; userPart = $"Пользователь: {login}";
} }
else if (!string.IsNullOrWhiteSpace(login)) else if (!string.IsNullOrWhiteSpace(login))
{ {
_lblCurrentUser.Text = $"Пользователь: {login} (не авторизован)"; userPart = $"Пользователь: {login} (не авторизован)";
} }
else else
{ {
_lblCurrentUser.Text = "Пользователь не авторизован"; userPart = "Пользователь не авторизован";
}
if (hasToken && !string.IsNullOrWhiteSpace(pharmacy) && pharmacy != "Прайс-Лист")
{
_lblCurrentUser.Text = $"{userPart} | Аптека: {pharmacy}";
}
else
{
_lblCurrentUser.Text = $"{userPart} | Аптека: не выбрана";
} }
} }
@ -211,6 +322,9 @@ namespace Электронная_Фармация
case "invoices": case "invoices":
OpenInvoicesTab(); OpenInvoicesTab();
break; break;
case "reports":
OpenReportsTab();
break;
case "upload": case "upload":
tsBtnDownloadData_Click(this, EventArgs.Empty); tsBtnDownloadData_Click(this, EventArgs.Empty);
_appHeader.SetActive("upload"); _appHeader.SetActive("upload");
@ -389,6 +503,12 @@ namespace Электронная_Фармация
OpenDocumentTab("Отказы", "refusals", refusals, allowDuplicate: false); OpenDocumentTab("Отказы", "refusals", refusals, allowDuplicate: false);
} }
private void OpenReportsTab()
{
var reports = new UCReports();
OpenDocumentTab("Отчёты", "reports", reports, allowDuplicate: false);
}
partial void ShowShellLoading(string message) partial void ShowShellLoading(string message)
{ {
_loadingOverlay?.Show(message); _loadingOverlay?.Show(message);

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Data; using System.Data;
@ -14,6 +14,7 @@ using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using Elfisa.UI.Theming; using Elfisa.UI.Theming;
using Электроннаяармация.Classes; using Электроннаяармация.Classes;
using Электроннаяармация.Properties;
using Электроннаяармация.Forms; using Электроннаяармация.Forms;
using Электроннаяармация.HelpForms; using Электроннаяармация.HelpForms;
using Электроннаяармация.UserControls; using Электроннаяармация.UserControls;
@ -66,25 +67,102 @@ namespace Электронная_Фармация
{ {
FinalizeModernShell(); FinalizeModernShell();
// Принудительное обновление: если на Gitea есть более новый Release — не пускаем в работу.
// При недоступной сети не блокируем (офлайн-аптека должна открываться).
try
{
var update = UpdateChecker.CheckForUpdate();
if (update != null && update.UpdateRequired)
{
using (var dlg = new HF_UpdateAvailable(update))
{
UiThemeHelper.ApplyToControlTree(dlg);
dlg.ShowDialog(this);
}
// Жёстко завершаем процесс, чтобы updater смог заменить exe.
Environment.Exit(0);
return;
}
}
catch (Exception ex)
{
AppDebugLog.Info("App", $"Проверка обновлений пропущена: {ex.Message}");
}
FCheckDatabaseIntegrary fCheckDatabaseIntegrary = new FCheckDatabaseIntegrary(); FCheckDatabaseIntegrary fCheckDatabaseIntegrary = new FCheckDatabaseIntegrary();
UiThemeHelper.ApplyToControlTree(fCheckDatabaseIntegrary); UiThemeHelper.ApplyToControlTree(fCheckDatabaseIntegrary);
fCheckDatabaseIntegrary.ShowDialog(); fCheckDatabaseIntegrary.ShowDialog();
var hasToken = !string.IsNullOrWhiteSpace(Settings.Default.stringToken);
// Окно выбора грузополучателя открываем только после авторизации.
// Если аптек одна — не открываем окно вообще.
int consigneeCount = 0;
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
con.Open();
using (var cmd = new SQLiteCommand("SELECT COUNT(*) FROM Consignees", con))
{
consigneeCount = Convert.ToInt32(cmd.ExecuteScalar());
}
}
if (hasToken && consigneeCount > 1)
{
var consigneeDialog = new HFConsignees { ParentForm = this }; var consigneeDialog = new HFConsignees { ParentForm = this };
UiThemeHelper.ApplyToControlTree(consigneeDialog); UiThemeHelper.ApplyToControlTree(consigneeDialog);
consigneeDialog.ShowDialog(); consigneeDialog.ShowDialog();
}
if (string.IsNullOrWhiteSpace(ConsigneeName))
{
var defaultName = ConsigneeHelper.GetDefaultConsigneeName();
if (!string.IsNullOrWhiteSpace(defaultName))
{
ConsigneeName = defaultName;
ConsigneeHelper.SetActiveConsignee(defaultName);
}
}
if (!HasDocumentTabs()) if (!HasDocumentTabs())
{ {
loadDefaultPriceList(); loadDefaultPriceList();
} }
RefreshCurrentUserStatus();
} }
void loadDefaultPriceList() void loadDefaultPriceList()
{ {
var ucPriceList = new UCPriceList("Прайс-Лист"); var consignee = !string.IsNullOrWhiteSpace(ConsigneeName)
OpenDocumentTab("Прайс-лист", "price", ucPriceList, allowDuplicate: true); ? ConsigneeName
: ConsigneeHelper.GetDefaultConsigneeName();
if (string.IsNullOrWhiteSpace(consignee))
{
consignee = "Прайс-Лист";
}
else
{
ConsigneeName = consignee;
}
var title = consignee == "Прайс-Лист"
? "Прайс-лист"
: $"Прайс-лист ({consignee})";
// На смене аптеки не должно появляться много вкладок прайса.
// Поэтому перед открытием закрываем все вкладки с navKey="price".
ConsigneeHelper.SyncCartToPriceList(consignee);
foreach (var tab in _documentTabStrip.Tabs.Where(t => t.NavKey == "price").ToList())
{
CloseDocumentTab(tab.Id);
}
var ucPriceList = new UCPriceList(consignee);
OpenDocumentTab(title, "price", ucPriceList, allowDuplicate: false);
ucPriceList.dgvPriceList.Focus(); ucPriceList.dgvPriceList.Focus();
RefreshCurrentUserStatus();
} }
#region загрузкаПрайсЛиста_старая версия #region загрузкаПрайсЛиста_старая версия
@ -139,9 +217,21 @@ namespace Электронная_Фармация
private void tsConsignee_Click(object sender, EventArgs e) private void tsConsignee_Click(object sender, EventArgs e)
{ {
var hasToken = !string.IsNullOrWhiteSpace(Settings.Default.stringToken);
if (!hasToken)
{
UiDialogs.ShowInfo("Сначала выполните авторизацию, чтобы выбрать грузополучателя.", "Авторизация", this);
return;
}
var fConsignees = new FConsignees(); var fConsignees = new FConsignees();
UiThemeHelper.ApplyToControlTree(fConsignees); UiThemeHelper.ApplyToControlTree(fConsignees);
fConsignees.ShowDialog(); var dr = fConsignees.ShowDialog(this);
if (dr == DialogResult.OK &&
!string.IsNullOrWhiteSpace(fConsignees.SelectedConsigneeNameAfterSave))
{
showMePriceList(fConsignees.SelectedConsigneeNameAfterSave);
}
} }
private void tsItemInvoices_Click(object sender, EventArgs e) private void tsItemInvoices_Click(object sender, EventArgs e)
@ -164,9 +254,38 @@ namespace Электронная_Фармация
public void showMePriceList(string consignee) public void showMePriceList(string consignee)
{ {
ConsigneeName = consignee ?? string.Empty; ConsigneeName = consignee ?? string.Empty;
if (!string.IsNullOrWhiteSpace(ConsigneeName))
{
ConsigneeHelper.SetActiveConsignee(ConsigneeName);
}
// Перед открытием прайс-листа синхронизируем Zakaz/SummaZakaza с корзиной активной аптеки.
ConsigneeHelper.SyncCartToPriceList(ConsigneeName);
var ucPriceList = new UCPriceList(consignee); var ucPriceList = new UCPriceList(consignee);
OpenDocumentTab($"Прайс-лист ({consignee})", "price", ucPriceList, allowDuplicate: true); foreach (var tab in _documentTabStrip.Tabs.Where(t => t.NavKey == "price").ToList())
{
CloseDocumentTab(tab.Id);
}
OpenDocumentTab($"Прайс-лист ({consignee})", "price", ucPriceList, allowDuplicate: false);
ucPriceList.dgvPriceList.Focus(); ucPriceList.dgvPriceList.Focus();
RefreshCurrentUserStatus();
}
private void SwitchConsignee()
{
var hasToken = !string.IsNullOrWhiteSpace(Settings.Default.stringToken);
if (!hasToken)
{
UiDialogs.ShowInfo("Сначала выполните авторизацию, чтобы сменить грузополучателя.", "Авторизация", this);
return;
}
var consigneeDialog = new HFConsignees { ParentForm = this };
UiThemeHelper.ApplyToControlTree(consigneeDialog);
consigneeDialog.ShowDialog(this);
RefreshCurrentUserStatus();
} }
private void tsDebug_Click(object sender, EventArgs e) private void tsDebug_Click(object sender, EventArgs e)
@ -184,7 +303,9 @@ namespace Электронная_Фармация
private void tsExchangeLoad_Click(object sender, EventArgs e) private void tsExchangeLoad_Click(object sender, EventArgs e)
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string qShowMeCoountDataFromTempOrder = "select count(*) from TempOrderItems"; var consigneeSafe = (string.IsNullOrWhiteSpace(ConsigneeName) ? ConsigneeHelper.GetDefaultConsigneeName() : ConsigneeName)
?.Replace("'", "''");
string qShowMeCoountDataFromTempOrder = $"select count(*) from TempOrderItems where ConsigneeName = '{consigneeSafe}'";
int countDataTempOrder = 0; int countDataTempOrder = 0;
using (SQLiteConnection sqlCon = new SQLiteConnection(connectionStringToLocalDB)) using (SQLiteConnection sqlCon = new SQLiteConnection(connectionStringToLocalDB))
@ -208,6 +329,8 @@ namespace Электронная_Фармация
ClearDocumentTabs(); ClearDocumentTabs();
HF_DownloadDataFromServer hF_DownloadDataFromServer = new HF_DownloadDataFromServer(); HF_DownloadDataFromServer hF_DownloadDataFromServer = new HF_DownloadDataFromServer();
hF_DownloadDataFromServer.ActiveConsigneeName =
string.IsNullOrWhiteSpace(ConsigneeName) ? ConsigneeHelper.GetDefaultConsigneeName() : ConsigneeName;
UiThemeHelper.ApplyToControlTree(hF_DownloadDataFromServer); UiThemeHelper.ApplyToControlTree(hF_DownloadDataFromServer);
DialogResult downloadResult = DialogResult.Cancel; DialogResult downloadResult = DialogResult.Cancel;
if (countDataTempOrder > 0) if (countDataTempOrder > 0)
@ -584,6 +707,85 @@ namespace Электронная_Фармация
var hF_Registration = new HF_Registration(); var hF_Registration = new HF_Registration();
UiThemeHelper.ApplyToControlTree(hF_Registration); UiThemeHelper.ApplyToControlTree(hF_Registration);
if (hF_Registration.ShowDialog() == DialogResult.OK) if (hF_Registration.ShowDialog() == DialogResult.OK)
{
// После успешной авторизации сначала выбираем грузополучателя,
// и только потом (если нужно) запрашиваем Location ID.
int consigneeCount;
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
con.Open();
using (var cmd = new SQLiteCommand("SELECT COUNT(*) FROM Consignees", con))
{
consigneeCount = Convert.ToInt32(cmd.ExecuteScalar());
}
}
if (consigneeCount <= 1)
{
var defaultName = ConsigneeHelper.GetDefaultConsigneeName();
if (!string.IsNullOrWhiteSpace(defaultName))
{
ConsigneeName = defaultName;
ConsigneeHelper.SetActiveConsignee(defaultName);
}
}
else
{
var consigneeDialog = new HFConsignees { ParentForm = this };
UiThemeHelper.ApplyToControlTree(consigneeDialog);
if (consigneeDialog.ShowDialog(this) != DialogResult.OK)
{
// Без выбора грузополучателя дальше не идём.
return;
}
}
if (string.IsNullOrWhiteSpace(AppConfig.LocationId))
{
var consigneeName = ConsigneeName ?? string.Empty;
var consigneeAddress = string.Empty;
if (!string.IsNullOrWhiteSpace(consigneeName))
{
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
con.Open();
using (var cmd = new SQLiteCommand(
@"SELECT ifnull(ConsigneesAddress, '')
FROM Consignees
WHERE ConsigneesName = @name
LIMIT 1",
con))
{
cmd.Parameters.AddWithValue("@name", consigneeName.Trim());
consigneeAddress = cmd.ExecuteScalar()?.ToString() ?? string.Empty;
}
}
}
if (!HF_LocationId.PromptAndSave(this, consigneeName, consigneeAddress))
{
ToastNotification.ShowCustom(
"Location ID не указан. Укажите его перед отправкой заказов.",
Color.DarkOrange,
Color.White);
return;
}
}
if (!string.IsNullOrWhiteSpace(AppConfig.LocationId))
{
ConsigneeHelper.ApplyLocationIdToEmptyConsignees(AppConfig.LocationId);
}
RefreshCurrentUserStatus();
}
}
private void OpenSettingsDialog()
{
var settingsForm = new HF_Settings();
UiThemeHelper.ApplyToControlTree(settingsForm);
if (settingsForm.ShowDialog(this) == DialogResult.OK)
{ {
RefreshCurrentUserStatus(); RefreshCurrentUserStatus();
} }
@ -591,10 +793,38 @@ namespace Электронная_Фармация
private async void tsBtnSentData_Click(object sender, EventArgs e) private async void tsBtnSentData_Click(object sender, EventArgs e)
{ {
var hasToken = !string.IsNullOrWhiteSpace(Settings.Default.stringToken);
if (!hasToken)
{
UiDialogs.ShowInfo("Сначала выполните авторизацию, чтобы отправить заказы.", "Авторизация", this);
return;
}
DialogResult drUpdateData = UiDialogs.ConfirmYesNoCancel("Будут отправлены все сохранённые заказы. Вы уверены?", "Отправка заказов", this); DialogResult drUpdateData = UiDialogs.ConfirmYesNoCancel("Будут отправлены все сохранённые заказы. Вы уверены?", "Отправка заказов", this);
if (drUpdateData == DialogResult.Yes) if (drUpdateData == DialogResult.Yes)
{ {
var nowUtc = DateTime.UtcNow;
var lastPriceUpdateUtc = Settings.Default.LastPriceUpdateUtc;
var lastPriceReminderUtc = Settings.Default.LastPriceReminderUtc;
var priceIsStale = lastPriceUpdateUtc == DateTime.MinValue ||
(nowUtc - lastPriceUpdateUtc) > TimeSpan.FromHours(24);
var canRemind = lastPriceReminderUtc == DateTime.MinValue ||
(nowUtc - lastPriceReminderUtc) > TimeSpan.FromHours(24);
if (priceIsStale && canRemind)
{
// Напоминание не блокирует отправку.
UiDialogs.ShowInfo(
"Прайс-лист не обновлялся более 24 часов. Рекомендуем обновить прайс перед отправкой заказов.",
"Прайс-лист",
this);
Settings.Default.LastPriceReminderUtc = nowUtc;
Settings.Default.Save();
}
ShowShellLoading("Отправка заказов..."); ShowShellLoading("Отправка заказов...");
ToastNotification.ShowCustom("Отправляем данные", Color.DarkOrange, Color.White); ToastNotification.ShowCustom("Отправляем данные", Color.DarkOrange, Color.White);
@ -618,7 +848,9 @@ namespace Электронная_Фармация
private void tsBtnDownloadData_Click(object sender, EventArgs e) private void tsBtnDownloadData_Click(object sender, EventArgs e)
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string qShowMeCoountDataFromTempOrder = "select count(*) from TempOrderItems"; var consigneeSafe = (string.IsNullOrWhiteSpace(ConsigneeName) ? ConsigneeHelper.GetDefaultConsigneeName() : ConsigneeName)
?.Replace("'", "''");
string qShowMeCoountDataFromTempOrder = $"select count(*) from TempOrderItems where ConsigneeName = '{consigneeSafe}'";
int countDataTempOrder = 0; int countDataTempOrder = 0;
using (SQLiteConnection sqlCon = new SQLiteConnection(connectionStringToLocalDB)) using (SQLiteConnection sqlCon = new SQLiteConnection(connectionStringToLocalDB))
@ -642,6 +874,8 @@ namespace Электронная_Фармация
ClearDocumentTabs(); ClearDocumentTabs();
HF_DownloadDataFromServer hF_DownloadDataFromServer = new HF_DownloadDataFromServer(); HF_DownloadDataFromServer hF_DownloadDataFromServer = new HF_DownloadDataFromServer();
hF_DownloadDataFromServer.ActiveConsigneeName =
string.IsNullOrWhiteSpace(ConsigneeName) ? ConsigneeHelper.GetDefaultConsigneeName() : ConsigneeName;
UiThemeHelper.ApplyToControlTree(hF_DownloadDataFromServer); UiThemeHelper.ApplyToControlTree(hF_DownloadDataFromServer);
DialogResult downloadResult = DialogResult.Cancel; DialogResult downloadResult = DialogResult.Cancel;
if (countDataTempOrder > 0) if (countDataTempOrder > 0)

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Data; using System.Data;
@ -76,7 +76,8 @@ namespace Электронная_Фармация.Forms
[codeConsignees] integer not null, [codeConsignees] integer not null,
[ConsigneesName] nvarchar(128) not null, [ConsigneesName] nvarchar(128) not null,
[ConsigneesAddress] nvarchar(256) not null, [ConsigneesAddress] nvarchar(256) not null,
[ConsigneesUseAsDefault] int(1) not null [ConsigneesUseAsDefault] int(1) not null,
[LocationId] nvarchar(64)
) )
"; ";
@ -250,7 +251,8 @@ create table if not exists [Orders] (
[SummaZakaza] nvarchar(32), [SummaZakaza] nvarchar(32),
[OrderState] nvarchar(32), [OrderState] nvarchar(32),
[Comment] nvarchar(1024), [Comment] nvarchar(1024),
[ConsigneeName] nvarchar(128) [ConsigneeName] nvarchar(128),
[RemoteOrderId] nvarchar(36)
) )
"; ";
@ -307,6 +309,7 @@ create table if not exists [OrderItems] (
string strCheckExistsTableTempOrderItems = @" string strCheckExistsTableTempOrderItems = @"
create table if not exists [TempOrderItems] ( create table if not exists [TempOrderItems] (
[ConsigneeName] nvarchar(128) not null,
[guid_es] nvarchar(36) not null, [guid_es] nvarchar(36) not null,
[es_code] integer not null, [es_code] integer not null,
[supplier_id] nvarchar(36) not null, [supplier_id] nvarchar(36) not null,
@ -318,8 +321,9 @@ create table if not exists [OrderItems] (
[Description] text, [Description] text,
[Zakaz] real, [Zakaz] real,
[SummaZakaza] nvarchar(64), [SummaZakaza] nvarchar(64),
[id_PriceList_Item] integer primary key not null, [id_PriceList_Item] integer not null,
[supplier_price_id] nvarchar(36) [supplier_price_id] nvarchar(36),
primary key ([ConsigneeName], [id_PriceList_Item])
) )
"; ";
@ -392,6 +396,132 @@ create table if not exists [OrderItems] (
cmdCreateNotExistingsTables.CommandText = strCheckExistsTableTempOrderItems; cmdCreateNotExistingsTables.CommandText = strCheckExistsTableTempOrderItems;
cmdCreateNotExistingsTables.ExecuteNonQuery(); cmdCreateNotExistingsTables.ExecuteNonQuery();
// Миграция TempOrderItems под раздельную корзину по аптекам.
// Проверяем наличие ConsigneeName и то, что id_PriceList_Item не является одинарным PK.
bool hasConsigneeName = false;
bool idPriceListItemIsSinglePk = false;
using (var infoCmd = new SQLiteCommand("PRAGMA table_info([TempOrderItems]);", conForCheckTables))
using (var infoReader = infoCmd.ExecuteReader())
{
while (infoReader.Read())
{
var colName = infoReader["name"]?.ToString();
var pk = 0;
try { pk = Convert.ToInt32(infoReader["pk"]); } catch { pk = 0; }
if (string.Equals(colName, "ConsigneeName", StringComparison.OrdinalIgnoreCase))
{
hasConsigneeName = true;
}
if (string.Equals(colName, "id_PriceList_Item", StringComparison.OrdinalIgnoreCase) && pk == 1)
{
idPriceListItemIsSinglePk = true;
}
}
}
if (!hasConsigneeName || idPriceListItemIsSinglePk)
{
string defaultConsignee;
using (var defCmd = new SQLiteCommand(
@"SELECT ifnull((SELECT ConsigneesName
FROM Consignees
WHERE ConsigneesUseAsDefault = 1
LIMIT 1), '')",
conForCheckTables))
{
defaultConsignee = defCmd.ExecuteScalar()?.ToString() ?? string.Empty;
}
using (var tx = conForCheckTables.BeginTransaction())
{
using (var cmd = new SQLiteCommand(conForCheckTables))
{
cmd.Transaction = tx;
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS [TempOrderItems_new] (
[ConsigneeName] nvarchar(128) not null,
[guid_es] nvarchar(36) not null,
[es_code] integer not null,
[supplier_id] nvarchar(36) not null,
[DrugName] nvarchar(256) not null COLLATE NOCASE,
[SupplierName] nvarchar(256) not null,
[Price] nvarchar(32),
[Quantity] integer,
[ExpiryPeriod] nvarchar(64),
[Description] text,
[Zakaz] real,
[SummaZakaza] nvarchar(64),
[id_PriceList_Item] integer not null,
[supplier_price_id] nvarchar(36),
primary key ([ConsigneeName], [id_PriceList_Item])
);";
cmd.ExecuteNonQuery();
cmd.Parameters.Clear();
cmd.Parameters.AddWithValue("@defaultConsignee", defaultConsignee.Trim());
cmd.CommandText = @"
INSERT INTO [TempOrderItems_new] (
[ConsigneeName],
[guid_es],
[es_code],
[supplier_id],
[DrugName],
[SupplierName],
[Price],
[Quantity],
[ExpiryPeriod],
[Description],
[Zakaz],
[SummaZakaza],
[id_PriceList_Item],
[supplier_price_id]
)
SELECT
@defaultConsignee,
[guid_es],
[es_code],
[supplier_id],
[DrugName],
[SupplierName],
[Price],
[Quantity],
[ExpiryPeriod],
[Description],
[Zakaz],
[SummaZakaza],
[id_PriceList_Item],
[supplier_price_id]
FROM [TempOrderItems];";
cmd.ExecuteNonQuery();
cmd.Parameters.Clear();
cmd.CommandText = @"DROP TABLE [TempOrderItems];";
cmd.ExecuteNonQuery();
cmd.CommandText = @"ALTER TABLE [TempOrderItems_new] RENAME TO [TempOrderItems];";
cmd.ExecuteNonQuery();
}
tx.Commit();
}
}
// LocationId нужен для связи с BuyerLocation на сервере.
TryAddColumn(conForCheckTables, "Consignees", "LocationId", "nvarchar(64)");
// Удаляем старые тестовые грузополучатели — точки создаются в веб-кабинете.
using (var delTest = new SQLiteCommand(
@"DELETE FROM Consignees
WHERE ConsigneesName LIKE 'Тестовая аптека%'
OR ConsigneesAddress LIKE 'Тестовый адрес%';",
conForCheckTables))
{
delTest.ExecuteNonQuery();
}
TryAddColumn(conForCheckTables, "Orders", "ConsigneeName", "nvarchar(128)"); TryAddColumn(conForCheckTables, "Orders", "ConsigneeName", "nvarchar(128)");
TryAddColumn(conForCheckTables, "Invoice", "SupplierName", "nvarchar(256)"); TryAddColumn(conForCheckTables, "Invoice", "SupplierName", "nvarchar(256)");
TryAddColumn(conForCheckTables, "Invoice", "ConsigneesName", "nvarchar(128)"); TryAddColumn(conForCheckTables, "Invoice", "ConsigneesName", "nvarchar(128)");
@ -400,11 +530,33 @@ create table if not exists [OrderItems] (
TryAddColumn(conForCheckTables, "Invoice", "SourceOrderId", "integer"); TryAddColumn(conForCheckTables, "Invoice", "SourceOrderId", "integer");
TryAddColumn(conForCheckTables, "Invoice", "SourceOrderNumber", "nvarchar(36)"); TryAddColumn(conForCheckTables, "Invoice", "SourceOrderNumber", "nvarchar(36)");
TryAddColumn(conForCheckTables, "Invoice", "IsRequestedInvoice", "int(1)"); TryAddColumn(conForCheckTables, "Invoice", "IsRequestedInvoice", "int(1)");
TryAddColumn(conForCheckTables, "Orders", "RemoteOrderId", "nvarchar(36)");
TryAddColumn(conForCheckTables, "PriceList", "TradeName", "nvarchar(256)"); TryAddColumn(conForCheckTables, "PriceList", "TradeName", "nvarchar(256)");
TryAddColumn(conForCheckTables, "PriceList", "Dosage", "nvarchar(128)"); TryAddColumn(conForCheckTables, "PriceList", "Dosage", "nvarchar(128)");
TryAddColumn(conForCheckTables, "PriceList", "MarkupPercent", "real");
TryAddColumn(conForCheckTables, "Consignees", "ClientMarkupPercent", "real");
ConsigneeHelper.MigrateGlobalLocationIdIfNeeded(conForCheckTables);
} }
catch (Exception ex) catch (Exception ex)
{ {
try
{
var logDir = AppConfig.LogsDirectory;
Directory.CreateDirectory(logDir);
var logPath = Path.Combine(
logDir,
$"db-integrity-err-{DateTime.Now:yyyy-MM-dd}.log");
File.AppendAllText(
logPath,
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {ex}{Environment.NewLine}{Environment.NewLine}",
Encoding.UTF8);
}
catch
{
// Логирование не должно скрывать исходную ошибку.
}
UiDialogs.ShowError($"Возникла ошибка при проверке локальной базы на целостность.\n{ex}", "Ошибка целостности", this); UiDialogs.ShowError($"Возникла ошибка при проверке локальной базы на целостность.\n{ex}", "Ошибка целостности", this);
} }
finally finally

View File

@ -2,15 +2,8 @@
{ {
partial class FConsignees partial class FConsignees
{ {
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null; private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing && (components != null)) if (disposing && (components != null))
@ -22,13 +15,12 @@
#region Windows Form Designer generated code #region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent() private void InitializeComponent()
{ {
this.panel1 = new System.Windows.Forms.Panel(); this.panel1 = new System.Windows.Forms.Panel();
this.btnSetDefault = new Elfisa.UI.Controls.ModernButton();
this.btnAdd = new Elfisa.UI.Controls.ModernButton();
this.btnSave = new Elfisa.UI.Controls.ModernButton();
this.txtInteractiveShowMeConsignees = new Elfisa.UI.Controls.ModernTextBox(); this.txtInteractiveShowMeConsignees = new Elfisa.UI.Controls.ModernTextBox();
this.label2 = new System.Windows.Forms.Label(); this.label2 = new System.Windows.Forms.Label();
this.dgvConsignees = new Elfisa.UI.Controls.ModernDataGridView(); this.dgvConsignees = new Elfisa.UI.Controls.ModernDataGridView();
@ -39,31 +31,67 @@
// panel1 // panel1
// //
this.panel1.BackColor = System.Drawing.SystemColors.ButtonHighlight; this.panel1.BackColor = System.Drawing.SystemColors.ButtonHighlight;
this.panel1.Controls.Add(this.btnSetDefault);
this.panel1.Controls.Add(this.btnAdd);
this.panel1.Controls.Add(this.btnSave);
this.panel1.Controls.Add(this.txtInteractiveShowMeConsignees); this.panel1.Controls.Add(this.txtInteractiveShowMeConsignees);
this.panel1.Controls.Add(this.label2); this.panel1.Controls.Add(this.label2);
this.panel1.Controls.Add(this.dgvConsignees); this.panel1.Controls.Add(this.dgvConsignees);
this.panel1.Location = new System.Drawing.Point(13, 14); this.panel1.Dock = System.Windows.Forms.DockStyle.Fill;
this.panel1.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.panel1.Location = new System.Drawing.Point(0, 0);
this.panel1.Margin = new System.Windows.Forms.Padding(4);
this.panel1.Name = "panel1"; this.panel1.Name = "panel1";
this.panel1.Size = new System.Drawing.Size(724, 423); this.panel1.Padding = new System.Windows.Forms.Padding(12);
this.panel1.Size = new System.Drawing.Size(820, 480);
this.panel1.TabIndex = 4; this.panel1.TabIndex = 4;
// //
// btnSetDefault
//
this.btnSetDefault.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.btnSetDefault.Location = new System.Drawing.Point(280, 430);
this.btnSetDefault.Name = "btnSetDefault";
this.btnSetDefault.Size = new System.Drawing.Size(180, 34);
this.btnSetDefault.TabIndex = 6;
this.btnSetDefault.Text = "По умолчанию";
this.btnSetDefault.Click += new System.EventHandler(this.btnSetDefault_Click);
//
// btnAdd
//
this.btnAdd.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.btnAdd.Location = new System.Drawing.Point(148, 430);
this.btnAdd.Name = "btnAdd";
this.btnAdd.Size = new System.Drawing.Size(120, 34);
this.btnAdd.TabIndex = 5;
this.btnAdd.Text = "Добавить";
this.btnAdd.Click += new System.EventHandler(this.btnAdd_Click);
//
// btnSave
//
this.btnSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.btnSave.Location = new System.Drawing.Point(16, 430);
this.btnSave.Name = "btnSave";
this.btnSave.Size = new System.Drawing.Size(120, 34);
this.btnSave.TabIndex = 4;
this.btnSave.Text = "Сохранить";
this.btnSave.Click += new System.EventHandler(this.btnSave_Click);
//
// txtInteractiveShowMeConsignees // txtInteractiveShowMeConsignees
// //
this.txtInteractiveShowMeConsignees.Font = new System.Drawing.Font("Segoe UI", 11.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(204))); this.txtInteractiveShowMeConsignees.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
this.txtInteractiveShowMeConsignees.Location = new System.Drawing.Point(91, 6); | System.Windows.Forms.AnchorStyles.Right)));
this.txtInteractiveShowMeConsignees.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.txtInteractiveShowMeConsignees.Font = new System.Drawing.Font("Segoe UI", 11.25F);
this.txtInteractiveShowMeConsignees.Location = new System.Drawing.Point(91, 16);
this.txtInteractiveShowMeConsignees.Margin = new System.Windows.Forms.Padding(4);
this.txtInteractiveShowMeConsignees.Name = "txtInteractiveShowMeConsignees"; this.txtInteractiveShowMeConsignees.Name = "txtInteractiveShowMeConsignees";
this.txtInteractiveShowMeConsignees.Size = new System.Drawing.Size(623, 32); this.txtInteractiveShowMeConsignees.Size = new System.Drawing.Size(700, 32);
this.txtInteractiveShowMeConsignees.TabIndex = 3; this.txtInteractiveShowMeConsignees.TabIndex = 3;
this.txtInteractiveShowMeConsignees.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.txtInteractiveShowMeConsignees_KeyPress); this.txtInteractiveShowMeConsignees.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.txtInteractiveShowMeConsignees_KeyPress);
// //
// label2 // label2
// //
this.label2.AutoSize = true; this.label2.AutoSize = true;
this.label2.Font = new System.Drawing.Font("Segoe UI", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(204))); this.label2.Font = new System.Drawing.Font("Segoe UI", 12F);
this.label2.Location = new System.Drawing.Point(5, 6); this.label2.Location = new System.Drawing.Point(16, 18);
this.label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
this.label2.Name = "label2"; this.label2.Name = "label2";
this.label2.Size = new System.Drawing.Size(73, 28); this.label2.Size = new System.Drawing.Size(73, 28);
this.label2.TabIndex = 2; this.label2.TabIndex = 2;
@ -73,45 +101,48 @@
// //
this.dgvConsignees.AllowUserToAddRows = false; this.dgvConsignees.AllowUserToAddRows = false;
this.dgvConsignees.AllowUserToDeleteRows = false; this.dgvConsignees.AllowUserToDeleteRows = false;
this.dgvConsignees.AllowUserToResizeColumns = false;
this.dgvConsignees.AllowUserToResizeRows = false; this.dgvConsignees.AllowUserToResizeRows = false;
this.dgvConsignees.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.dgvConsignees.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.Fill; this.dgvConsignees.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.Fill;
this.dgvConsignees.AutoSizeRowsMode = System.Windows.Forms.DataGridViewAutoSizeRowsMode.DisplayedHeaders;
this.dgvConsignees.BackgroundColor = System.Drawing.SystemColors.ButtonFace; this.dgvConsignees.BackgroundColor = System.Drawing.SystemColors.ButtonFace;
this.dgvConsignees.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; this.dgvConsignees.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.dgvConsignees.Location = new System.Drawing.Point(11, 44); this.dgvConsignees.Location = new System.Drawing.Point(16, 56);
this.dgvConsignees.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4);
this.dgvConsignees.Name = "dgvConsignees"; this.dgvConsignees.Name = "dgvConsignees";
this.dgvConsignees.ReadOnly = true; this.dgvConsignees.ReadOnly = false;
this.dgvConsignees.RowHeadersWidthSizeMode = System.Windows.Forms.DataGridViewRowHeadersWidthSizeMode.AutoSizeToDisplayedHeaders; this.dgvConsignees.RowHeadersWidthSizeMode = System.Windows.Forms.DataGridViewRowHeadersWidthSizeMode.AutoSizeToDisplayedHeaders;
this.dgvConsignees.Size = new System.Drawing.Size(704, 369); this.dgvConsignees.Size = new System.Drawing.Size(775, 360);
this.dgvConsignees.TabIndex = 1; this.dgvConsignees.TabIndex = 1;
// //
// FConsignees // FConsignees
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(749, 450); this.ClientSize = new System.Drawing.Size(820, 480);
this.Controls.Add(this.panel1); this.Controls.Add(this.panel1);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
this.Margin = new System.Windows.Forms.Padding(3, 2, 3, 2); this.MaximizeBox = true;
this.MaximizeBox = false;
this.MinimizeBox = false; this.MinimizeBox = false;
this.MinimumSize = new System.Drawing.Size(700, 420);
this.Name = "FConsignees"; this.Name = "FConsignees";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Грузополучатели"; this.Text = "Грузополучатели (аптеки)";
this.Load += new System.EventHandler(this.FConsignees_Load); this.Load += new System.EventHandler(this.FConsignees_Load);
this.panel1.ResumeLayout(false); this.panel1.ResumeLayout(false);
this.panel1.PerformLayout(); this.panel1.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.dgvConsignees)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.dgvConsignees)).EndInit();
this.ResumeLayout(false); this.ResumeLayout(false);
} }
#endregion #endregion
private System.Windows.Forms.Panel panel1; private System.Windows.Forms.Panel panel1;
private Elfisa.UI.Controls.ModernTextBox txtInteractiveShowMeConsignees; private Elfisa.UI.Controls.ModernTextBox txtInteractiveShowMeConsignees;
private System.Windows.Forms.Label label2; private System.Windows.Forms.Label label2;
private Elfisa.UI.Controls.ModernDataGridView dgvConsignees; private Elfisa.UI.Controls.ModernDataGridView dgvConsignees;
private Elfisa.UI.Controls.ModernButton btnSave;
private Elfisa.UI.Controls.ModernButton btnAdd;
private Elfisa.UI.Controls.ModernButton btnSetDefault;
} }
} }

View File

@ -1,41 +1,43 @@
using System; using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data; using System.Data;
using System.Drawing; using System.Data.SQLite;
using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using Электроннаяармация.Classes; using Электроннаяармация.Classes;
using System.Data.SQLite; using Электроннаяармация.Properties;
namespace Электроннаяармация.Forms namespace Электроннаяармация.Forms
{ {
public partial class FConsignees : Form public partial class FConsignees : Form
{ {
public string SelectedConsigneeNameAfterSave { get; private set; } = string.Empty;
public FConsignees() public FConsignees()
{ {
InitializeComponent(); InitializeComponent();
} }
private void FConsignees_Load(object sender, EventArgs e) private async void FConsignees_Load(object sender, EventArgs e)
{ {
UiThemeHelper.ApplyToControlTree(this); UiThemeHelper.ApplyToControlTree(this);
btnAdd.Text = "С сервера";
showMeContentFromConsigneesTable(); showMeContentFromConsigneesTable();
await SyncFromServerAsync(silent: true);
} }
void showMeContentFromConsigneesTable() void showMeContentFromConsigneesTable()
{ {
using (SQLiteConnection conConsignees = new SQLiteConnection(AppConfig.SqliteConnectionString)) using (SQLiteConnection conConsignees = new SQLiteConnection(AppConfig.SqliteConnectionString))
{ {
string selectFromConsignees = $@" string selectFromConsignees = @"
select [codeConsignees] as [Код грузополучателя], select [idConsignees] as [Id],
[codeConsignees] as [Код],
[ConsigneesName] as [Наименование], [ConsigneesName] as [Наименование],
[ConsigneesAddress] as [Адрес], [ConsigneesAddress] as [Адрес],
[ConsigneesUseAsDefault] as [По умолчанию?] ifnull([LocationId], '') as [Location ID],
[ConsigneesUseAsDefault] as [По умолчанию]
from [Consignees] from [Consignees]
order by [ConsigneesUseAsDefault] desc"; order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
try try
{ {
conConsignees.Open(); conConsignees.Open();
@ -44,16 +46,21 @@ order by [ConsigneesUseAsDefault] desc";
SQLiteDataAdapter daConsigneeList = new SQLiteDataAdapter(cmdShowConsigneesList); SQLiteDataAdapter daConsigneeList = new SQLiteDataAdapter(cmdShowConsigneesList);
daConsigneeList.Fill(tableConsignees); daConsigneeList.Fill(tableConsignees);
dgvConsignees.DataSource = tableConsignees; dgvConsignees.DataSource = tableConsignees;
dgvConsignees.Columns[3].ValueType = typeof(float); if (dgvConsignees.Columns.Contains("Id"))
{
dgvConsignees.Columns["Id"].Visible = false;
}
foreach (DataGridViewColumn column in dgvConsignees.Columns)
{
// Редактируем только адрес локально; Location ID и имя приходят с сервера.
column.ReadOnly = column.Name != "Адрес";
}
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show($"Возникла ошибка при заполнении данных о грузополучателях.\nТекст ошибки:\n{ex}", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); MessageBox.Show($"Возникла ошибка при заполнении данных о грузополучателях.\nТекст ошибки:\n{ex}", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
} }
finally
{
conConsignees.Close();
}
} }
} }
@ -61,9 +68,9 @@ order by [ConsigneesUseAsDefault] desc";
{ {
if (e.KeyChar == (char)Keys.Enter) if (e.KeyChar == (char)Keys.Enter)
{ {
if (dgvConsignees.Rows.Count > 0) if (dgvConsignees.Rows.Count > 0 && dgvConsignees.DataSource is DataTable table)
{ {
(dgvConsignees.DataSource as DataTable).DefaultView.RowFilter = string.Format($"[Наименование] like '%{txtInteractiveShowMeConsignees.Text}%'"); table.DefaultView.RowFilter = $"[Наименование] like '%{txtInteractiveShowMeConsignees.Text.Replace("'", "''")}%'";
if (dgvConsignees.Rows.Count == 0) if (dgvConsignees.Rows.Count == 0)
{ {
showMeContentFromConsigneesTable(); showMeContentFromConsigneesTable();
@ -77,5 +84,132 @@ order by [ConsigneesUseAsDefault] desc";
} }
} }
private void btnSave_Click(object sender, EventArgs e)
{
if (!(dgvConsignees.DataSource is DataTable table))
{
return;
}
dgvConsignees.EndEdit();
var selectedName = dgvConsignees.CurrentRow?.Cells["Наименование"]?.Value?.ToString()?.Trim()
?? string.Empty;
SelectedConsigneeNameAfterSave = selectedName;
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var tx = connection.BeginTransaction())
{
foreach (DataRow row in table.Rows)
{
if (row.RowState == DataRowState.Unchanged)
{
continue;
}
using (var cmd = new SQLiteCommand(
@"UPDATE Consignees
SET ConsigneesAddress = @address
WHERE idConsignees = @id",
connection,
tx))
{
cmd.Parameters.AddWithValue("@address", row["Адрес"]?.ToString() ?? string.Empty);
cmd.Parameters.AddWithValue("@id", Convert.ToInt64(row["Id"]));
cmd.ExecuteNonQuery();
}
}
tx.Commit();
}
}
table.AcceptChanges();
ToastNotification.ShowSuccess("Грузополучатели сохранены");
if (!string.IsNullOrWhiteSpace(selectedName))
{
ConsigneeHelper.SetActiveConsignee(selectedName);
}
DialogResult = DialogResult.OK;
Close();
}
private async void btnAdd_Click(object sender, EventArgs e)
{
await SyncFromServerAsync(silent: false);
}
private async Task SyncFromServerAsync(bool silent)
{
try
{
btnAdd.Enabled = false;
var client = new ApiClient();
if (!client.IsAuthenticated)
{
var token = Settings.Default.stringToken;
if (string.IsNullOrWhiteSpace(token))
{
if (!silent)
{
UiDialogs.ShowInfo(
"Сначала войдите на сервер — грузополучатели подтягиваются из веб-кабинета покупателя.",
"Грузополучатели",
this);
}
return;
}
client.SetToken(token);
}
var locations = await client.GetBuyerLocationsAsync();
var count = ConsigneeHelper.SyncFromServer(locations);
showMeContentFromConsigneesTable();
if (!silent)
{
ToastNotification.ShowSuccess(count > 0
? $"Обновлено грузополучателей: {count}"
: "На сервере пока нет грузополучателей");
}
}
catch (Exception ex)
{
if (!silent)
{
UiDialogs.ShowError($"Не удалось загрузить грузополучателей с сервера.\n{ex.Message}", "Грузополучатели", this);
}
else
{
AppDebugLog.Error("FConsignees", "Тихая синхронизация грузополучателей не удалась", ex);
}
}
finally
{
btnAdd.Enabled = true;
}
}
private void btnSetDefault_Click(object sender, EventArgs e)
{
if (dgvConsignees.CurrentRow == null)
{
UiDialogs.ShowInfo("Выберите аптеку в списке.", "Грузополучатели", this);
return;
}
var name = dgvConsignees.CurrentRow.Cells["Наименование"]?.Value?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
return;
}
ConsigneeHelper.SetActiveConsignee(name);
showMeContentFromConsigneesTable();
ToastNotification.ShowSuccess($"Аптека по умолчанию: {name}");
}
} }
} }

View File

@ -2,15 +2,8 @@
{ {
partial class FDebug partial class FDebug
{ {
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null; private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing && (components != null)) if (disposing && (components != null))
@ -22,115 +15,267 @@
#region Windows Form Designer generated code #region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent() private void InitializeComponent()
{ {
this.RTXT_Sql = new System.Windows.Forms.RichTextBox(); this.splitMain = new System.Windows.Forms.SplitContainer();
this.label1 = new System.Windows.Forms.Label(); this.listTemplates = new System.Windows.Forms.ListBox();
this.btnCleanSqlScript = new Elfisa.UI.Controls.ModernButton(); this.lblTemplates = new System.Windows.Forms.Label();
this.btnExecuteSqlScript = new Elfisa.UI.Controls.ModernButton(); this.splitRight = new System.Windows.Forms.SplitContainer();
this.panelTop = new System.Windows.Forms.Panel();
this.btnScanParams = new Elfisa.UI.Controls.ModernButton();
this.btnExecuteSelectSqlScript = new Elfisa.UI.Controls.ModernButton(); this.btnExecuteSelectSqlScript = new Elfisa.UI.Controls.ModernButton();
this.btnExecuteSqlScript = new Elfisa.UI.Controls.ModernButton();
this.btnCleanSqlScript = new Elfisa.UI.Controls.ModernButton();
this.lblDbPath = new System.Windows.Forms.Label();
this.label1 = new System.Windows.Forms.Label();
this.RTXT_Sql = new System.Windows.Forms.RichTextBox();
this.lblParams = new System.Windows.Forms.Label();
this.panelParams = new System.Windows.Forms.Panel();
this.dgvSelectCommand = new Elfisa.UI.Controls.ModernDataGridView(); this.dgvSelectCommand = new Elfisa.UI.Controls.ModernDataGridView();
this.lblStatus = new System.Windows.Forms.Label();
((System.ComponentModel.ISupportInitialize)(this.splitMain)).BeginInit();
this.splitMain.Panel1.SuspendLayout();
this.splitMain.Panel2.SuspendLayout();
this.splitMain.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.splitRight)).BeginInit();
this.splitRight.Panel1.SuspendLayout();
this.splitRight.Panel2.SuspendLayout();
this.splitRight.SuspendLayout();
this.panelTop.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.dgvSelectCommand)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.dgvSelectCommand)).BeginInit();
this.SuspendLayout(); this.SuspendLayout();
// //
// RTXT_Sql // splitMain
// //
this.RTXT_Sql.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) this.splitMain.Dock = System.Windows.Forms.DockStyle.Fill;
| System.Windows.Forms.AnchorStyles.Right))); this.splitMain.Location = new System.Drawing.Point(0, 0);
this.RTXT_Sql.Location = new System.Drawing.Point(9, 70); this.splitMain.Name = "splitMain";
this.RTXT_Sql.Margin = new System.Windows.Forms.Padding(4);
this.RTXT_Sql.Name = "RTXT_Sql";
this.RTXT_Sql.Size = new System.Drawing.Size(565, 118);
this.RTXT_Sql.TabIndex = 0;
this.RTXT_Sql.Text = "";
// //
// label1 // splitMain.Panel1
// //
this.label1.AutoSize = true; this.splitMain.Panel1.Controls.Add(this.listTemplates);
this.label1.Location = new System.Drawing.Point(6, 50); this.splitMain.Panel1.Controls.Add(this.lblTemplates);
this.label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.splitMain.Panel1MinSize = 180;
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(84, 16);
this.label1.TabIndex = 1;
this.label1.Text = "SQL-запрос";
// //
// btnCleanSqlScript // splitMain.Panel2
// //
this.btnCleanSqlScript.Location = new System.Drawing.Point(9, 15); this.splitMain.Panel2.Controls.Add(this.splitRight);
this.btnCleanSqlScript.Margin = new System.Windows.Forms.Padding(4); this.splitMain.Size = new System.Drawing.Size(980, 620);
this.btnCleanSqlScript.Name = "btnCleanSqlScript"; this.splitMain.SplitterDistance = 240;
this.btnCleanSqlScript.Size = new System.Drawing.Size(100, 28); this.splitMain.TabIndex = 0;
this.btnCleanSqlScript.TabIndex = 2;
this.btnCleanSqlScript.Text = "Очистить";
this.btnCleanSqlScript.UseVisualStyleBackColor = true;
this.btnCleanSqlScript.Click += new System.EventHandler(this.btnCleanSqlScript_Click);
// //
// btnExecuteSqlScript // listTemplates
// //
this.btnExecuteSqlScript.Location = new System.Drawing.Point(128, 15); this.listTemplates.Dock = System.Windows.Forms.DockStyle.Fill;
this.btnExecuteSqlScript.Margin = new System.Windows.Forms.Padding(4); this.listTemplates.Font = new System.Drawing.Font("Segoe UI", 10F);
this.btnExecuteSqlScript.Name = "btnExecuteSqlScript"; this.listTemplates.FormattingEnabled = true;
this.btnExecuteSqlScript.Size = new System.Drawing.Size(100, 28); this.listTemplates.ItemHeight = 23;
this.btnExecuteSqlScript.TabIndex = 3; this.listTemplates.Location = new System.Drawing.Point(0, 28);
this.btnExecuteSqlScript.Text = "Выполнить"; this.listTemplates.Name = "listTemplates";
this.btnExecuteSqlScript.UseVisualStyleBackColor = true; this.listTemplates.Size = new System.Drawing.Size(240, 592);
this.btnExecuteSqlScript.Click += new System.EventHandler(this.btnExecuteSqlScript_Click); this.listTemplates.TabIndex = 1;
this.listTemplates.SelectedIndexChanged += new System.EventHandler(this.listTemplates_SelectedIndexChanged);
//
// lblTemplates
//
this.lblTemplates.Dock = System.Windows.Forms.DockStyle.Top;
this.lblTemplates.Font = new System.Drawing.Font("Segoe UI Semibold", 10F, System.Drawing.FontStyle.Bold);
this.lblTemplates.Location = new System.Drawing.Point(0, 0);
this.lblTemplates.Name = "lblTemplates";
this.lblTemplates.Padding = new System.Windows.Forms.Padding(8, 4, 0, 0);
this.lblTemplates.Size = new System.Drawing.Size(240, 28);
this.lblTemplates.TabIndex = 0;
this.lblTemplates.Text = "Шаблоны SQL";
//
// splitRight
//
this.splitRight.Dock = System.Windows.Forms.DockStyle.Fill;
this.splitRight.Location = new System.Drawing.Point(0, 0);
this.splitRight.Name = "splitRight";
this.splitRight.Orientation = System.Windows.Forms.Orientation.Horizontal;
//
// splitRight.Panel1
//
this.splitRight.Panel1.Controls.Add(this.RTXT_Sql);
this.splitRight.Panel1.Controls.Add(this.panelParams);
this.splitRight.Panel1.Controls.Add(this.lblParams);
this.splitRight.Panel1.Controls.Add(this.panelTop);
this.splitRight.Panel1MinSize = 220;
//
// splitRight.Panel2
//
this.splitRight.Panel2.Controls.Add(this.dgvSelectCommand);
this.splitRight.Panel2.Controls.Add(this.lblStatus);
this.splitRight.Panel2MinSize = 120;
this.splitRight.Size = new System.Drawing.Size(736, 620);
this.splitRight.SplitterDistance = 320;
this.splitRight.TabIndex = 0;
//
// panelTop
//
this.panelTop.Controls.Add(this.btnScanParams);
this.panelTop.Controls.Add(this.btnExecuteSelectSqlScript);
this.panelTop.Controls.Add(this.btnExecuteSqlScript);
this.panelTop.Controls.Add(this.btnCleanSqlScript);
this.panelTop.Controls.Add(this.lblDbPath);
this.panelTop.Controls.Add(this.label1);
this.panelTop.Dock = System.Windows.Forms.DockStyle.Top;
this.panelTop.Location = new System.Drawing.Point(0, 0);
this.panelTop.Name = "panelTop";
this.panelTop.Size = new System.Drawing.Size(736, 72);
this.panelTop.TabIndex = 0;
//
// btnScanParams
//
this.btnScanParams.Location = new System.Drawing.Point(430, 36);
this.btnScanParams.Name = "btnScanParams";
this.btnScanParams.Size = new System.Drawing.Size(150, 28);
this.btnScanParams.TabIndex = 5;
this.btnScanParams.Text = "Параметры из SQL";
this.btnScanParams.Click += new System.EventHandler(this.btnScanParams_Click);
// //
// btnExecuteSelectSqlScript // btnExecuteSelectSqlScript
// //
this.btnExecuteSelectSqlScript.Location = new System.Drawing.Point(254, 15); this.btnExecuteSelectSqlScript.Location = new System.Drawing.Point(232, 36);
this.btnExecuteSelectSqlScript.Margin = new System.Windows.Forms.Padding(4);
this.btnExecuteSelectSqlScript.Name = "btnExecuteSelectSqlScript"; this.btnExecuteSelectSqlScript.Name = "btnExecuteSelectSqlScript";
this.btnExecuteSelectSqlScript.Size = new System.Drawing.Size(182, 28); this.btnExecuteSelectSqlScript.Size = new System.Drawing.Size(182, 28);
this.btnExecuteSelectSqlScript.TabIndex = 4; this.btnExecuteSelectSqlScript.TabIndex = 4;
this.btnExecuteSelectSqlScript.Text = "Выполнить SELECT"; this.btnExecuteSelectSqlScript.Text = "Выполнить SELECT";
this.btnExecuteSelectSqlScript.UseVisualStyleBackColor = true;
this.btnExecuteSelectSqlScript.Click += new System.EventHandler(this.btnExecuteSelectSqlScript_Click); this.btnExecuteSelectSqlScript.Click += new System.EventHandler(this.btnExecuteSelectSqlScript_Click);
// //
// btnExecuteSqlScript
//
this.btnExecuteSqlScript.Location = new System.Drawing.Point(116, 36);
this.btnExecuteSqlScript.Name = "btnExecuteSqlScript";
this.btnExecuteSqlScript.Size = new System.Drawing.Size(100, 28);
this.btnExecuteSqlScript.TabIndex = 3;
this.btnExecuteSqlScript.Text = "Выполнить";
this.btnExecuteSqlScript.Click += new System.EventHandler(this.btnExecuteSqlScript_Click);
//
// btnCleanSqlScript
//
this.btnCleanSqlScript.Location = new System.Drawing.Point(10, 36);
this.btnCleanSqlScript.Name = "btnCleanSqlScript";
this.btnCleanSqlScript.Size = new System.Drawing.Size(100, 28);
this.btnCleanSqlScript.TabIndex = 2;
this.btnCleanSqlScript.Text = "Очистить";
this.btnCleanSqlScript.Click += new System.EventHandler(this.btnCleanSqlScript_Click);
//
// lblDbPath
//
this.lblDbPath.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.lblDbPath.Location = new System.Drawing.Point(100, 8);
this.lblDbPath.Name = "lblDbPath";
this.lblDbPath.Size = new System.Drawing.Size(620, 20);
this.lblDbPath.TabIndex = 1;
this.lblDbPath.Text = "db";
//
// label1
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(8, 8);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(84, 16);
this.label1.TabIndex = 0;
this.label1.Text = "SQL / база:";
//
// RTXT_Sql
//
this.RTXT_Sql.Dock = System.Windows.Forms.DockStyle.Fill;
this.RTXT_Sql.Font = new System.Drawing.Font("Consolas", 10F);
this.RTXT_Sql.Location = new System.Drawing.Point(0, 100);
this.RTXT_Sql.Name = "RTXT_Sql";
this.RTXT_Sql.Size = new System.Drawing.Size(736, 120);
this.RTXT_Sql.TabIndex = 3;
this.RTXT_Sql.Text = "";
//
// lblParams
//
this.lblParams.Dock = System.Windows.Forms.DockStyle.Top;
this.lblParams.Location = new System.Drawing.Point(0, 72);
this.lblParams.Name = "lblParams";
this.lblParams.Padding = new System.Windows.Forms.Padding(8, 4, 0, 0);
this.lblParams.Size = new System.Drawing.Size(736, 28);
this.lblParams.TabIndex = 1;
this.lblParams.Text = "Параметры (@name / {{name}})";
//
// panelParams
//
this.panelParams.AutoScroll = true;
this.panelParams.Dock = System.Windows.Forms.DockStyle.Bottom;
this.panelParams.Location = new System.Drawing.Point(0, 220);
this.panelParams.Name = "panelParams";
this.panelParams.Size = new System.Drawing.Size(736, 100);
this.panelParams.TabIndex = 2;
//
// dgvSelectCommand // dgvSelectCommand
// //
this.dgvSelectCommand.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) this.dgvSelectCommand.AllowUserToAddRows = false;
| System.Windows.Forms.AnchorStyles.Left) this.dgvSelectCommand.AllowUserToDeleteRows = false;
| System.Windows.Forms.AnchorStyles.Right))); this.dgvSelectCommand.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.DisplayedCells;
this.dgvSelectCommand.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; this.dgvSelectCommand.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.dgvSelectCommand.Location = new System.Drawing.Point(9, 195); this.dgvSelectCommand.Dock = System.Windows.Forms.DockStyle.Fill;
this.dgvSelectCommand.Location = new System.Drawing.Point(0, 0);
this.dgvSelectCommand.Name = "dgvSelectCommand"; this.dgvSelectCommand.Name = "dgvSelectCommand";
this.dgvSelectCommand.ReadOnly = true;
this.dgvSelectCommand.RowHeadersWidth = 51; this.dgvSelectCommand.RowHeadersWidth = 51;
this.dgvSelectCommand.RowTemplate.Height = 24; this.dgvSelectCommand.RowTemplate.Height = 24;
this.dgvSelectCommand.Size = new System.Drawing.Size(562, 150); this.dgvSelectCommand.Size = new System.Drawing.Size(736, 268);
this.dgvSelectCommand.TabIndex = 5; this.dgvSelectCommand.TabIndex = 0;
//
// lblStatus
//
this.lblStatus.Dock = System.Windows.Forms.DockStyle.Bottom;
this.lblStatus.Location = new System.Drawing.Point(0, 268);
this.lblStatus.Name = "lblStatus";
this.lblStatus.Padding = new System.Windows.Forms.Padding(8, 4, 8, 4);
this.lblStatus.Size = new System.Drawing.Size(736, 28);
this.lblStatus.TabIndex = 1;
this.lblStatus.Text = "Готово";
// //
// FDebug // FDebug
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(583, 351); this.ClientSize = new System.Drawing.Size(980, 620);
this.Controls.Add(this.dgvSelectCommand); this.Controls.Add(this.splitMain);
this.Controls.Add(this.btnExecuteSelectSqlScript); this.MinimumSize = new System.Drawing.Size(800, 500);
this.Controls.Add(this.btnExecuteSqlScript);
this.Controls.Add(this.btnCleanSqlScript);
this.Controls.Add(this.label1);
this.Controls.Add(this.RTXT_Sql);
this.Margin = new System.Windows.Forms.Padding(4);
this.Name = "FDebug"; this.Name = "FDebug";
this.Text = "FDebug"; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "SQL-отладка";
this.Load += new System.EventHandler(this.FDebug_Load);
this.splitMain.Panel1.ResumeLayout(false);
this.splitMain.Panel2.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)(this.splitMain)).EndInit();
this.splitMain.ResumeLayout(false);
this.splitRight.Panel1.ResumeLayout(false);
this.splitRight.Panel2.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)(this.splitRight)).EndInit();
this.splitRight.ResumeLayout(false);
this.panelTop.ResumeLayout(false);
this.panelTop.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.dgvSelectCommand)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.dgvSelectCommand)).EndInit();
this.ResumeLayout(false); this.ResumeLayout(false);
this.PerformLayout();
} }
#endregion #endregion
private System.Windows.Forms.RichTextBox RTXT_Sql; private System.Windows.Forms.SplitContainer splitMain;
private System.Windows.Forms.Label label1; private System.Windows.Forms.ListBox listTemplates;
private Elfisa.UI.Controls.ModernButton btnCleanSqlScript; private System.Windows.Forms.Label lblTemplates;
private Elfisa.UI.Controls.ModernButton btnExecuteSqlScript; private System.Windows.Forms.SplitContainer splitRight;
private System.Windows.Forms.Panel panelTop;
private Elfisa.UI.Controls.ModernButton btnScanParams;
private Elfisa.UI.Controls.ModernButton btnExecuteSelectSqlScript; private Elfisa.UI.Controls.ModernButton btnExecuteSelectSqlScript;
private Elfisa.UI.Controls.ModernButton btnExecuteSqlScript;
private Elfisa.UI.Controls.ModernButton btnCleanSqlScript;
private System.Windows.Forms.Label lblDbPath;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.RichTextBox RTXT_Sql;
private System.Windows.Forms.Label lblParams;
private System.Windows.Forms.Panel panelParams;
private Elfisa.UI.Controls.ModernDataGridView dgvSelectCommand; private Elfisa.UI.Controls.ModernDataGridView dgvSelectCommand;
private System.Windows.Forms.Label lblStatus;
} }
} }

View File

@ -1,81 +1,385 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.Data; using System.Data;
using System.Data.SQLite;
using System.Diagnostics;
using System.Drawing; using System.Drawing;
using System.Linq; using System.Linq;
using System.Text; using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using Elfisa.UI.Theming;
using Электроннаяармация.Classes; using Электроннаяармация.Classes;
using System.Data.SQLite;
namespace Электроннаяармация.Forms namespace Электроннаяармация.Forms
{ {
public partial class FDebug : Form public partial class FDebug : Form
{ {
private sealed class SqlTemplate
{
public string Title { get; set; }
public string Sql { get; set; }
public string[] Parameters { get; set; }
}
private readonly List<SqlTemplate> _templates = new List<SqlTemplate>();
private readonly Dictionary<string, TextBox> _paramBoxes = new Dictionary<string, TextBox>(StringComparer.OrdinalIgnoreCase);
private readonly Timer _highlightTimer = new Timer();
private bool _highlighting;
public FDebug() public FDebug()
{ {
InitializeComponent(); InitializeComponent();
_highlightTimer.Interval = 180;
_highlightTimer.Tick += (_, __) =>
{
_highlightTimer.Stop();
ApplySqlHighlight();
};
} }
private void btnExecuteSqlScript_Click(object sender, EventArgs e) private void FDebug_Load(object sender, EventArgs e)
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; UiThemeHelper.ApplyToControlTree(this);
Text = "SQL-отладка";
string sqlScript = RTXT_Sql.Text; lblDbPath.Text = AppConfig.SqliteDbPath;
RTXT_Sql.DetectUrls = false;
RTXT_Sql.AcceptsTab = true;
using (SQLiteConnection debugCon = new SQLiteConnection(connectionStringToLocalDB)) RTXT_Sql.TextChanged += RTXT_Sql_TextChanged;
BuildTemplates();
listTemplates.Items.Clear();
foreach (var template in _templates)
{ {
listTemplates.Items.Add(template.Title);
}
SetStatus("Готово. Выберите шаблон или введите свой SQL.");
ApplySqlHighlight();
FormClosed += (_, __) =>
{
_highlightTimer.Stop();
_highlightTimer.Dispose();
};
}
private void RTXT_Sql_TextChanged(object sender, EventArgs e)
{
if (_highlighting)
{
return;
}
_highlightTimer.Stop();
_highlightTimer.Start();
}
private void ApplySqlHighlight()
{
if (_highlighting || RTXT_Sql == null || RTXT_Sql.IsDisposed)
{
return;
}
_highlighting = true;
try try
{ {
debugCon.Open(); SqlSyntaxHighlighter.Apply(RTXT_Sql, ThemeManager.IsDarkProductivityActive);
SQLiteCommand cmdDebug = new SQLiteCommand(sqlScript, debugCon);
cmdDebug.ExecuteNonQuery();
}
catch (Exception ex)
{
MessageBox.Show($"При выполнении запроса возникла ошибка. Текст ошибки:\n{ex.ToString()}", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
} }
finally finally
{ {
debugCon.Close(); _highlighting = false;
} }
} }
private void BuildTemplates()
{
_templates.Clear();
_templates.Add(new SqlTemplate
{
Title = "Счётчик прайса",
Sql = "SELECT COUNT(*) AS [Позиций] FROM PriceList;"
});
_templates.Add(new SqlTemplate
{
Title = "Прайс по поставщикам",
Sql = @"SELECT SupplierName AS [Поставщик],
COUNT(*) AS [Позиций],
MIN(Price) AS [МинЦена],
MAX(Price) AS [МаксЦена]
FROM PriceList
GROUP BY SupplierName
ORDER BY COUNT(*) DESC;"
});
_templates.Add(new SqlTemplate
{
Title = "Заказы по статусам",
Sql = @"SELECT OrderState AS [Статус], COUNT(*) AS [Кол-во]
FROM Orders
GROUP BY OrderState
ORDER BY COUNT(*) DESC;"
});
_templates.Add(new SqlTemplate
{
Title = "Грузополучатели + LocationId",
Sql = @"SELECT codeConsignees AS [Код],
ConsigneesName AS [Аптека],
ConsigneesAddress AS [Адрес],
ifnull(LocationId, '') AS [LocationId],
ConsigneesUseAsDefault AS [ПоУмолчанию]
FROM Consignees
ORDER BY ConsigneesUseAsDefault DESC, ConsigneesName;"
});
_templates.Add(new SqlTemplate
{
Title = "Корзина TempOrderItems",
Sql = @"SELECT COUNT(*) AS [Строк],
ifnull(SUM(CAST(SummaZakaza AS REAL)), 0) AS [Сумма]
FROM TempOrderItems;"
});
_templates.Add(new SqlTemplate
{
Title = "Поиск по наименованию",
Sql = @"SELECT DrugName, SupplierName, Price, Quantity, supplier_price_id
FROM PriceList
WHERE DrugName LIKE '%' || @q || '%'
ORDER BY DrugName
LIMIT 200;",
Parameters = new[] { "q" }
});
_templates.Add(new SqlTemplate
{
Title = "Последние заказы",
Sql = @"SELECT Id_Order, OrderNumber, OrderDate, SupplierName, ConsigneeName, OrderState, SummaZakaza
FROM Orders
ORDER BY Id_Order DESC
LIMIT 50;"
});
_templates.Add(new SqlTemplate
{
Title = "Список таблиц",
Sql = @"SELECT name AS [Таблица], type AS [Тип]
FROM sqlite_master
WHERE type IN ('table', 'view')
ORDER BY name;"
});
_templates.Add(new SqlTemplate
{
Title = "Позиции заказа",
Sql = @"SELECT oi.Id_Order, oi.DrugName, oi.Zakaz, oi.Price, oi.supplier_price_id, o.ConsigneeName, o.OrderState
FROM OrderItems oi
INNER JOIN Orders o ON o.Id_Order = oi.Id_Order
WHERE oi.Id_Order = @orderId
ORDER BY oi.DrugName;",
Parameters = new[] { "orderId" }
});
_templates.Add(new SqlTemplate
{
Title = "Очистить корзину (DML)",
Sql = "DELETE FROM TempOrderItems;"
});
}
private void listTemplates_SelectedIndexChanged(object sender, EventArgs e)
{
if (listTemplates.SelectedIndex < 0 || listTemplates.SelectedIndex >= _templates.Count)
{
return;
}
var template = _templates[listTemplates.SelectedIndex];
RTXT_Sql.Text = template.Sql;
RebuildParamEditors(template.Parameters);
SetStatus($"Шаблон: {template.Title}");
}
private void RebuildParamEditors(string[] parameters)
{
panelParams.Controls.Clear();
_paramBoxes.Clear();
if (parameters == null || parameters.Length == 0)
{
var empty = new Label
{
Text = "Параметры не требуются. Можно писать {{name}} или @name в SQL.",
AutoSize = true,
MaximumSize = new Size(panelParams.Width - 20, 0),
Location = new Point(8, 8)
};
panelParams.Controls.Add(empty);
return;
}
int y = 8;
foreach (var name in parameters)
{
var lbl = new Label
{
Text = "@" + name,
AutoSize = true,
Location = new Point(8, y + 4)
};
var box = new TextBox
{
Name = "param_" + name,
Width = Math.Max(180, panelParams.Width - 140),
Location = new Point(120, y)
};
panelParams.Controls.Add(lbl);
panelParams.Controls.Add(box);
_paramBoxes[name] = box;
y += 34;
}
} }
private void btnCleanSqlScript_Click(object sender, EventArgs e) private void btnCleanSqlScript_Click(object sender, EventArgs e)
{ {
RTXT_Sql.Text = ""; RTXT_Sql.Text = string.Empty;
dgvSelectCommand.DataSource = null;
RebuildParamEditors(null);
SetStatus("Очищено.");
}
private void btnExecuteSqlScript_Click(object sender, EventArgs e)
{
ExecuteSql(forceSelect: false);
} }
private void btnExecuteSelectSqlScript_Click(object sender, EventArgs e) private void btnExecuteSelectSqlScript_Click(object sender, EventArgs e)
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; ExecuteSql(forceSelect: true);
}
string sqlScript = RTXT_Sql.Text; private void ExecuteSql(bool forceSelect)
{
var sql = (RTXT_Sql.Text ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(sql))
{
SetStatus("Пустой SQL.");
return;
}
using (SQLiteConnection debugSelectCon = new SQLiteConnection(connectionStringToLocalDB)) sql = ApplyMustachePlaceholders(sql);
bool isSelect = forceSelect || LooksLikeSelect(sql);
var sw = Stopwatch.StartNew();
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{ {
try try
{ {
debugSelectCon.Open(); connection.Open();
SQLiteCommand cmdSelectSqlCommand = new SQLiteCommand(sqlScript, debugSelectCon); using (var command = new SQLiteCommand(sql, connection))
SQLiteDataAdapter daSelectDataAdapter = new SQLiteDataAdapter(cmdSelectSqlCommand); {
DataTable tableSelect = new DataTable(); BindParameters(command, sql);
daSelectDataAdapter.Fill(tableSelect);
dgvSelectCommand.DataSource = tableSelect; if (isSelect)
{
using (var adapter = new SQLiteDataAdapter(command))
{
var table = new DataTable();
adapter.Fill(table);
dgvSelectCommand.DataSource = table;
sw.Stop();
SetStatus($"SELECT: {table.Rows.Count} строк за {sw.ElapsedMilliseconds} мс");
}
}
else
{
int affected = command.ExecuteNonQuery();
dgvSelectCommand.DataSource = null;
sw.Stop();
SetStatus($"Выполнено. Затронуто строк: {affected}. {sw.ElapsedMilliseconds} мс");
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show($"Возникла ошибка при заполнении таблицы данными.\nТекст ошибки:\n{ex.ToString()}", "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); sw.Stop();
SetStatus("Ошибка: " + ex.Message);
MessageBox.Show($"При выполнении запроса возникла ошибка.\n{ex}", "SQL-отладка", MessageBoxButtons.OK, MessageBoxIcon.Error);
} }
finally }
}
private static bool LooksLikeSelect(string sql)
{ {
debugSelectCon.Close(); var trimmed = sql.TrimStart();
return trimmed.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("WITH", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("EXPLAIN", StringComparison.OrdinalIgnoreCase);
}
private string ApplyMustachePlaceholders(string sql)
{
return Regex.Replace(sql, @"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}", match =>
{
var name = match.Groups[1].Value;
if (_paramBoxes.TryGetValue(name, out var box))
{
return (box.Text ?? string.Empty).Replace("'", "''");
}
return match.Value;
});
}
private void BindParameters(SQLiteCommand command, string sql)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in Regex.Matches(sql, @"@([A-Za-z_][A-Za-z0-9_]*)"))
{
names.Add(match.Groups[1].Value);
}
foreach (var name in names)
{
object value = string.Empty;
if (_paramBoxes.TryGetValue(name, out var box))
{
value = box.Text ?? string.Empty;
}
command.Parameters.AddWithValue("@" + name, value);
}
// Параметры из панели, даже если в SQL их ещё нет (удобно для шаблонов).
foreach (var pair in _paramBoxes)
{
if (!names.Contains(pair.Key))
{
command.Parameters.AddWithValue("@" + pair.Key, pair.Value.Text ?? string.Empty);
} }
} }
} }
private void SetStatus(string text)
{
lblStatus.Text = text;
}
private void btnScanParams_Click(object sender, EventArgs e)
{
var sql = RTXT_Sql.Text ?? string.Empty;
var names = Regex.Matches(sql, @"@([A-Za-z_][A-Za-z0-9_]*)")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.Concat(Regex.Matches(sql, @"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
.Cast<Match>()
.Select(m => m.Groups[1].Value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var existing = _paramBoxes.ToDictionary(p => p.Key, p => p.Value.Text, StringComparer.OrdinalIgnoreCase);
RebuildParamEditors(names);
foreach (var pair in existing)
{
if (_paramBoxes.TryGetValue(pair.Key, out var box))
{
box.Text = pair.Value;
}
}
SetStatus(names.Length == 0 ? "Параметры в SQL не найдены." : $"Найдено параметров: {names.Length}");
}
} }
} }

View File

@ -45,14 +45,14 @@
this.dgvConsignees.BackgroundColor = System.Drawing.SystemColors.ButtonFace; this.dgvConsignees.BackgroundColor = System.Drawing.SystemColors.ButtonFace;
this.dgvConsignees.BorderStyle = System.Windows.Forms.BorderStyle.None; this.dgvConsignees.BorderStyle = System.Windows.Forms.BorderStyle.None;
this.dgvConsignees.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; this.dgvConsignees.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.dgvConsignees.Location = new System.Drawing.Point(12, 56); this.dgvConsignees.Location = new System.Drawing.Point(16, 56);
this.dgvConsignees.MultiSelect = false; this.dgvConsignees.MultiSelect = false;
this.dgvConsignees.Name = "dgvConsignees"; this.dgvConsignees.Name = "dgvConsignees";
this.dgvConsignees.ReadOnly = true; this.dgvConsignees.ReadOnly = true;
this.dgvConsignees.RowHeadersVisible = false; this.dgvConsignees.RowHeadersVisible = false;
this.dgvConsignees.RowHeadersWidth = 51; this.dgvConsignees.RowHeadersWidth = 51;
this.dgvConsignees.RowTemplate.Height = 24; this.dgvConsignees.RowTemplate.Height = 24;
this.dgvConsignees.Size = new System.Drawing.Size(409, 295); this.dgvConsignees.Size = new System.Drawing.Size(640, 295);
this.dgvConsignees.TabIndex = 0; this.dgvConsignees.TabIndex = 0;
// //
// label1 // label1
@ -69,7 +69,7 @@
// //
this.btnConfirmConsignee.FlatStyle = System.Windows.Forms.FlatStyle.Popup; this.btnConfirmConsignee.FlatStyle = System.Windows.Forms.FlatStyle.Popup;
this.btnConfirmConsignee.Font = new System.Drawing.Font("Segoe UI Semibold", 10.8F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(204))); this.btnConfirmConsignee.Font = new System.Drawing.Font("Segoe UI Semibold", 10.8F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(204)));
this.btnConfirmConsignee.Location = new System.Drawing.Point(336, 361); this.btnConfirmConsignee.Location = new System.Drawing.Point(567, 361);
this.btnConfirmConsignee.Name = "btnConfirmConsignee"; this.btnConfirmConsignee.Name = "btnConfirmConsignee";
this.btnConfirmConsignee.Size = new System.Drawing.Size(89, 43); this.btnConfirmConsignee.Size = new System.Drawing.Size(89, 43);
this.btnConfirmConsignee.TabIndex = 2; this.btnConfirmConsignee.TabIndex = 2;
@ -81,7 +81,7 @@
// //
this.btnCloseThis.FlatStyle = System.Windows.Forms.FlatStyle.Popup; this.btnCloseThis.FlatStyle = System.Windows.Forms.FlatStyle.Popup;
this.btnCloseThis.Font = new System.Drawing.Font("Segoe UI Semibold", 10.8F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(204))); this.btnCloseThis.Font = new System.Drawing.Font("Segoe UI Semibold", 10.8F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(204)));
this.btnCloseThis.Location = new System.Drawing.Point(174, 361); this.btnCloseThis.Location = new System.Drawing.Point(420, 361);
this.btnCloseThis.Name = "btnCloseThis"; this.btnCloseThis.Name = "btnCloseThis";
this.btnCloseThis.Size = new System.Drawing.Size(125, 43); this.btnCloseThis.Size = new System.Drawing.Size(125, 43);
this.btnCloseThis.TabIndex = 3; this.btnCloseThis.TabIndex = 3;
@ -93,7 +93,7 @@
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(434, 412); this.ClientSize = new System.Drawing.Size(672, 412);
this.Controls.Add(this.btnCloseThis); this.Controls.Add(this.btnCloseThis);
this.Controls.Add(this.btnConfirmConsignee); this.Controls.Add(this.btnConfirmConsignee);
this.Controls.Add(this.label1); this.Controls.Add(this.label1);

View File

@ -1,21 +1,16 @@
using System; using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data; using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using Электроннаяармация.Classes;
using System.Data.SQLite; using System.Data.SQLite;
using System.Windows.Forms;
using Электроннаяармация; using Электроннаяармация;
using Электроннаяармация.Classes;
namespace Электроннаяармация.HelpForms namespace Электроннаяармация.HelpForms
{ {
public partial class HFConsignees : Form public partial class HFConsignees : Form
{ {
public ElectroPharmacy ParentForm { get; set; } public new ElectroPharmacy ParentForm { get; set; }
public HFConsignees() public HFConsignees()
{ {
InitializeComponent(); InitializeComponent();
@ -31,28 +26,39 @@ namespace Электронная_Фармация.HelpForms
{ {
conConsigneesList.Open(); conConsigneesList.Open();
string qShowMeConsigneesList = "select ConsigneesName as [Грузополучатель] from Consignees order by ConsigneesUseAsDefault desc"; string qShowMeConsigneesList = @"
select ConsigneesName as [Грузополучатель],
ConsigneesAddress as [Адрес]
from Consignees
order by ConsigneesUseAsDefault desc, ConsigneesName";
SQLiteCommand cmdShowMeConsigneesList = new SQLiteCommand(qShowMeConsigneesList, conConsigneesList); SQLiteCommand cmdShowMeConsigneesList = new SQLiteCommand(qShowMeConsigneesList, conConsigneesList);
SQLiteDataAdapter daConsigneesList = new SQLiteDataAdapter(cmdShowMeConsigneesList); SQLiteDataAdapter daConsigneesList = new SQLiteDataAdapter(cmdShowMeConsigneesList);
DataTable tableConsignees = new DataTable(); DataTable tableConsignees = new DataTable();
daConsigneesList.Fill(tableConsignees); daConsigneesList.Fill(tableConsignees);
dgvConsignees.DataSource = tableConsignees; dgvConsignees.DataSource = tableConsignees;
if (dgvConsignees.Columns.Contains("Грузополучатель"))
{
dgvConsignees.Columns["Грузополучатель"].FillWeight = 45;
}
if (dgvConsignees.Columns.Contains("Адрес"))
{
dgvConsignees.Columns["Адрес"].FillWeight = 55;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
UiDialogs.ShowError($"При загрузке списка получателей возникла ошибка.\n{ex}", "Ошибка", this); UiDialogs.ShowError($"При загрузке списка получателей возникла ошибка.\n{ex}", "Ошибка", this);
} }
finally
{
conConsigneesList.Close();
}
} }
} }
private void btnCloseThis_Click(object sender, EventArgs e) private void btnCloseThis_Click(object sender, EventArgs e)
{ {
this.Close(); DialogResult = DialogResult.Cancel;
Close();
} }
private void btnConfirmConsignee_Click(object sender, EventArgs e) private void btnConfirmConsignee_Click(object sender, EventArgs e)
@ -63,7 +69,27 @@ namespace Электронная_Фармация.HelpForms
return; return;
} }
string consigneesName = dgvConsignees.CurrentRow.Cells[0].Value.ToString(); string consigneesName = dgvConsignees.CurrentRow.Cells[0].Value?.ToString();
if (string.IsNullOrWhiteSpace(consigneesName))
{
UiDialogs.ShowInfo("Выберите грузополучателя из списка.", "Грузополучатель", this);
return;
}
var locationId = ConsigneeHelper.GetLocationIdByName(consigneesName);
if (string.IsNullOrWhiteSpace(locationId))
{
var ask = UiDialogs.ConfirmYesNo(
$"У аптеки «{consigneesName}» не задан Location ID.\nПродолжить без него? Заказы этой аптеки нельзя будет отправить, пока ID не заполните.",
"Location ID",
this);
if (ask != DialogResult.Yes)
{
return;
}
}
ConsigneeHelper.SetActiveConsignee(consigneesName);
ParentForm?.showMePriceList(consigneesName); ParentForm?.showMePriceList(consigneesName);
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();

View File

@ -43,23 +43,26 @@ using Электронная_Фармация.Classes;
using (SQLiteConnection conAddComment = new SQLiteConnection(connectionStringToLocalDB)) using (SQLiteConnection conAddComment = new SQLiteConnection(connectionStringToLocalDB))
{ {
string qShowMeStatusOrder = $"select OrderState from Orders where Id_Order = '{_idOrder}'";
string qAddComment = $"update Orders set Comment = '{rtxtComment.Text}' where Id_Order = '{_idOrder}'";
try try
{ {
conAddComment.Open(); conAddComment.Open();
SQLiteCommand cmdShowMeOrderStatus = new SQLiteCommand(qShowMeStatusOrder, conAddComment); string orderState;
using (var cmdShowMeOrderStatus = new SQLiteCommand("select OrderState from Orders where Id_Order = @id", conAddComment))
string OrderState = cmdShowMeOrderStatus.ExecuteScalar().ToString();
if (OrderState == "НОВЫЙ")
{ {
SQLiteCommand cmdAddComment = new SQLiteCommand(qAddComment, conAddComment); cmdShowMeOrderStatus.Parameters.AddWithValue("@id", _idOrder);
orderState = Convert.ToString(cmdShowMeOrderStatus.ExecuteScalar()) ?? string.Empty;
}
if (orderState == "НОВЫЙ")
{
using (var cmdAddComment = new SQLiteCommand("update Orders set Comment = @comment where Id_Order = @id", conAddComment))
{
cmdAddComment.Parameters.AddWithValue("@comment", rtxtComment.Text);
cmdAddComment.Parameters.AddWithValue("@id", _idOrder);
cmdAddComment.ExecuteNonQuery(); cmdAddComment.ExecuteNonQuery();
} }
}
else else
{ {
UiDialogs.ShowError("Добавить комментарий можно только к заказу со статусом НОВЫЙ.", "Ошибка", this); UiDialogs.ShowError("Добавить комментарий можно только к заказу со статусом НОВЫЙ.", "Ошибка", this);

View File

@ -32,19 +32,64 @@
this.dataGridView1 = new Elfisa.UI.Controls.ModernDataGridView(); this.dataGridView1 = new Elfisa.UI.Controls.ModernDataGridView();
this.DataSetPriceList = new System.Data.DataSet(); this.DataSetPriceList = new System.Data.DataSet();
this.btnCloseThis = new Elfisa.UI.Controls.ModernButton(); this.btnCloseThis = new Elfisa.UI.Controls.ModernButton();
this.lblTitle = new System.Windows.Forms.Label();
this.lblProgress = new System.Windows.Forms.Label();
this.progressDownload = new System.Windows.Forms.ProgressBar();
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.DataSetPriceList)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.DataSetPriceList)).BeginInit();
this.SuspendLayout(); this.SuspendLayout();
// //
// lblTitle
//
this.lblTitle.AutoSize = true;
this.lblTitle.Font = new System.Drawing.Font("Segoe UI Semibold", 12F, System.Drawing.FontStyle.Bold);
this.lblTitle.Location = new System.Drawing.Point(22, 14);
this.lblTitle.Name = "lblTitle";
this.lblTitle.Size = new System.Drawing.Size(220, 28);
this.lblTitle.TabIndex = 5;
this.lblTitle.Text = "Загрузка прайс-листа";
//
// progressDownload
//
this.progressDownload.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.progressDownload.Location = new System.Drawing.Point(25, 52);
this.progressDownload.Name = "progressDownload";
this.progressDownload.Size = new System.Drawing.Size(630, 22);
this.progressDownload.Style = System.Windows.Forms.ProgressBarStyle.Continuous;
this.progressDownload.Maximum = 100;
this.progressDownload.TabIndex = 6;
//
// lblProgress
//
this.lblProgress.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.lblProgress.Font = new System.Drawing.Font("Segoe UI", 9.75F);
this.lblProgress.Location = new System.Drawing.Point(25, 82);
this.lblProgress.Name = "lblProgress";
this.lblProgress.Size = new System.Drawing.Size(630, 40);
this.lblProgress.TabIndex = 7;
this.lblProgress.Text = "Подготовка...";
this.lblProgress.UseMnemonic = false;
//
// rtxtDebug // rtxtDebug
// //
this.rtxtDebug.BackColor = System.Drawing.SystemColors.Control; this.rtxtDebug.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
this.rtxtDebug.BorderStyle = System.Windows.Forms.BorderStyle.None; | System.Windows.Forms.AnchorStyles.Left)
this.rtxtDebug.Location = new System.Drawing.Point(25, 12); | System.Windows.Forms.AnchorStyles.Right)));
this.rtxtDebug.BackColor = System.Drawing.SystemColors.Window;
this.rtxtDebug.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.rtxtDebug.DetectUrls = false;
this.rtxtDebug.Font = new System.Drawing.Font("Consolas", 9F);
this.rtxtDebug.HideSelection = false;
this.rtxtDebug.Location = new System.Drawing.Point(25, 128);
this.rtxtDebug.Name = "rtxtDebug"; this.rtxtDebug.Name = "rtxtDebug";
this.rtxtDebug.Size = new System.Drawing.Size(630, 319); this.rtxtDebug.ReadOnly = true;
this.rtxtDebug.ScrollBars = System.Windows.Forms.RichTextBoxScrollBars.Vertical;
this.rtxtDebug.Size = new System.Drawing.Size(630, 243);
this.rtxtDebug.TabIndex = 0; this.rtxtDebug.TabIndex = 0;
this.rtxtDebug.Text = ""; this.rtxtDebug.Text = "";
this.rtxtDebug.WordWrap = true;
// //
// dataGridView1 // dataGridView1
// //
@ -63,8 +108,9 @@
// //
// btnCloseThis // btnCloseThis
// //
this.btnCloseThis.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.btnCloseThis.Font = new System.Drawing.Font("Segoe UI", 11.25F); this.btnCloseThis.Font = new System.Drawing.Font("Segoe UI", 11.25F);
this.btnCloseThis.Location = new System.Drawing.Point(555, 337); this.btnCloseThis.Location = new System.Drawing.Point(555, 381);
this.btnCloseThis.Margin = new System.Windows.Forms.Padding(4); this.btnCloseThis.Margin = new System.Windows.Forms.Padding(4);
this.btnCloseThis.Name = "btnCloseThis"; this.btnCloseThis.Name = "btnCloseThis";
this.btnCloseThis.Size = new System.Drawing.Size(100, 41); this.btnCloseThis.Size = new System.Drawing.Size(100, 41);
@ -78,12 +124,16 @@
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.BackColor = System.Drawing.SystemColors.Control; this.BackColor = System.Drawing.SystemColors.Control;
this.ClientSize = new System.Drawing.Size(684, 396); this.ClientSize = new System.Drawing.Size(684, 440);
this.Controls.Add(this.lblProgress);
this.Controls.Add(this.progressDownload);
this.Controls.Add(this.lblTitle);
this.Controls.Add(this.btnCloseThis); this.Controls.Add(this.btnCloseThis);
this.Controls.Add(this.dataGridView1); this.Controls.Add(this.dataGridView1);
this.Controls.Add(this.rtxtDebug); this.Controls.Add(this.rtxtDebug);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
this.MaximizeBox = false; this.MaximizeBox = true;
this.MinimumSize = new System.Drawing.Size(640, 420);
this.MinimizeBox = false; this.MinimizeBox = false;
this.Name = "HF_DownloadDataFromServer"; this.Name = "HF_DownloadDataFromServer";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
@ -93,7 +143,7 @@
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.DataSetPriceList)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.DataSetPriceList)).EndInit();
this.ResumeLayout(false); this.ResumeLayout(false);
this.PerformLayout();
} }
#endregion #endregion
@ -102,5 +152,8 @@
private Elfisa.UI.Controls.ModernDataGridView dataGridView1; private Elfisa.UI.Controls.ModernDataGridView dataGridView1;
private System.Data.DataSet DataSetPriceList; private System.Data.DataSet DataSetPriceList;
private Elfisa.UI.Controls.ModernButton btnCloseThis; private Elfisa.UI.Controls.ModernButton btnCloseThis;
private System.Windows.Forms.Label lblTitle;
private System.Windows.Forms.Label lblProgress;
private System.Windows.Forms.ProgressBar progressDownload;
} }
} }

View File

@ -2,7 +2,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Data; using System.Data;
using System.Data.SQLite; using System.Data.SQLite;
using System.Drawing;
using System.Net.Http; using System.Net.Http;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using Электроннаяармация.Classes; using Электроннаяармация.Classes;
@ -12,22 +14,35 @@ namespace Электронная_Фармация.HelpForms
{ {
public partial class HF_DownloadDataFromServer : Form public partial class HF_DownloadDataFromServer : Form
{ {
// Доли общего прогресса 0..100.
private const int DownloadPhaseEnd = 45;
private const int BuildPhaseEnd = 55;
private const int SavePhaseEnd = 100;
public HF_DownloadDataFromServer() public HF_DownloadDataFromServer()
{ {
InitializeComponent(); InitializeComponent();
} }
// Чтобы при обновлении прайса не терять корзины других аптек,
// чистим TempOrderItems только для активного грузополучателя.
public string ActiveConsigneeName { get; set; } = string.Empty;
private readonly string _login = Settings.Default.stringLogin ?? string.Empty; private readonly string _login = Settings.Default.stringLogin ?? string.Empty;
private readonly string _password = Settings.Default.stringPassword ?? string.Empty; private readonly string _password = Settings.Default.stringPassword ?? string.Empty;
private readonly string _connectionStringToLocalDb = AppConfig.SqliteConnectionString; private readonly string _connectionStringToLocalDb = AppConfig.SqliteConnectionString;
private bool _isDownloading;
private async void HF_DownloadDataFromServer_Load(object sender, EventArgs e) private async void HF_DownloadDataFromServer_Load(object sender, EventArgs e)
{ {
UiThemeHelper.ApplyToControlTree(this); UiThemeHelper.ApplyToControlTree(this);
ConfigureLogView();
btnCloseThis.Enabled = false; btnCloseThis.Enabled = false;
dataGridView1.Visible = false; dataGridView1.Visible = false;
rtxtDebug.Visible = true; rtxtDebug.Visible = true;
rtxtDebug.ReadOnly = true; rtxtDebug.ReadOnly = true;
_isDownloading = true;
SetOverallProgress(0, "Подключение к серверу...");
var client = new ApiClient(); var client = new ApiClient();
AppendStatus($"Сервер: {client.BaseUrl}"); AppendStatus($"Сервер: {client.BaseUrl}");
@ -36,40 +51,92 @@ namespace Электронная_Фармация.HelpForms
try try
{ {
SetOverallProgress(1, "Авторизация...");
await EnsureAuthenticatedAsync(client); await EnsureAuthenticatedAsync(client);
AppendStatus("Загружаю сводный прайс (постранично, весь)..."); try
{
AppendStatus("Загружаю грузополучателей с сервера...");
var locations = await client.GetBuyerLocationsAsync();
var synced = ConsigneeHelper.SyncFromServer(locations);
AppendStatus(synced > 0
? $"Грузополучателей обновлено: {synced}"
: "Грузополучателей на сервере пока нет");
}
catch (Exception locEx)
{
AppendStatus($"Грузополучатели не обновлены: {locEx.Message}");
AppDebugLog.Error("Download", "Не удалось синхронизировать грузополучателей", locEx);
}
SetOverallProgress(3, "Загрузка прайса с сервера...");
AppendStatus("Загружаю сводный прайс...");
var allSuppliersSummary = await GetFullPriceSummaryPagedAsync(client); var allSuppliersSummary = await GetFullPriceSummaryPagedAsync(client);
AppendStatus($"Получено {allSuppliersSummary.Summary?.Count ?? 0} позиций"); int totalItems = allSuppliersSummary.Summary?.Count ?? 0;
var tablePrice = BuildPriceTable(allSuppliersSummary); // Наценки прайса/региона/покупателя считает сервер.
SavePriceTable(tablePrice); // Десктоп сохраняет цену из API как есть, без локального пересчёта.
AppendStatus("Цена берётся с сервера без локального пересчёта наценок");
AppendStatus($"Получено {totalItems} позиций");
SetOverallProgress(DownloadPhaseEnd, $"Получено {totalItems} / {totalItems} позиций (100%)");
SetOverallProgress(DownloadPhaseEnd, "Подготовка таблицы...");
var buildProgress = new Progress<CountProgress>(p =>
{
int phasePercent = MapPhasePercent(p.Current, p.Total, DownloadPhaseEnd, BuildPhaseEnd);
SetOverallProgress(
phasePercent,
$"Обработка: {p.Current} / {p.Total} ({PercentOf(p.Current, p.Total)}%)");
});
var tablePrice = await Task.Run(() => BuildPriceTable(allSuppliersSummary, buildProgress));
SetOverallProgress(BuildPhaseEnd, "Сохранение в локальную базу...");
var saveProgress = new Progress<CountProgress>(p =>
{
int phasePercent = MapPhasePercent(p.Current, p.Total, BuildPhaseEnd, SavePhaseEnd);
SetOverallProgress(
phasePercent,
$"Сохранение: {p.Current} / {p.Total} ({PercentOf(p.Current, p.Total)}%)");
});
await Task.Run(() => SavePriceTable(tablePrice, saveProgress));
SetOverallProgress(100, $"Готово: {tablePrice.Rows.Count} / {tablePrice.Rows.Count} (100%)");
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
AppendStatus($"Готово. Сохранено {tablePrice.Rows.Count} позиций в локальную базу."); AppendStatus($"Готово. Сохранено {tablePrice.Rows.Count} позиций в локальную базу.");
ToastNotification.ShowSuccess($"Прайс обновлён: {tablePrice.Rows.Count} позиций"); ToastNotification.ShowSuccess($"Прайс обновлён: {tablePrice.Rows.Count} позиций");
Settings.Default.LastPriceUpdateUtc = DateTime.UtcNow;
Settings.Default.LastPriceReminderUtc = DateTime.MinValue;
Settings.Default.Save();
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
SetOverallProgress(0, "Ошибка авторизации");
AppendStatus($"Ошибка авторизации: {ex.Message}"); AppendStatus($"Ошибка авторизации: {ex.Message}");
AppDebugLog.Error("Download", "Ошибка авторизации при загрузке прайса", ex); AppDebugLog.Error("Download", "Ошибка авторизации при загрузке прайса", ex);
ToastNotification.ShowError($"Ошибка авторизации: {ex.Message}"); ToastNotification.ShowError($"Ошибка авторизации: {ex.Message}");
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
SetOverallProgress(0, "Ошибка сети");
AppendStatus($"Ошибка сети: {ex.Message}"); AppendStatus($"Ошибка сети: {ex.Message}");
AppDebugLog.Error("Download", "Ошибка сети при загрузке прайса", ex); AppDebugLog.Error("Download", "Ошибка сети при загрузке прайса", ex);
ToastNotification.ShowError($"Ошибка сети: {ex.Message}"); ToastNotification.ShowError($"Ошибка сети: {ex.Message}");
} }
catch (Exception ex) catch (Exception ex)
{ {
SetOverallProgress(0, "Ошибка загрузки");
AppendStatus($"Ошибка загрузки прайса: {ex.Message}"); AppendStatus($"Ошибка загрузки прайса: {ex.Message}");
AppDebugLog.Error("Download", "Неожиданная ошибка при загрузке прайса", ex); AppDebugLog.Error("Download", "Неожиданная ошибка при загрузке прайса", ex);
ToastNotification.ShowError($"Ошибка загрузки прайса: {ex.Message}"); ToastNotification.ShowError($"Ошибка загрузки прайса: {ex.Message}");
} }
finally
{
_isDownloading = false;
btnCloseThis.Enabled = true; btnCloseThis.Enabled = true;
} }
}
private async Task EnsureAuthenticatedAsync(ApiClient client) private async Task EnsureAuthenticatedAsync(ApiClient client)
{ {
@ -98,58 +165,228 @@ namespace Электронная_Фармация.HelpForms
AppendStatus("Авторизация успешно пройдена"); AppendStatus("Авторизация успешно пройдена");
} }
// Тянем весь прайс постранично: сервер отдаёт максимум 5000 за запрос,
// поэтому идём offset'ом, пока страница не окажется неполной.
private async Task<PriceSummaryResponse> GetFullPriceSummaryPagedAsync(ApiClient client) private async Task<PriceSummaryResponse> GetFullPriceSummaryPagedAsync(ApiClient client)
{ {
var regionId = string.IsNullOrWhiteSpace(Settings.Default.RegionId) var regionId = string.IsNullOrWhiteSpace(Settings.Default.RegionId)
? null ? null
: Settings.Default.RegionId; : Settings.Default.RegionId;
// Тянем весь прайс одним запросом: постраничный OFFSET на тяжёлом const int supplierPageLimit = 100000;
// сводном запросе валит сервер на глубоких страницах. Один запрос = const int discoveryLimit = 50000;
// один расчёт выборки. Страховка-цикл остаётся на случай, если позиций
// окажется больше pageSize.
const int pageSize = 200000;
const int maxPages = 100;
var combined = new PriceSummaryResponse { Summary = new List<PriceSummaryItem>() }; var combined = new PriceSummaryResponse { Summary = new List<PriceSummaryItem>() };
var transferProgress = new Progress<HttpTransferProgress>(ReportTransferProgress);
for (int page = 0; page < maxPages; page++) var supplierIds = new List<string>(ConsigneeHelper.GetKnownSupplierIds());
if (supplierIds.Count == 0)
{ {
int offset = page * pageSize; AppendStatus("Первая загрузка: определяю список поставщиков...");
SetOverallProgress(3, "Определение поставщиков...");
var discovery = await GetPriceSummaryWithAuthRetryAsync(
client,
supplierId: null,
regionId: regionId,
limit: discoveryLimit,
offset: 0,
transferProgress: transferProgress);
if (discovery?.MarkupPercent.HasValue == true)
{
combined.MarkupPercent = discovery.MarkupPercent;
}
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (discovery?.Summary != null)
{
foreach (var item in discovery.Summary)
{
if (!string.IsNullOrWhiteSpace(item.SupplierId))
{
discovered.Add(item.SupplierId.Trim());
}
}
}
supplierIds.AddRange(discovered);
AppendStatus($"Найдено поставщиков: {supplierIds.Count}");
}
if (supplierIds.Count == 0)
{
AppendStatus("Поставщики не найдены, пробую полный запрос...");
var fallback = await GetPriceSummaryWithAuthRetryAsync(
client,
supplierId: null,
regionId: regionId,
limit: 200000,
offset: 0,
transferProgress: transferProgress);
if (fallback?.MarkupPercent.HasValue == true)
{
combined.MarkupPercent = fallback.MarkupPercent;
}
if (fallback?.Summary != null)
{
combined.Summary.AddRange(fallback.Summary);
}
return combined;
}
int totalSuppliers = supplierIds.Count;
for (int i = 0; i < totalSuppliers; i++)
{
var supplierId = supplierIds[i];
int supplierProgressStart = 3 + (int)((DownloadPhaseEnd - 3) * ((double)i / totalSuppliers));
int supplierProgressEnd = 3 + (int)((DownloadPhaseEnd - 3) * ((double)(i + 1) / totalSuppliers));
SetOverallProgress(
supplierProgressStart,
$"Поставщик {i + 1}/{totalSuppliers}...");
AppendStatus($"Загрузка поставщика {i + 1}/{totalSuppliers}: {supplierId}");
PriceSummaryResponse chunk; PriceSummaryResponse chunk;
try try
{ {
chunk = await client.GetPriceSummaryAsync(supplierId: null, regionId: regionId, limit: pageSize, offset: offset); chunk = await GetPriceSummaryWithAuthRetryAsync(
client,
supplierId: supplierId,
regionId: regionId,
limit: supplierPageLimit,
offset: 0,
transferProgress: transferProgress);
} }
catch (UnauthorizedAccessException) catch (Exception ex)
{ {
AppendStatus("Токен недействителен, повторная авторизация..."); AppendStatus($"Ошибка поставщика {supplierId}: {ex.Message}");
Settings.Default.stringToken = string.Empty; AppDebugLog.Error("Download", $"Ошибка загрузки прайса поставщика {supplierId}", ex);
Settings.Default.Save(); continue;
client.SetToken(null); }
await LoginAndSaveTokenAsync(client);
chunk = await client.GetPriceSummaryAsync(supplierId: null, regionId: regionId, limit: pageSize, offset: offset); if (i == 0 && chunk?.MarkupPercent.HasValue == true && !combined.MarkupPercent.HasValue)
{
combined.MarkupPercent = chunk.MarkupPercent;
} }
int got = chunk?.Summary?.Count ?? 0; int got = chunk?.Summary?.Count ?? 0;
if (got > 0) if (got > 0)
{ {
combined.Summary.AddRange(chunk.Summary); combined.Summary.AddRange(chunk.Summary);
AppendStatus($"Загружено {combined.Summary.Count} позиций...");
} }
if (got < pageSize) SetOverallProgress(
{ Math.Max(supplierProgressStart, supplierProgressEnd - 1),
break; // последняя (неполная) страница — дальше данных нет $"Поставщик {i + 1}/{totalSuppliers}: +{got}, всего {combined.Summary.Count}");
AppendStatus($"Поставщик {i + 1}/{totalSuppliers}: получено {got}, всего {combined.Summary.Count}");
} }
// Лёгкий discovery новых поставщиков, которых ещё не было в локальной базе.
try
{
AppendStatus("Проверка новых поставщиков...");
var probe = await GetPriceSummaryWithAuthRetryAsync(
client,
supplierId: null,
regionId: regionId,
limit: 5000,
offset: 0,
transferProgress: null);
var known = new HashSet<string>(supplierIds, StringComparer.OrdinalIgnoreCase);
var newcomers = new List<string>();
if (probe?.Summary != null)
{
foreach (var item in probe.Summary)
{
if (!string.IsNullOrWhiteSpace(item.SupplierId) && known.Add(item.SupplierId.Trim()))
{
newcomers.Add(item.SupplierId.Trim());
}
}
}
for (int i = 0; i < newcomers.Count; i++)
{
var supplierId = newcomers[i];
AppendStatus($"Новый поставщик {i + 1}/{newcomers.Count}: {supplierId}");
var chunk = await GetPriceSummaryWithAuthRetryAsync(
client,
supplierId: supplierId,
regionId: regionId,
limit: supplierPageLimit,
offset: 0,
transferProgress: transferProgress);
int got = chunk?.Summary?.Count ?? 0;
if (got > 0)
{
combined.Summary.AddRange(chunk.Summary);
}
AppendStatus($"Новый поставщик: +{got}, всего {combined.Summary.Count}");
}
}
catch (Exception ex)
{
AppendStatus($"Проверка новых поставщиков пропущена: {ex.Message}");
AppDebugLog.Error("Download", "Ошибка discovery новых поставщиков", ex);
} }
return combined; return combined;
} }
private static DataTable BuildPriceTable(PriceSummaryResponse allSuppliersSummary) private async Task<PriceSummaryResponse> GetPriceSummaryWithAuthRetryAsync(
ApiClient client,
string supplierId,
string regionId,
int limit,
int offset,
IProgress<HttpTransferProgress> transferProgress)
{
try
{
return await client.GetPriceSummaryAsync(
supplierId: supplierId,
regionId: regionId,
limit: limit,
offset: offset,
transferProgress: transferProgress);
}
catch (UnauthorizedAccessException)
{
AppendStatus("Токен недействителен, повторная авторизация...");
SetOverallProgress(2, "Повторная авторизация...");
Settings.Default.stringToken = string.Empty;
Settings.Default.Save();
client.SetToken(null);
await LoginAndSaveTokenAsync(client);
return await client.GetPriceSummaryAsync(
supplierId: supplierId,
regionId: regionId,
limit: limit,
offset: offset,
transferProgress: transferProgress);
}
}
private void ReportTransferProgress(HttpTransferProgress transfer)
{
if (transfer.TotalBytes.HasValue && transfer.TotalBytes.Value > 0)
{
int phasePercent = MapPhasePercent(transfer.BytesReceived, transfer.TotalBytes.Value, 3, DownloadPhaseEnd);
SetOverallProgress(
phasePercent,
$"Скачивание: {FormatBytes(transfer.BytesReceived)} / {FormatBytes(transfer.TotalBytes.Value)} ({transfer.Percent}%)");
}
else
{
SetOverallProgress(
Math.Min(DownloadPhaseEnd - 1, 5),
$"Скачивание: получено {FormatBytes(transfer.BytesReceived)}...");
}
}
private static DataTable BuildPriceTable(
PriceSummaryResponse allSuppliersSummary,
IProgress<CountProgress> progress = null)
{ {
var tablePrice = new DataTable(); var tablePrice = new DataTable();
tablePrice.Columns.Add("supplier_price_id"); tablePrice.Columns.Add("supplier_price_id");
@ -186,12 +423,20 @@ namespace Электронная_Фармация.HelpForms
DataType = typeof(string), DataType = typeof(string),
AllowDBNull = true AllowDBNull = true
}); });
tablePrice.Columns.Add(new DataColumn
{
ColumnName = "MarkupPercent",
DataType = typeof(decimal),
AllowDBNull = true
});
if (allSuppliersSummary?.Summary == null) if (allSuppliersSummary?.Summary == null)
{ {
return tablePrice; return tablePrice;
} }
int total = allSuppliersSummary.Summary.Count;
int current = 0;
foreach (var item in allSuppliersSummary.Summary) foreach (var item in allSuppliersSummary.Summary)
{ {
var row = tablePrice.NewRow(); var row = tablePrice.NewRow();
@ -201,7 +446,10 @@ namespace Электронная_Фармация.HelpForms
row["supplier_id"] = item.SupplierId ?? string.Empty; row["supplier_id"] = item.SupplierId ?? string.Empty;
row["DrugName"] = item.DrugName ?? string.Empty; row["DrugName"] = item.DrugName ?? string.Empty;
row["SupplierName"] = item.SupplierName ?? string.Empty; row["SupplierName"] = item.SupplierName ?? string.Empty;
row["Price"] = item.Price?.ToString() ?? string.Empty;
// Цена уже итоговая с сервера — локально не пересчитываем.
var serverPrice = item.Price ?? 0m;
row["Price"] = serverPrice.ToString(System.Globalization.CultureInfo.InvariantCulture);
row["Quantity"] = item.Quantity?.ToString() ?? string.Empty; row["Quantity"] = item.Quantity?.ToString() ?? string.Empty;
row["ExpiryPeriod"] = item.ExpiryPeriod == null ? (object)DBNull.Value : item.ExpiryPeriod; row["ExpiryPeriod"] = item.ExpiryPeriod == null ? (object)DBNull.Value : item.ExpiryPeriod;
row["Description"] = item.Description == null ? (object)DBNull.Value : item.Description; row["Description"] = item.Description == null ? (object)DBNull.Value : item.Description;
@ -209,16 +457,27 @@ namespace Электронная_Фармация.HelpForms
row["SummaZakaza"] = string.Empty; row["SummaZakaza"] = string.Empty;
row["TradeName"] = string.IsNullOrWhiteSpace(item.TradeName) ? (object)DBNull.Value : item.TradeName.Trim(); row["TradeName"] = string.IsNullOrWhiteSpace(item.TradeName) ? (object)DBNull.Value : item.TradeName.Trim();
row["Dosage"] = string.IsNullOrWhiteSpace(item.Dosage) ? (object)DBNull.Value : item.Dosage.Trim(); row["Dosage"] = string.IsNullOrWhiteSpace(item.Dosage) ? (object)DBNull.Value : item.Dosage.Trim();
row["MarkupPercent"] = item.MarkupPercent.HasValue && item.MarkupPercent.Value > 0m
? item.MarkupPercent.Value
: 0m;
tablePrice.Rows.Add(row); tablePrice.Rows.Add(row);
current++;
if (progress != null && (current % 500 == 0 || current == total))
{
progress.Report(new CountProgress(current, total));
}
} }
return tablePrice; return tablePrice;
} }
private void SavePriceTable(DataTable tablePrice) private void SavePriceTable(DataTable tablePrice, IProgress<CountProgress> progress = null)
{ {
const string commandToDelete = "delete from [PriceList]"; const string commandToDelete = "delete from [PriceList]";
const string commandToDeleteTempOrder = "delete from [TempOrderItems]"; var commandToDeleteTempOrder = string.IsNullOrWhiteSpace(ActiveConsigneeName)
? "delete from [TempOrderItems]"
: "delete from [TempOrderItems] where [ConsigneeName] = @consignee";
const string commandToInsert = @"INSERT INTO [PriceList] ( const string commandToInsert = @"INSERT INTO [PriceList] (
[guid_es], [guid_es],
[es_code], [es_code],
@ -233,7 +492,8 @@ namespace Электронная_Фармация.HelpForms
[SummaZakaza], [SummaZakaza],
[supplier_price_id], [supplier_price_id],
[TradeName], [TradeName],
[Dosage] [Dosage],
[MarkupPercent]
) )
VALUES VALUES
( (
@ -250,9 +510,11 @@ VALUES
@SummaZakaza, @SummaZakaza,
@supplier_price_id, @supplier_price_id,
@TradeName, @TradeName,
@Dosage @Dosage,
@MarkupPercent
)"; )";
int total = tablePrice.Rows.Count;
using (var sqliteCon = new SQLiteConnection(_connectionStringToLocalDb)) using (var sqliteCon = new SQLiteConnection(_connectionStringToLocalDb))
{ {
sqliteCon.Open(); sqliteCon.Open();
@ -261,30 +523,63 @@ VALUES
using (var cmdDeleteTempOrder = new SQLiteCommand(commandToDeleteTempOrder, sqliteCon)) using (var cmdDeleteTempOrder = new SQLiteCommand(commandToDeleteTempOrder, sqliteCon))
{ {
cmdDeleteBeforeInsert.ExecuteNonQuery(); cmdDeleteBeforeInsert.ExecuteNonQuery();
if (!string.IsNullOrWhiteSpace(ActiveConsigneeName) &&
commandToDeleteTempOrder.Contains("@consignee"))
{
cmdDeleteTempOrder.Parameters.AddWithValue("@consignee", ActiveConsigneeName.Trim());
}
cmdDeleteTempOrder.ExecuteNonQuery(); cmdDeleteTempOrder.ExecuteNonQuery();
} }
using (var transaction = sqliteCon.BeginTransaction()) using (var transaction = sqliteCon.BeginTransaction())
{
foreach (DataRow row in tablePrice.Rows)
{
using (var cmd = new SQLiteCommand(commandToInsert, sqliteCon, transaction)) using (var cmd = new SQLiteCommand(commandToInsert, sqliteCon, transaction))
{ {
cmd.Parameters.AddWithValue("@guid_es", row["guid_es"]); var pGuidEs = cmd.Parameters.Add("@guid_es", DbType.String);
cmd.Parameters.AddWithValue("@es_code", row["es_code"]); var pEsCode = cmd.Parameters.Add("@es_code", DbType.String);
cmd.Parameters.AddWithValue("@supplier_id", row["supplier_id"]); var pSupplierId = cmd.Parameters.Add("@supplier_id", DbType.String);
cmd.Parameters.AddWithValue("@DrugName", row["DrugName"]); var pDrugName = cmd.Parameters.Add("@DrugName", DbType.String);
cmd.Parameters.AddWithValue("@SupplierName", row["SupplierName"]); var pSupplierName = cmd.Parameters.Add("@SupplierName", DbType.String);
cmd.Parameters.AddWithValue("@Price", row["Price"]); var pPrice = cmd.Parameters.Add("@Price", DbType.String);
cmd.Parameters.AddWithValue("@Quantity", row["Quantity"]); var pQuantity = cmd.Parameters.Add("@Quantity", DbType.String);
cmd.Parameters.AddWithValue("@ExpiryPeriod", row["ExpiryPeriod"]); var pExpiry = cmd.Parameters.Add("@ExpiryPeriod", DbType.String);
cmd.Parameters.AddWithValue("@Description", row["Description"]); var pDescription = cmd.Parameters.Add("@Description", DbType.String);
cmd.Parameters.AddWithValue("@Zakaz", row["Zakaz"]); var pZakaz = cmd.Parameters.Add("@Zakaz", DbType.String);
cmd.Parameters.AddWithValue("@SummaZakaza", row["SummaZakaza"]); var pSumma = cmd.Parameters.Add("@SummaZakaza", DbType.String);
cmd.Parameters.AddWithValue("@supplier_price_id", row["supplier_price_id"]); var pSupplierPriceId = cmd.Parameters.Add("@supplier_price_id", DbType.String);
cmd.Parameters.AddWithValue("@TradeName", row.Table.Columns.Contains("TradeName") ? row["TradeName"] : DBNull.Value); var pTradeName = cmd.Parameters.Add("@TradeName", DbType.String);
cmd.Parameters.AddWithValue("@Dosage", row.Table.Columns.Contains("Dosage") ? row["Dosage"] : DBNull.Value); var pDosage = cmd.Parameters.Add("@Dosage", DbType.String);
var pMarkup = cmd.Parameters.Add("@MarkupPercent", DbType.Object);
int saved = 0;
bool hasTradeName = tablePrice.Columns.Contains("TradeName");
bool hasDosage = tablePrice.Columns.Contains("Dosage");
bool hasMarkup = tablePrice.Columns.Contains("MarkupPercent");
foreach (DataRow row in tablePrice.Rows)
{
pGuidEs.Value = row["guid_es"] ?? DBNull.Value;
pEsCode.Value = row["es_code"] ?? DBNull.Value;
pSupplierId.Value = row["supplier_id"] ?? DBNull.Value;
pDrugName.Value = row["DrugName"] ?? DBNull.Value;
pSupplierName.Value = row["SupplierName"] ?? DBNull.Value;
pPrice.Value = row["Price"] ?? DBNull.Value;
pQuantity.Value = row["Quantity"] ?? DBNull.Value;
pExpiry.Value = row["ExpiryPeriod"] ?? DBNull.Value;
pDescription.Value = row["Description"] ?? DBNull.Value;
pZakaz.Value = row["Zakaz"] ?? DBNull.Value;
pSumma.Value = row["SummaZakaza"] ?? DBNull.Value;
pSupplierPriceId.Value = row["supplier_price_id"] ?? DBNull.Value;
pTradeName.Value = hasTradeName ? (row["TradeName"] ?? DBNull.Value) : DBNull.Value;
pDosage.Value = hasDosage ? (row["Dosage"] ?? DBNull.Value) : DBNull.Value;
pMarkup.Value = hasMarkup ? (row["MarkupPercent"] ?? DBNull.Value) : DBNull.Value;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
saved++;
if (progress != null && (saved % 250 == 0 || saved == total))
{
progress.Report(new CountProgress(saved, total));
} }
} }
@ -293,6 +588,131 @@ VALUES
} }
} }
private static int MapPhasePercent(long current, long total, int phaseStart, int phaseEnd)
{
if (total <= 0)
{
return phaseStart;
}
double ratio = Math.Max(0.0, Math.Min(1.0, (double)current / total));
return phaseStart + (int)((phaseEnd - phaseStart) * ratio);
}
private static int PercentOf(long current, long total)
{
if (total <= 0)
{
return 0;
}
return (int)Math.Max(0, Math.Min(100, (100.0 * current) / total));
}
private static string FormatBytes(long bytes)
{
if (bytes < 1024)
{
return $"{bytes} Б";
}
if (bytes < 1024 * 1024)
{
return $"{bytes / 1024.0:0.0} КБ";
}
return $"{bytes / (1024.0 * 1024.0):0.00} МБ";
}
private void SetOverallProgress(int percent, string text)
{
if (IsDisposed)
{
return;
}
if (InvokeRequired)
{
BeginInvoke(new Action<int, string>(SetOverallProgress), percent, text);
return;
}
if (progressDownload.Style != ProgressBarStyle.Continuous)
{
progressDownload.Style = ProgressBarStyle.Continuous;
}
progressDownload.Minimum = 0;
progressDownload.Maximum = 100;
progressDownload.Value = Math.Max(0, Math.Min(100, percent));
lblProgress.Text = FormatProgressText(percent, text);
lblProgress.Refresh();
progressDownload.Refresh();
}
private void ConfigureLogView()
{
rtxtDebug.ReadOnly = true;
rtxtDebug.WordWrap = true;
rtxtDebug.ScrollBars = RichTextBoxScrollBars.Vertical;
rtxtDebug.HideSelection = false;
rtxtDebug.DetectUrls = false;
rtxtDebug.BorderStyle = BorderStyle.FixedSingle;
if (rtxtDebug.Font.Name != "Consolas")
{
rtxtDebug.Font = new Font("Consolas", 9f);
}
lblProgress.AutoEllipsis = false;
lblProgress.UseMnemonic = false;
}
private static string FormatProgressText(int percent, string text)
{
return WrapLongText($"{Math.Max(0, Math.Min(100, percent))}% — {text}", 78, 2);
}
private static string WrapLongText(string text, int maxLineLength, int maxLines)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLineLength)
{
return text ?? string.Empty;
}
var lines = new List<string>();
var remaining = text.Trim();
while (!string.IsNullOrEmpty(remaining) && lines.Count < maxLines)
{
if (remaining.Length <= maxLineLength)
{
lines.Add(remaining);
break;
}
var breakIndex = remaining.LastIndexOf(' ', Math.Min(maxLineLength, remaining.Length - 1));
if (breakIndex <= 0)
{
breakIndex = maxLineLength;
}
lines.Add(remaining.Substring(0, breakIndex).TrimEnd());
remaining = remaining.Substring(breakIndex).TrimStart();
}
if (!string.IsNullOrEmpty(remaining) && lines.Count >= maxLines)
{
var lastLine = lines[lines.Count - 1];
if (lastLine.Length > maxLineLength - 3)
{
lastLine = lastLine.Substring(0, Math.Max(0, maxLineLength - 3)).TrimEnd();
}
lines[lines.Count - 1] = lastLine + "...";
}
return string.Join(Environment.NewLine, lines);
}
private void AppendStatus(string message) private void AppendStatus(string message)
{ {
if (InvokeRequired) if (InvokeRequired)
@ -301,8 +721,10 @@ VALUES
return; return;
} }
rtxtDebug.AppendText(message + Environment.NewLine); var wrapped = WrapLongText(message ?? string.Empty, 96, int.MaxValue);
rtxtDebug.AppendText(wrapped + Environment.NewLine);
rtxtDebug.SelectionStart = rtxtDebug.TextLength; rtxtDebug.SelectionStart = rtxtDebug.TextLength;
rtxtDebug.SelectionLength = 0;
rtxtDebug.ScrollToCaret(); rtxtDebug.ScrollToCaret();
rtxtDebug.Refresh(); rtxtDebug.Refresh();
AppDebugLog.Info("Download", message); AppDebugLog.Info("Download", message);
@ -310,13 +732,29 @@ VALUES
private void HF_DownloadDataFromServer_FormClosing(object sender, FormClosingEventArgs e) private void HF_DownloadDataFromServer_FormClosing(object sender, FormClosingEventArgs e)
{ {
Dispose(); if (_isDownloading)
Close(); {
e.Cancel = true;
}
} }
private void btnCloseThis_Click(object sender, EventArgs e) private void btnCloseThis_Click(object sender, EventArgs e)
{ {
Close(); Close();
} }
private sealed class CountProgress
{
public CountProgress(int current, int total)
{
Current = current;
Total = total;
}
public int Current { get; }
public int Total { get; }
} }
} }
}

View File

@ -55,40 +55,58 @@ namespace Электронная_Фармация.HelpForms
this.lblHint.Size = new System.Drawing.Size(420, 48); this.lblHint.Size = new System.Drawing.Size(420, 48);
this.lblHint.TabIndex = 1; this.lblHint.TabIndex = 1;
this.lblHint.Text = "Укажите UUID торговой точки. Он будет использоваться при отправке заказов на сервер."; this.lblHint.Text = "Укажите UUID торговой точки. Он будет использоваться при отправке заказов на сервер.";
// lblConsigneeName
this.lblConsigneeName.AutoSize = true;
this.lblConsigneeName.Font = new System.Drawing.Font("Segoe UI Semibold", 10F, System.Drawing.FontStyle.Bold);
this.lblConsigneeName.Location = new System.Drawing.Point(20, 128);
this.lblConsigneeName.Name = "lblConsigneeName";
this.lblConsigneeName.Size = new System.Drawing.Size(0, 0);
this.lblConsigneeName.TabIndex = 2;
this.lblConsigneeName.Text = "";
// lblConsigneeAddress
this.lblConsigneeAddress.AutoSize = true;
this.lblConsigneeAddress.Font = new System.Drawing.Font("Segoe UI", 10F);
this.lblConsigneeAddress.Location = new System.Drawing.Point(20, 150);
this.lblConsigneeAddress.Name = "lblConsigneeAddress";
this.lblConsigneeAddress.Size = new System.Drawing.Size(0, 0);
this.lblConsigneeAddress.TabIndex = 3;
this.lblConsigneeAddress.Text = "";
// //
// lblLocationId // lblLocationId
// //
this.lblLocationId.AutoSize = true; this.lblLocationId.AutoSize = true;
this.lblLocationId.Font = new System.Drawing.Font("Segoe UI", 12F); this.lblLocationId.Font = new System.Drawing.Font("Segoe UI", 12F);
this.lblLocationId.Location = new System.Drawing.Point(20, 128); this.lblLocationId.Location = new System.Drawing.Point(20, 182);
this.lblLocationId.Name = "lblLocationId"; this.lblLocationId.Name = "lblLocationId";
this.lblLocationId.Size = new System.Drawing.Size(108, 28); this.lblLocationId.Size = new System.Drawing.Size(108, 28);
this.lblLocationId.TabIndex = 2; this.lblLocationId.TabIndex = 4;
this.lblLocationId.Text = "Location ID"; this.lblLocationId.Text = "Location ID";
// //
// txtLocationId // txtLocationId
// //
this.txtLocationId.Font = new System.Drawing.Font("Segoe UI", 12F); this.txtLocationId.Font = new System.Drawing.Font("Segoe UI", 12F);
this.txtLocationId.Location = new System.Drawing.Point(24, 160); this.txtLocationId.Location = new System.Drawing.Point(24, 214);
this.txtLocationId.Name = "txtLocationId"; this.txtLocationId.Name = "txtLocationId";
this.txtLocationId.Size = new System.Drawing.Size(412, 34); this.txtLocationId.Size = new System.Drawing.Size(412, 34);
this.txtLocationId.TabIndex = 3; this.txtLocationId.TabIndex = 5;
// //
// btnCancel // btnCancel
// //
this.btnCancel.Location = new System.Drawing.Point(24, 220); this.btnCancel.Location = new System.Drawing.Point(24, 275);
this.btnCancel.Name = "btnCancel"; this.btnCancel.Name = "btnCancel";
this.btnCancel.Size = new System.Drawing.Size(125, 40); this.btnCancel.Size = new System.Drawing.Size(125, 40);
this.btnCancel.TabIndex = 4; this.btnCancel.TabIndex = 6;
this.btnCancel.Text = "Отмена"; this.btnCancel.Text = "Отмена";
this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click); this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click);
// //
// btnSave // btnSave
// //
this.btnSave.Location = new System.Drawing.Point(221, 220); this.btnSave.Location = new System.Drawing.Point(221, 275);
this.btnSave.Name = "btnSave"; this.btnSave.Name = "btnSave";
this.btnSave.Size = new System.Drawing.Size(215, 40); this.btnSave.Size = new System.Drawing.Size(215, 40);
this.btnSave.TabIndex = 5; this.btnSave.TabIndex = 7;
this.btnSave.Text = "Сохранить"; this.btnSave.Text = "Сохранить";
this.btnSave.Click += new System.EventHandler(this.btnSave_Click); this.btnSave.Click += new System.EventHandler(this.btnSave_Click);
// //
@ -98,10 +116,12 @@ namespace Электронная_Фармация.HelpForms
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.CancelButton = this.btnCancel; this.CancelButton = this.btnCancel;
this.ClientSize = new System.Drawing.Size(460, 280); this.ClientSize = new System.Drawing.Size(460, 330);
this.Controls.Add(this.btnSave); this.Controls.Add(this.btnSave);
this.Controls.Add(this.btnCancel); this.Controls.Add(this.btnCancel);
this.Controls.Add(this.txtLocationId); this.Controls.Add(this.txtLocationId);
this.Controls.Add(this.lblConsigneeAddress);
this.Controls.Add(this.lblConsigneeName);
this.Controls.Add(this.lblLocationId); this.Controls.Add(this.lblLocationId);
this.Controls.Add(this.lblHint); this.Controls.Add(this.lblHint);
this.Controls.Add(this.panelHeader); this.Controls.Add(this.panelHeader);
@ -123,6 +143,8 @@ namespace Электронная_Фармация.HelpForms
private System.Windows.Forms.Panel panelHeader; private System.Windows.Forms.Panel panelHeader;
private System.Windows.Forms.Label lblTitle; private System.Windows.Forms.Label lblTitle;
private System.Windows.Forms.Label lblHint; private System.Windows.Forms.Label lblHint;
private System.Windows.Forms.Label lblConsigneeName;
private System.Windows.Forms.Label lblConsigneeAddress;
private System.Windows.Forms.Label lblLocationId; private System.Windows.Forms.Label lblLocationId;
private Elfisa.UI.Controls.ModernTextBox txtLocationId; private Elfisa.UI.Controls.ModernTextBox txtLocationId;
private Elfisa.UI.Controls.ModernButton btnCancel; private Elfisa.UI.Controls.ModernButton btnCancel;

View File

@ -10,6 +10,9 @@ namespace Электронная_Фармация.HelpForms
{ {
public partial class HF_LocationId : Form public partial class HF_LocationId : Form
{ {
private string _consigneeName = string.Empty;
private string _consigneeAddress = string.Empty;
public HF_LocationId() public HF_LocationId()
{ {
InitializeComponent(); InitializeComponent();
@ -28,6 +31,20 @@ namespace Электронная_Фармация.HelpForms
} }
} }
/// <summary>
/// Показывает Location ID с контекстом: выбранная аптека (имя/адрес).
/// </summary>
public static bool PromptAndSave(IWin32Window owner, string consigneeName, string consigneeAddress)
{
using (var form = new HF_LocationId())
{
form._consigneeName = consigneeName ?? string.Empty;
form._consigneeAddress = consigneeAddress ?? string.Empty;
UiThemeHelper.ApplyToControlTree(form);
return form.ShowDialog(owner) == DialogResult.OK;
}
}
private void HF_LocationId_Load(object sender, EventArgs e) private void HF_LocationId_Load(object sender, EventArgs e)
{ {
UiThemeHelper.ApplyToControlTree(this); UiThemeHelper.ApplyToControlTree(this);
@ -35,6 +52,18 @@ namespace Электронная_Фармация.HelpForms
txtLocationId.Text = Settings.Default.LocationId ?? string.Empty; txtLocationId.Text = Settings.Default.LocationId ?? string.Empty;
txtLocationId.SelectAll(); txtLocationId.SelectAll();
txtLocationId.Focus(); txtLocationId.Focus();
if (lblConsigneeName != null)
{
lblConsigneeName.Text = _consigneeName;
lblConsigneeName.Visible = !string.IsNullOrWhiteSpace(_consigneeName);
}
if (lblConsigneeAddress != null)
{
lblConsigneeAddress.Text = _consigneeAddress;
lblConsigneeAddress.Visible = !string.IsNullOrWhiteSpace(_consigneeAddress);
}
} }
private void btnSave_Click(object sender, EventArgs e) private void btnSave_Click(object sender, EventArgs e)

View File

@ -38,8 +38,6 @@
this.checkTokenGained = new System.Windows.Forms.CheckBox(); this.checkTokenGained = new System.Windows.Forms.CheckBox();
this.panel1 = new System.Windows.Forms.Panel(); this.panel1 = new System.Windows.Forms.Panel();
this.label3 = new System.Windows.Forms.Label(); this.label3 = new System.Windows.Forms.Label();
this.label5 = new System.Windows.Forms.Label();
this.txtApiUrl = new Elfisa.UI.Controls.ModernTextBox();
this.panel1.SuspendLayout(); this.panel1.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
// //
@ -81,7 +79,7 @@
// //
// btnConfirmRegistration // btnConfirmRegistration
// //
this.btnConfirmRegistration.Location = new System.Drawing.Point(188, 300); this.btnConfirmRegistration.Location = new System.Drawing.Point(188, 244);
this.btnConfirmRegistration.Name = "btnConfirmRegistration"; this.btnConfirmRegistration.Name = "btnConfirmRegistration";
this.btnConfirmRegistration.Size = new System.Drawing.Size(215, 43); this.btnConfirmRegistration.Size = new System.Drawing.Size(215, 43);
this.btnConfirmRegistration.TabIndex = 5; this.btnConfirmRegistration.TabIndex = 5;
@ -90,7 +88,7 @@
// //
// btnCloseThis // btnCloseThis
// //
this.btnCloseThis.Location = new System.Drawing.Point(28, 300); this.btnCloseThis.Location = new System.Drawing.Point(28, 244);
this.btnCloseThis.Name = "btnCloseThis"; this.btnCloseThis.Name = "btnCloseThis";
this.btnCloseThis.Size = new System.Drawing.Size(125, 43); this.btnCloseThis.Size = new System.Drawing.Size(125, 43);
this.btnCloseThis.TabIndex = 6; this.btnCloseThis.TabIndex = 6;
@ -102,7 +100,7 @@
this.checkTokenGained.AutoCheck = false; this.checkTokenGained.AutoCheck = false;
this.checkTokenGained.AutoSize = true; this.checkTokenGained.AutoSize = true;
this.checkTokenGained.Font = new System.Drawing.Font("Segoe UI", 12F); this.checkTokenGained.Font = new System.Drawing.Font("Segoe UI", 12F);
this.checkTokenGained.Location = new System.Drawing.Point(28, 260); this.checkTokenGained.Location = new System.Drawing.Point(28, 304);
this.checkTokenGained.Name = "checkTokenGained"; this.checkTokenGained.Name = "checkTokenGained";
this.checkTokenGained.Size = new System.Drawing.Size(171, 32); this.checkTokenGained.Size = new System.Drawing.Size(171, 32);
this.checkTokenGained.TabIndex = 7; this.checkTokenGained.TabIndex = 7;
@ -133,31 +131,11 @@
this.label3.Text = "АВТОРИЗАЦИЯ"; this.label3.Text = "АВТОРИЗАЦИЯ";
this.label3.TextAlign = System.Drawing.ContentAlignment.MiddleRight; this.label3.TextAlign = System.Drawing.ContentAlignment.MiddleRight;
// //
// label5
//
this.label5.AutoSize = true;
this.label5.Font = new System.Drawing.Font("Segoe UI", 12F);
this.label5.Location = new System.Drawing.Point(23, 225);
this.label5.Name = "label5";
this.label5.Size = new System.Drawing.Size(103, 28);
this.label5.TabIndex = 11;
this.label5.Text = "URL API";
//
// txtApiUrl
//
this.txtApiUrl.Font = new System.Drawing.Font("Segoe UI", 12F);
this.txtApiUrl.Location = new System.Drawing.Point(132, 225);
this.txtApiUrl.Name = "txtApiUrl";
this.txtApiUrl.Size = new System.Drawing.Size(271, 34);
this.txtApiUrl.TabIndex = 12;
//
// HF_Registration // HF_Registration
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(436, 360); this.ClientSize = new System.Drawing.Size(436, 300);
this.Controls.Add(this.txtApiUrl);
this.Controls.Add(this.label5);
this.Controls.Add(this.panel1); this.Controls.Add(this.panel1);
this.Controls.Add(this.checkTokenGained); this.Controls.Add(this.checkTokenGained);
this.Controls.Add(this.btnCloseThis); this.Controls.Add(this.btnCloseThis);
@ -190,7 +168,5 @@
private System.Windows.Forms.CheckBox checkTokenGained; private System.Windows.Forms.CheckBox checkTokenGained;
private System.Windows.Forms.Panel panel1; private System.Windows.Forms.Panel panel1;
private System.Windows.Forms.Label label3; private System.Windows.Forms.Label label3;
private System.Windows.Forms.Label label5;
private Elfisa.UI.Controls.ModernTextBox txtApiUrl;
} }
} }

View File

@ -16,10 +16,6 @@ namespace Электронная_Фармация.HelpForms
Shown += (_, __) => WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors); Shown += (_, __) => WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
} }
string login = Settings.Default.stringLogin.ToString();
string password = Settings.Default.stringPassword.ToString();
string doWeHaveAToken = Settings.Default.stringToken.ToString();
private void btnCloseThis_Click(object sender, EventArgs e) private void btnCloseThis_Click(object sender, EventArgs e)
{ {
Close(); Close();
@ -30,19 +26,17 @@ namespace Электронная_Фармация.HelpForms
btnConfirmRegistration.Enabled = false; btnConfirmRegistration.Enabled = false;
btnCloseThis.Enabled = false; btnCloseThis.Enabled = false;
UseWaitCursor = true; UseWaitCursor = true;
string strLogin = txtLogin.Text; string strLogin = (txtLogin.Text ?? string.Empty).Trim();
string strPassword = txtPassword.Text; string strPassword = txtPassword.Text ?? string.Empty;
if (strLogin.Length > 0 && strPassword.Length > 0) if (strLogin.Length > 0 && strPassword.Length > 0)
{ {
// Адрес API берём из поля и сохраняем ДО входа: иначе логин уходит string apiUrl = string.IsNullOrWhiteSpace(Settings.Default.ApiBaseUrl)
// на старый сохранённый/дефолтный адрес, а не на введённый пользователем. ? AppConfig.DefaultApiBaseUrl
string apiUrl = txtApiUrl.Text.Trim(); : Settings.Default.ApiBaseUrl;
Settings.Default.ApiBaseUrl = apiUrl;
Settings.Default.Save();
LoginRequest loginRequest = new LoginRequest(); LoginRequest loginRequest = new LoginRequest();
loginRequest.Username = strLogin.Trim(); loginRequest.Username = strLogin;
loginRequest.Password = strPassword.Trim(); loginRequest.Password = strPassword.Trim();
var client = new ApiClient(AppConfig.NormalizeApiBaseUrl(apiUrl)); var client = new ApiClient(AppConfig.NormalizeApiBaseUrl(apiUrl));
@ -52,8 +46,9 @@ namespace Электронная_Фармация.HelpForms
{ {
string token = await client.LoginAsync(loginRequest.Username, loginRequest.Password); string token = await client.LoginAsync(loginRequest.Username, loginRequest.Password);
// Сохраняем только логин и токен; пароль в настройках не храним.
Settings.Default.stringLogin = strLogin; Settings.Default.stringLogin = strLogin;
Settings.Default.stringPassword = strPassword; Settings.Default.stringPassword = string.Empty;
Settings.Default.stringToken = token; Settings.Default.stringToken = token;
Settings.Default.ApiBaseUrl = client.BaseUrl; Settings.Default.ApiBaseUrl = client.BaseUrl;
Settings.Default.Save(); Settings.Default.Save();
@ -61,15 +56,6 @@ namespace Электронная_Фармация.HelpForms
AppDebugLog.Info("Auth", $"Авторизация успешна. token={AppDebugLog.MaskToken(token)}"); AppDebugLog.Info("Auth", $"Авторизация успешна. token={AppDebugLog.MaskToken(token)}");
ToastNotification.ShowSuccess("Авторизация успешно выполнена"); ToastNotification.ShowSuccess("Авторизация успешно выполнена");
// После входа обязательно запрашиваем Location ID для отправки заказов.
if (!HF_LocationId.PromptAndSave(this))
{
ToastNotification.ShowCustom(
"Location ID не указан. Отправка заказов будет недоступна, пока его не заполните.",
System.Drawing.Color.DarkOrange,
System.Drawing.Color.White);
}
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();
return; return;
@ -99,13 +85,10 @@ namespace Электронная_Фармация.HelpForms
{ {
UiThemeHelper.ApplyToControlTree(this); UiThemeHelper.ApplyToControlTree(this);
WindowChromeHelper.ApplyDialogChrome(this, panel1, label3); WindowChromeHelper.ApplyDialogChrome(this, panel1, label3);
txtLogin.Text = login; // Только последний успешный логин; пароль пользователь вводит каждый раз.
txtPassword.Text = password; txtLogin.Text = Settings.Default.stringLogin ?? string.Empty;
txtApiUrl.Text = string.IsNullOrWhiteSpace(Settings.Default.ApiBaseUrl) txtPassword.Text = string.Empty;
? AppConfig.DefaultApiBaseUrl checkTokenGained.Checked = !string.IsNullOrWhiteSpace(Settings.Default.stringToken);
: Settings.Default.ApiBaseUrl;
checkTokenGained.Checked = doWeHaveAToken.Length > 0;
} }
} }
} }

View File

@ -0,0 +1,192 @@
namespace Электроннаяармация.HelpForms
{
partial class HF_Settings
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
this.panelHeader = new System.Windows.Forms.Panel();
this.lblTitle = new System.Windows.Forms.Label();
this.lblHint = new System.Windows.Forms.Label();
this.lblLocationId = new System.Windows.Forms.Label();
this.txtLocationId = new Elfisa.UI.Controls.ModernTextBox();
this.lblApiUrl = new System.Windows.Forms.Label();
this.txtApiUrl = new Elfisa.UI.Controls.ModernTextBox();
this.lblInvoiceExportPath = new System.Windows.Forms.Label();
this.txtInvoiceExportPath = new Elfisa.UI.Controls.ModernTextBox();
this.btnBrowseExportPath = new Elfisa.UI.Controls.ModernButton();
this.btnCancel = new Elfisa.UI.Controls.ModernButton();
this.btnSave = new Elfisa.UI.Controls.ModernButton();
this.panelHeader.SuspendLayout();
this.SuspendLayout();
//
// panelHeader
//
this.panelHeader.Controls.Add(this.lblTitle);
this.panelHeader.Dock = System.Windows.Forms.DockStyle.Top;
this.panelHeader.Location = new System.Drawing.Point(0, 0);
this.panelHeader.Name = "panelHeader";
this.panelHeader.Size = new System.Drawing.Size(560, 56);
this.panelHeader.TabIndex = 0;
//
// lblTitle
//
this.lblTitle.Font = new System.Drawing.Font("Segoe UI Semibold", 13.8F, System.Drawing.FontStyle.Bold);
this.lblTitle.ForeColor = System.Drawing.Color.White;
this.lblTitle.Location = new System.Drawing.Point(12, 10);
this.lblTitle.Name = "lblTitle";
this.lblTitle.Size = new System.Drawing.Size(520, 36);
this.lblTitle.TabIndex = 0;
this.lblTitle.Text = "Настройки";
this.lblTitle.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblHint
//
this.lblHint.Font = new System.Drawing.Font("Segoe UI", 10F);
this.lblHint.Location = new System.Drawing.Point(24, 72);
this.lblHint.Name = "lblHint";
this.lblHint.Size = new System.Drawing.Size(512, 46);
this.lblHint.TabIndex = 1;
this.lblHint.Text = "Location ID ниже — запасной. Для каждой аптеки задайте свой ID в «Грузополучатели». Также укажите API и папку накладных.";
//
// lblLocationId
//
this.lblLocationId.AutoSize = true;
this.lblLocationId.Font = new System.Drawing.Font("Segoe UI", 11F);
this.lblLocationId.Location = new System.Drawing.Point(24, 124);
this.lblLocationId.Name = "lblLocationId";
this.lblLocationId.Size = new System.Drawing.Size(100, 25);
this.lblLocationId.TabIndex = 2;
this.lblLocationId.Text = "Location ID";
//
// txtLocationId
//
this.txtLocationId.Font = new System.Drawing.Font("Segoe UI", 11F);
this.txtLocationId.Location = new System.Drawing.Point(28, 152);
this.txtLocationId.Name = "txtLocationId";
this.txtLocationId.Size = new System.Drawing.Size(508, 32);
this.txtLocationId.TabIndex = 3;
//
// lblApiUrl
//
this.lblApiUrl.AutoSize = true;
this.lblApiUrl.Font = new System.Drawing.Font("Segoe UI", 11F);
this.lblApiUrl.Location = new System.Drawing.Point(24, 196);
this.lblApiUrl.Name = "lblApiUrl";
this.lblApiUrl.Size = new System.Drawing.Size(127, 25);
this.lblApiUrl.TabIndex = 4;
this.lblApiUrl.Text = "Адрес сервера";
//
// txtApiUrl
//
this.txtApiUrl.Font = new System.Drawing.Font("Segoe UI", 11F);
this.txtApiUrl.Location = new System.Drawing.Point(28, 224);
this.txtApiUrl.Name = "txtApiUrl";
this.txtApiUrl.Size = new System.Drawing.Size(508, 32);
this.txtApiUrl.TabIndex = 5;
//
// lblInvoiceExportPath
//
this.lblInvoiceExportPath.AutoSize = true;
this.lblInvoiceExportPath.Font = new System.Drawing.Font("Segoe UI", 11F);
this.lblInvoiceExportPath.Location = new System.Drawing.Point(24, 268);
this.lblInvoiceExportPath.Name = "lblInvoiceExportPath";
this.lblInvoiceExportPath.Size = new System.Drawing.Size(228, 25);
this.lblInvoiceExportPath.TabIndex = 6;
this.lblInvoiceExportPath.Text = "Папка экспорта накладных";
//
// txtInvoiceExportPath
//
this.txtInvoiceExportPath.Font = new System.Drawing.Font("Segoe UI", 11F);
this.txtInvoiceExportPath.Location = new System.Drawing.Point(28, 296);
this.txtInvoiceExportPath.Name = "txtInvoiceExportPath";
this.txtInvoiceExportPath.Size = new System.Drawing.Size(448, 32);
this.txtInvoiceExportPath.TabIndex = 7;
//
// btnBrowseExportPath
//
this.btnBrowseExportPath.Font = new System.Drawing.Font("Segoe UI Semibold", 10F, System.Drawing.FontStyle.Bold);
this.btnBrowseExportPath.Location = new System.Drawing.Point(487, 296);
this.btnBrowseExportPath.Name = "btnBrowseExportPath";
this.btnBrowseExportPath.Size = new System.Drawing.Size(49, 32);
this.btnBrowseExportPath.TabIndex = 8;
this.btnBrowseExportPath.Text = "...";
this.btnBrowseExportPath.Click += new System.EventHandler(this.btnBrowseExportPath_Click);
//
// btnCancel
//
this.btnCancel.Location = new System.Drawing.Point(28, 356);
this.btnCancel.Name = "btnCancel";
this.btnCancel.Size = new System.Drawing.Size(140, 40);
this.btnCancel.TabIndex = 9;
this.btnCancel.Text = "Отмена";
this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click);
//
// btnSave
//
this.btnSave.Location = new System.Drawing.Point(364, 356);
this.btnSave.Name = "btnSave";
this.btnSave.Size = new System.Drawing.Size(172, 40);
this.btnSave.TabIndex = 10;
this.btnSave.Text = "Сохранить";
this.btnSave.Click += new System.EventHandler(this.btnSave_Click);
//
// HF_Settings
//
this.AcceptButton = this.btnSave;
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.CancelButton = this.btnCancel;
this.ClientSize = new System.Drawing.Size(560, 420);
this.Controls.Add(this.btnSave);
this.Controls.Add(this.btnCancel);
this.Controls.Add(this.btnBrowseExportPath);
this.Controls.Add(this.txtInvoiceExportPath);
this.Controls.Add(this.lblInvoiceExportPath);
this.Controls.Add(this.txtApiUrl);
this.Controls.Add(this.lblApiUrl);
this.Controls.Add(this.txtLocationId);
this.Controls.Add(this.lblLocationId);
this.Controls.Add(this.lblHint);
this.Controls.Add(this.panelHeader);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "HF_Settings";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Load += new System.EventHandler(this.HF_Settings_Load);
this.panelHeader.ResumeLayout(false);
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Panel panelHeader;
private System.Windows.Forms.Label lblTitle;
private System.Windows.Forms.Label lblHint;
private System.Windows.Forms.Label lblLocationId;
private Elfisa.UI.Controls.ModernTextBox txtLocationId;
private System.Windows.Forms.Label lblApiUrl;
private Elfisa.UI.Controls.ModernTextBox txtApiUrl;
private System.Windows.Forms.Label lblInvoiceExportPath;
private Elfisa.UI.Controls.ModernTextBox txtInvoiceExportPath;
private Elfisa.UI.Controls.ModernButton btnBrowseExportPath;
private Elfisa.UI.Controls.ModernButton btnCancel;
private Elfisa.UI.Controls.ModernButton btnSave;
}
}

View File

@ -0,0 +1,95 @@
using System;
using System.Windows.Forms;
using Elfisa.UI.Helpers;
using Elfisa.UI.Theming;
using Электроннаяармация.Classes;
using Электроннаяармация.Properties;
namespace Электроннаяармация.HelpForms
{
public partial class HF_Settings : Form
{
public HF_Settings()
{
InitializeComponent();
Shown += (_, __) => WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
}
private void HF_Settings_Load(object sender, EventArgs e)
{
UiThemeHelper.ApplyToControlTree(this);
WindowChromeHelper.ApplyDialogChrome(this, panelHeader, lblTitle);
txtLocationId.Text = AppConfig.LocationId;
txtApiUrl.Text = string.IsNullOrWhiteSpace(Settings.Default.ApiBaseUrl)
? AppConfig.DefaultApiBaseUrl
: Settings.Default.ApiBaseUrl;
txtInvoiceExportPath.Text = AppConfig.InvoiceExportPath;
}
private void btnBrowseExportPath_Click(object sender, EventArgs e)
{
using (var dialog = new FolderBrowserDialog())
{
dialog.Description = "Выберите папку для экспорта накладных";
dialog.ShowNewFolderButton = true;
if (!string.IsNullOrWhiteSpace(txtInvoiceExportPath.Text) &&
System.IO.Directory.Exists(txtInvoiceExportPath.Text.Trim()))
{
dialog.SelectedPath = txtInvoiceExportPath.Text.Trim();
}
if (dialog.ShowDialog(this) == DialogResult.OK)
{
txtInvoiceExportPath.Text = dialog.SelectedPath;
}
}
}
private void btnSave_Click(object sender, EventArgs e)
{
var locationId = (txtLocationId.Text ?? string.Empty).Trim();
var apiUrl = AppConfig.NormalizeApiBaseUrl(txtApiUrl.Text.Trim());
var invoiceExportPath = (txtInvoiceExportPath.Text ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(locationId))
{
UiDialogs.ShowError("Укажите Location ID торговой точки.", "Настройки", this);
txtLocationId.Focus();
return;
}
if (string.IsNullOrWhiteSpace(apiUrl))
{
UiDialogs.ShowError("Укажите адрес API сервера.", "Настройки", this);
txtApiUrl.Focus();
return;
}
if (!string.IsNullOrWhiteSpace(invoiceExportPath) &&
!System.IO.Directory.Exists(invoiceExportPath))
{
UiDialogs.ShowError("Указанная папка для накладных не существует.", "Настройки", this);
txtInvoiceExportPath.Focus();
return;
}
AppConfig.SetLocationId(locationId);
Settings.Default.ApiBaseUrl = apiUrl;
AppConfig.SetInvoiceExportPath(invoiceExportPath);
Settings.Default.Save();
AppDebugLog.Info("Settings", $"Настройки сохранены. ApiBaseUrl={apiUrl}, LocationId={locationId}, InvoiceExportPath={invoiceExportPath}");
ToastNotification.ShowSuccess("Настройки сохранены");
DialogResult = DialogResult.OK;
Close();
}
private void btnCancel_Click(object sender, EventArgs e)
{
DialogResult = DialogResult.Cancel;
Close();
}
}
}

View File

@ -0,0 +1,107 @@
namespace Электроннаяармация.HelpForms
{
partial class HF_UpdateAvailable
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
this.panelHeader = new System.Windows.Forms.Panel();
this.lblTitle = new System.Windows.Forms.Label();
this.lblMessage = new System.Windows.Forms.Label();
this.btnDownload = new Elfisa.UI.Controls.ModernButton();
this.btnExit = new Elfisa.UI.Controls.ModernButton();
this.panelHeader.SuspendLayout();
this.SuspendLayout();
//
// panelHeader
//
this.panelHeader.Controls.Add(this.lblTitle);
this.panelHeader.Dock = System.Windows.Forms.DockStyle.Top;
this.panelHeader.Location = new System.Drawing.Point(0, 0);
this.panelHeader.Name = "panelHeader";
this.panelHeader.Size = new System.Drawing.Size(520, 56);
this.panelHeader.TabIndex = 0;
//
// lblTitle
//
this.lblTitle.Font = new System.Drawing.Font("Segoe UI Semibold", 13.8F, System.Drawing.FontStyle.Bold);
this.lblTitle.ForeColor = System.Drawing.Color.White;
this.lblTitle.Location = new System.Drawing.Point(12, 10);
this.lblTitle.Name = "lblTitle";
this.lblTitle.Size = new System.Drawing.Size(480, 36);
this.lblTitle.TabIndex = 0;
this.lblTitle.Text = "Доступно новое обновление";
this.lblTitle.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblMessage
//
this.lblMessage.Font = new System.Drawing.Font("Segoe UI", 11F);
this.lblMessage.Location = new System.Drawing.Point(24, 72);
this.lblMessage.Name = "lblMessage";
this.lblMessage.Size = new System.Drawing.Size(472, 120);
this.lblMessage.TabIndex = 1;
this.lblMessage.Text = "Доступно новое обновление";
//
// btnDownload
//
this.btnDownload.Font = new System.Drawing.Font("Segoe UI", 11F);
this.btnDownload.Location = new System.Drawing.Point(24, 220);
this.btnDownload.Name = "btnDownload";
this.btnDownload.Size = new System.Drawing.Size(180, 40);
this.btnDownload.TabIndex = 2;
this.btnDownload.Text = "Скачать";
this.btnDownload.Click += new System.EventHandler(this.btnDownload_Click);
//
// btnExit
//
this.btnExit.Font = new System.Drawing.Font("Segoe UI", 11F);
this.btnExit.Location = new System.Drawing.Point(316, 220);
this.btnExit.Name = "btnExit";
this.btnExit.Size = new System.Drawing.Size(180, 40);
this.btnExit.TabIndex = 3;
this.btnExit.Text = "Выход";
this.btnExit.Click += new System.EventHandler(this.btnExit_Click);
//
// HF_UpdateAvailable
//
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(520, 290);
this.Controls.Add(this.btnExit);
this.Controls.Add(this.btnDownload);
this.Controls.Add(this.lblMessage);
this.Controls.Add(this.panelHeader);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "HF_UpdateAvailable";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Обновление";
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.HF_UpdateAvailable_FormClosing);
this.panelHeader.ResumeLayout(false);
this.ResumeLayout(false);
}
#endregion
private System.Windows.Forms.Panel panelHeader;
private System.Windows.Forms.Label lblTitle;
private System.Windows.Forms.Label lblMessage;
private Elfisa.UI.Controls.ModernButton btnDownload;
private Elfisa.UI.Controls.ModernButton btnExit;
}
}

View File

@ -0,0 +1,104 @@
using System;
using System.Threading.Tasks;
using System.Windows.Forms;
using Электроннаяармация.Classes;
namespace Электроннаяармация.HelpForms
{
public partial class HF_UpdateAvailable : Form
{
private readonly UpdateCheckResult _result;
private bool _updating;
public HF_UpdateAvailable(UpdateCheckResult result)
{
_result = result ?? throw new ArgumentNullException(nameof(result));
InitializeComponent();
UiThemeHelper.ApplyToControlTree(this);
var latest = string.IsNullOrWhiteSpace(_result.LatestVersion) ? "?" : _result.LatestVersion;
var local = string.IsNullOrWhiteSpace(_result.LocalVersion) ? "?" : _result.LocalVersion;
lblMessage.Text =
$"Доступно новое обновление {latest} (у вас {local}).{Environment.NewLine}{Environment.NewLine}" +
"Нажмите «Обновить» — программа скачает и установит новую версию сама." + Environment.NewLine +
"Локальная база и данные сохранятся.";
btnDownload.Text = "Обновить";
}
private async void btnDownload_Click(object sender, EventArgs e)
{
if (_updating)
{
return;
}
if (string.IsNullOrWhiteSpace(_result.DownloadUrl) &&
string.IsNullOrWhiteSpace(_result.ReleaseUrl))
{
UiDialogs.ShowError("Ссылка на обновление не задана.", "Обновление", this);
return;
}
_updating = true;
btnDownload.Enabled = false;
btnExit.Enabled = false;
ControlBox = false;
var progress = new Progress<string>(msg =>
{
lblMessage.Text = msg;
});
try
{
await AutoUpdater.ApplyUpdateAsync(_result, progress);
lblMessage.Text = "Обновление скачано. Перезапуск...";
// Снимаем блокировку закрытия, иначе диалог зависает навечно
// на «Перезапуск...» и updater ждёт PID, который не завершается.
_updating = false;
DialogResult = DialogResult.OK;
Close();
}
catch (Exception ex)
{
AppDebugLog.Error("Update", "Автообновление не удалось", ex);
_updating = false;
btnDownload.Enabled = true;
btnExit.Enabled = true;
ControlBox = true;
UiDialogs.ShowError(
"Не удалось установить обновление автоматически." + Environment.NewLine +
ex.Message,
"Обновление",
this);
}
}
private void btnExit_Click(object sender, EventArgs e)
{
if (_updating)
{
return;
}
DialogResult = DialogResult.Abort;
Close();
}
private void HF_UpdateAvailable_FormClosing(object sender, FormClosingEventArgs e)
{
// Блокируем только ручное закрытие во время скачивания.
if (_updating && DialogResult != DialogResult.OK)
{
e.Cancel = true;
return;
}
// Крестик / Alt+F4 — выход из приложения (не пускать в работу).
if (DialogResult != DialogResult.Abort && DialogResult != DialogResult.OK)
{
DialogResult = DialogResult.Abort;
}
}
}
}

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Windows.Forms; using System.Windows.Forms;
using System.Net; using System.Net;
using Электроннаяармация.Classes; using Электроннаяармация.Classes;
@ -12,6 +12,7 @@ namespace Электронная_Фармация
{ {
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
AppConfig.MigrateLegacySettings(); AppConfig.MigrateLegacySettings();
AppConfig.UpgradeUserSettingsIfNeeded();
AppDebugLog.Info("App", $"Запуск. ApiBaseUrl={AppConfig.ApiBaseUrl}, Log={AppDebugLog.CurrentLogFilePath}"); AppDebugLog.Info("App", $"Запуск. ApiBaseUrl={AppConfig.ApiBaseUrl}, Log={AppDebugLog.CurrentLogFilePath}");
Application.EnableVisualStyles(); Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false); Application.SetCompatibleTextRenderingDefault(false);

View File

@ -2,35 +2,36 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
// Общие сведения об этой сборке предоставляются следующим набором // пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ
// набора атрибутов. Измените значения этих атрибутов для изменения сведений, // пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ. пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ,
// связанных со сборкой. // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ.
[assembly: AssemblyTitle("Электронная Фармация")] [assembly: AssemblyTitle("пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ")]
[assembly: AssemblyDescription("")] [assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")] [assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")] [assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Электронная Фармация")] [assembly: AssemblyProduct("пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ")]
[assembly: AssemblyCopyright("Copyright © 2023")] [assembly: AssemblyCopyright("Copyright пїЅ 2023")]
[assembly: AssemblyTrademark("")] [assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")] [assembly: AssemblyCulture("")]
// Установка значения False для параметра ComVisible делает типы в этой сборке невидимыми // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ False пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ ComVisible пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
// для компонентов COM. Если необходимо обратиться к типу в этой сборке через // пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ COM. пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ
// COM, следует установить атрибут ComVisible в TRUE для этого типа. // COM, пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ ComVisible пїЅ TRUE пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ.
[assembly: ComVisible(false)] [assembly: ComVisible(false)]
// Следующий GUID служит для идентификации библиотеки типов, если этот проект будет видимым для COM // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ GUID пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ, пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ COM
[assembly: Guid("145d8060-4187-4871-b4fb-8df795727b66")] [assembly: Guid("145d8060-4187-4871-b4fb-8df795727b66")]
// Сведения о версии сборки состоят из указанных ниже четырех значений: // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ:
// //
// Основной номер версии // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
// Дополнительный номер версии // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
// Номер сборки // пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
// Редакция // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
// //
// Можно задать все значения или принять номера сборки и редакции по умолчанию // пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
// используя "*", как показано ниже: // пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ "*", пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ:
// [assembly: AssemblyVersion("1.0.*")] // [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyVersion("1.0.29.0")]
[assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.29.0")]

View File

@ -1,4 +1,4 @@
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// <auto-generated> // <auto-generated>
// Этот код создан программой. // Этот код создан программой.
// Исполняемая версия:4.0.30319.42000 // Исполняемая версия:4.0.30319.42000
@ -35,6 +35,18 @@ namespace Электронная_Фармация.Properties {
} }
} }
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("0")]
public decimal ClientMarkupPercent {
get {
return ((decimal)(this["ClientMarkupPercent"]));
}
set {
this["ClientMarkupPercent"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()] [global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")] [global::System.Configuration.DefaultSettingValueAttribute("")]
@ -119,5 +131,101 @@ namespace Электронная_Фармация.Properties {
} }
} }
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("0")]
public int PriceListSplitterDistance {
get {
return ((int)(this["PriceListSplitterDistance"]));
}
set {
this["PriceListSplitterDistance"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")]
public string InvoiceExportPath {
get {
return ((string)(this["InvoiceExportPath"]));
}
set {
this["InvoiceExportPath"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("01.01.0001 00:00:00")]
public global::System.DateTime LastPriceUpdateUtc {
get {
return ((global::System.DateTime)(this["LastPriceUpdateUtc"]));
}
set {
this["LastPriceUpdateUtc"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("01.01.0001 00:00:00")]
public global::System.DateTime LastPriceReminderUtc {
get {
return ((global::System.DateTime)(this["LastPriceReminderUtc"]));
}
set {
this["LastPriceReminderUtc"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("https://git.24pharmdata.ru")]
public string UpdateCheckBaseUrl {
get {
return ((string)(this["UpdateCheckBaseUrl"]));
}
set {
this["UpdateCheckBaseUrl"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("pharmdata/elfisa-pharmacy")]
public string UpdateRepo {
get {
return ((string)(this["UpdateRepo"]));
}
set {
this["UpdateRepo"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")]
public string UpdateApiToken {
get {
return ((string)(this["UpdateApiToken"]));
}
set {
this["UpdateApiToken"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("True")]
public bool UpgradeSettings {
get {
return ((bool)(this["UpgradeSettings"]));
}
set {
this["UpgradeSettings"] = value;
}
}
} }
} }

View File

@ -1,10 +1,13 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)" GeneratedClassNamespace="Электронная_Фармация.Properties" GeneratedClassName="Settings"> <SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)" GeneratedClassNamespace="Электронная_Фармация.Properties" GeneratedClassName="Settings">
<Profiles /> <Profiles />
<Settings> <Settings>
<Setting Name="IntDefaultPercentMarkup" Type="System.Int32" Scope="User"> <Setting Name="IntDefaultPercentMarkup" Type="System.Int32" Scope="User">
<Value Profile="(Default)">0</Value> <Value Profile="(Default)">0</Value>
</Setting> </Setting>
<Setting Name="ClientMarkupPercent" Type="System.Decimal" Scope="User">
<Value Profile="(Default)">0</Value>
</Setting>
<Setting Name="stringLogin" Type="System.String" Scope="User"> <Setting Name="stringLogin" Type="System.String" Scope="User">
<Value Profile="(Default)" /> <Value Profile="(Default)" />
</Setting> </Setting>
@ -26,5 +29,29 @@
<Setting Name="UseDarkTheme" Type="System.Boolean" Scope="User"> <Setting Name="UseDarkTheme" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value> <Value Profile="(Default)">False</Value>
</Setting> </Setting>
<Setting Name="PriceListSplitterDistance" Type="System.Int32" Scope="User">
<Value Profile="(Default)">0</Value>
</Setting>
<Setting Name="InvoiceExportPath" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="LastPriceUpdateUtc" Type="System.DateTime" Scope="User">
<Value Profile="(Default)">01.01.0001 00:00:00</Value>
</Setting>
<Setting Name="LastPriceReminderUtc" Type="System.DateTime" Scope="User">
<Value Profile="(Default)">01.01.0001 00:00:00</Value>
</Setting>
<Setting Name="UpdateCheckBaseUrl" Type="System.String" Scope="User">
<Value Profile="(Default)">https://git.24pharmdata.ru</Value>
</Setting>
<Setting Name="UpdateRepo" Type="System.String" Scope="User">
<Value Profile="(Default)">pharmdata/elfisa-pharmacy</Value>
</Setting>
<Setting Name="UpdateApiToken" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="UpgradeSettings" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">True</Value>
</Setting>
</Settings> </Settings>
</SettingsFile> </SettingsFile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -37,7 +37,7 @@ namespace Электронная_Фармация.UserControls
dtpDateFrom.Value = DateTime.Now.AddDays(-3); dtpDateFrom.Value = DateTime.Now.AddDays(-3);
dtpDateTo.Value = DateTime.Now; dtpDateTo.Value = DateTime.Now;
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), string.Empty); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), string.Empty);
loadSuppliers(); loadSuppliers();
comboSuppliers.Visible = true; comboSuppliers.Visible = true;
@ -334,8 +334,8 @@ namespace Электронная_Фармация.UserControls
var addon = parts.Count == 0 ? string.Empty : " and " + string.Join(" and ", parts); var addon = parts.Count == 0 ? string.Empty : " and " + string.Join(" and ", parts);
LoadOrders( LoadOrders(
dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateFrom.Value.ToString("yyyy-MM-dd"),
dtpDateTo.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("yyyy-MM-dd"),
addon); addon);
} }
@ -394,9 +394,9 @@ namespace Электронная_Фармация.UserControls
try try
{ {
var service = new InvoiceRequestService(); var service = new InvoiceSyncService();
service.CreateOrUpdateFromOrder(orderId); service.SyncFromServerAsync().GetAwaiter().GetResult();
ToastNotification.ShowSuccess("Накладная добавлена в раздел \"Накладные\"."); ToastNotification.ShowSuccess("Накладные обновлены с сервера.");
var shell = FindForm() as ElectroPharmacy; var shell = FindForm() as ElectroPharmacy;
shell?.OpenInvoicesTab(orderId); shell?.OpenInvoicesTab(orderId);
@ -411,6 +411,14 @@ namespace Электронная_Фармация.UserControls
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
// OrderDate в БД исторически dd.MM.yyyy — сравниваем как даты, не как строки,
// иначе «03.08» не попадает в диапазон «с 31.07» (лексикографически).
const string orderDateIso = @"CASE
WHEN length(OrderDate)=10 AND substr(OrderDate,3,1)='.' THEN
substr(OrderDate,7,4)||'-'||substr(OrderDate,4,2)||'-'||substr(OrderDate,1,2)
ELSE OrderDate
END";
string qShowMeOrders = $@" string qShowMeOrders = $@"
select select
[Id_Order], [Id_Order],
@ -425,8 +433,8 @@ select
[ConsigneeName] [ConsigneeName]
from Orders from Orders
where where
OrderDate >= '{dateFrom}' ({orderDateIso}) >= '{dateFrom}'
and OrderDate <= '{dateTo}' and ({orderDateIso}) <= '{dateTo}'
{filters} {filters}
"; ";
@ -508,7 +516,11 @@ and OrderDate <= '{dateTo}'
void loadConsignees() void loadConsignees()
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string qShowMeConsigneesList = "select ConsigneesName from Consignees order by [ConsigneesUseAsDefault] desc"; string qShowMeConsigneesList = @"
select distinct ConsigneeName
from Orders
where trim(ifnull(ConsigneeName, '')) <> ''
order by ConsigneeName";
using (SQLiteConnection conConsignees = new SQLiteConnection(connectionStringToLocalDB)) using (SQLiteConnection conConsignees = new SQLiteConnection(connectionStringToLocalDB))
{ {
@ -527,7 +539,7 @@ and OrderDate <= '{dateTo}'
var items = new List<string> { "Грузополучатель" }; var items = new List<string> { "Грузополучатель" };
items.AddRange(tableConsignees.Rows.Cast<DataRow>() items.AddRange(tableConsignees.Rows.Cast<DataRow>()
.Select(r => r["ConsigneesName"]?.ToString()) .Select(r => r["ConsigneeName"]?.ToString())
.Where(n => !string.IsNullOrWhiteSpace(n))); .Where(n => !string.IsNullOrWhiteSpace(n)));
_comboConsignees.DataSource = items; _comboConsignees.DataSource = items;
_comboConsignees.SelectedIndex = 0; _comboConsignees.SelectedIndex = 0;
@ -547,7 +559,11 @@ and OrderDate <= '{dateTo}'
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string qShowMeSuppliersList = "select SuppliersName from suppliers order by [SuppliersName] asc"; string qShowMeSuppliersList = @"
select distinct SupplierName
from Orders
where trim(ifnull(SupplierName, '')) <> ''
order by SupplierName asc";
using (SQLiteConnection conSuppliers = new SQLiteConnection(connectionStringToLocalDB)) using (SQLiteConnection conSuppliers = new SQLiteConnection(connectionStringToLocalDB))
{ {
@ -559,7 +575,7 @@ and OrderDate <= '{dateTo}'
DataTable tableSuppliers = new DataTable(); DataTable tableSuppliers = new DataTable();
daSuppliersList.Fill(tableSuppliers); daSuppliersList.Fill(tableSuppliers);
comboSuppliers.DataSource = tableSuppliers; comboSuppliers.DataSource = tableSuppliers;
comboSuppliers.DisplayMember = "SuppliersName"; comboSuppliers.DisplayMember = "SupplierName";
comboSuppliers.Text = "Поставщик"; comboSuppliers.Text = "Поставщик";
} }
catch (Exception ex) catch (Exception ex)
@ -676,25 +692,25 @@ and OrderDate <= '{dateTo}'
{ {
txtGoodFromOrders.Text = "Товар"; txtGoodFromOrders.Text = "Товар";
filter = string.Empty; filter = string.Empty;
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
} }
private void dtpDateFrom_ValueChanged(object sender, EventArgs e) private void dtpDateFrom_ValueChanged(object sender, EventArgs e)
{ {
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
} }
private void dtpDateTo_ValueChanged(object sender, EventArgs e) private void dtpDateTo_ValueChanged(object sender, EventArgs e)
{ {
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
} }
private void dtpDateTo_KeyPress(object sender, KeyPressEventArgs e) private void dtpDateTo_KeyPress(object sender, KeyPressEventArgs e)
{ {
if (e.KeyChar == (Char)Keys.Enter) if (e.KeyChar == (Char)Keys.Enter)
{ {
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
} }
} }
@ -702,7 +718,7 @@ and OrderDate <= '{dateTo}'
{ {
if (e.KeyChar == (Char)Keys.Enter) if (e.KeyChar == (Char)Keys.Enter)
{ {
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
} }
} }
@ -726,7 +742,7 @@ and OrderDate <= '{dateTo}'
and Id_Order in (select Id_Order from OrderItems where DrugName like '%{txtGoodFromOrders.Text}%') and Id_Order in (select Id_Order from OrderItems where DrugName like '%{txtGoodFromOrders.Text}%')
"; ";
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
} }
} }
} }
@ -873,7 +889,7 @@ where [id_order] = '{idPriceList}'
and OrderNumber like '%{txtOrderNumberFilter.Text}%' and OrderNumber like '%{txtOrderNumberFilter.Text}%'
"; ";
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
} }
} }
} }
@ -898,7 +914,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
{ {
txtOrderNumberFilter.Text = "Номер заказа"; txtOrderNumberFilter.Text = "Номер заказа";
filter = string.Empty; filter = string.Empty;
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
} }
private void btnAddComment_Click(object sender, EventArgs e) private void btnAddComment_Click(object sender, EventArgs e)
@ -911,7 +927,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
HF_Comment hf_comment = new HF_Comment(OrderNumber, Id_Order); HF_Comment hf_comment = new HF_Comment(OrderNumber, Id_Order);
hf_comment.ShowDialog(); hf_comment.ShowDialog();
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), string.Empty); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), string.Empty);
} }
} }
@ -948,7 +964,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
//ToastNotification.ShowCustom($"Заказ {OrderNumber} был удалён", Color.DarkOrange, Color.White); //ToastNotification.ShowCustom($"Заказ {OrderNumber} был удалён", Color.DarkOrange, Color.White);
ToastNotification.ShowSuccess($"Заказ {OrderNumber} был удалён"); ToastNotification.ShowSuccess($"Заказ {OrderNumber} был удалён");
//MessageBox.Show($"Заказ {OrderNumber} был успешно удалён.", "Удаление заказа", MessageBoxButtons.OK, MessageBoxIcon.Information); //MessageBox.Show($"Заказ {OrderNumber} был успешно удалён.", "Удаление заказа", MessageBoxButtons.OK, MessageBoxIcon.Information);
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), string.Empty); LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), string.Empty);
} }
catch(Exception ex) catch(Exception ex)
{ {

View File

@ -57,7 +57,11 @@ namespace Электронная_Фармация.UserControls
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string qShowMeSuppliersList = "select SuppliersName from suppliers order by [SuppliersName] asc"; string qShowMeSuppliersList = @"
select distinct SupplierName
from Invoice
where trim(ifnull(SupplierName, '')) <> ''
order by SupplierName asc";
using (SQLiteConnection conSuppliers = new SQLiteConnection(connectionStringToLocalDB)) using (SQLiteConnection conSuppliers = new SQLiteConnection(connectionStringToLocalDB))
{ {
@ -69,7 +73,7 @@ namespace Электронная_Фармация.UserControls
DataTable tableSuppliers = new DataTable(); DataTable tableSuppliers = new DataTable();
daSuppliersList.Fill(tableSuppliers); daSuppliersList.Fill(tableSuppliers);
comboSuppliers.DataSource = tableSuppliers; comboSuppliers.DataSource = tableSuppliers;
comboSuppliers.DisplayMember = "SuppliersName"; comboSuppliers.DisplayMember = "SupplierName";
comboSuppliers.Text = "Поставщик"; comboSuppliers.Text = "Поставщик";
} }
catch(Exception ex) catch(Exception ex)
@ -87,7 +91,11 @@ namespace Электронная_Фармация.UserControls
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string qShowMeConsigneesList = "select ConsigneesName from Consignees order by [ConsigneesUseAsDefault] desc"; string qShowMeConsigneesList = @"
select distinct ConsigneesName
from Invoice
where trim(ifnull(ConsigneesName, '')) <> ''
order by ConsigneesName asc";
using (SQLiteConnection conConsignees = new SQLiteConnection(connectionStringToLocalDB)) using (SQLiteConnection conConsignees = new SQLiteConnection(connectionStringToLocalDB))
{ {
@ -127,8 +135,8 @@ select
i.[idInvoice] as [ИД накладной], i.[idInvoice] as [ИД накладной],
i.[InvoiceNumber] as [Номер], i.[InvoiceNumber] as [Номер],
i.[InvoiceDate] as [Дата], i.[InvoiceDate] as [Дата],
coalesce(nullif(i.[SupplierName], ''), (select s.[SuppliersName] from [Suppliers] s where s.[codeSuppliers] = i.[CodeSupplier])) as [Поставщик], ifnull(i.[SupplierName], '') as [Поставщик],
coalesce(nullif(i.[ConsigneesName], ''), (select c.[ConsigneesName] from [Consignees] c where c.[codeConsignees] = i.[CodeConsignees])) as [Грузополучатель], ifnull(i.[ConsigneesName], '') as [Грузополучатель],
i.[SourceOrderNumber] as [Номер заказа], i.[SourceOrderNumber] as [Номер заказа],
coalesce(i.[InvoiceSum], i.[SumWithNDS], i.[SumWithoutNDS], 0) as [Сумма], coalesce(i.[InvoiceSum], i.[SumWithNDS], i.[SumWithoutNDS], 0) as [Сумма],
coalesce(i.[RefuseSum], 0) as [Сумма отказа] coalesce(i.[RefuseSum], 0) as [Сумма отказа]
@ -142,12 +150,12 @@ where date(i.[InvoiceDate]) between date(@dateFrom) and date(@dateTo)";
if (comboSuppliers.Text != "" && comboSuppliers.Text != "Поставщик") if (comboSuppliers.Text != "" && comboSuppliers.Text != "Поставщик")
{ {
sql += " and coalesce(nullif(i.[SupplierName], ''), (select s.[SuppliersName] from [Suppliers] s where s.[codeSuppliers] = i.[CodeSupplier])) like @supplierName"; sql += " and ifnull(i.[SupplierName], '') like @supplierName";
} }
if (comboConsignees.Text != "" && comboConsignees.Text != "Грузополучатель") if (comboConsignees.Text != "" && comboConsignees.Text != "Грузополучатель")
{ {
sql += " and coalesce(nullif(i.[ConsigneesName], ''), (select c.[ConsigneesName] from [Consignees] c where c.[codeConsignees] = i.[CodeConsignees])) like @consigneeName"; sql += " and ifnull(i.[ConsigneesName], '') like @consigneeName";
} }
if (!string.IsNullOrWhiteSpace(_initialSourceOrderId)) if (!string.IsNullOrWhiteSpace(_initialSourceOrderId))
@ -406,7 +414,7 @@ where date(i.[InvoiceDate]) between date(@dateFrom) and date(@dateTo)";
return; return;
} }
_exportInvoiceMenuItem = new ToolStripMenuItem("Экспорт"); _exportInvoiceMenuItem = new ToolStripMenuItem("Экспортировать в папку");
_exportInvoiceMenuItem.Click += (_, __) => ExportSelectedInvoice(); _exportInvoiceMenuItem.Click += (_, __) => ExportSelectedInvoice();
_invoiceMenu = new ContextMenuStrip(); _invoiceMenu = new ContextMenuStrip();
@ -547,11 +555,27 @@ where ii.idInvoice = @invoiceId;";
return; return;
} }
var invoiceNumber = dgvInvoice.SelectedRows[0].Cells["Номер"]?.Value?.ToString() ?? string.Empty; var row = dgvInvoice.SelectedRows[0];
ToastNotification.ShowCustom( var invoiceId = row.Cells["ИД накладной"]?.Value?.ToString() ?? string.Empty;
$"Экспорт накладной {invoiceNumber} будет добавлен после согласования формата.", var invoiceNumber = row.Cells["Номер"]?.Value?.ToString() ?? string.Empty;
Color.SteelBlue,
Color.White); if (string.IsNullOrWhiteSpace(invoiceId))
{
ToastNotification.ShowCustom("Не удалось определить накладную для экспорта.", Color.DarkOrange, Color.White);
return;
}
try
{
var exportService = new InvoiceExportService();
var filePath = exportService.ExportInvoice(invoiceId, FindForm());
ToastNotification.ShowSuccess($"Накладная {invoiceNumber} сохранена:\n{filePath}");
}
catch (Exception ex)
{
AppDebugLog.Error("Invoices", $"Ошибка экспорта накладной {invoiceNumber}", ex);
ToastNotification.ShowError($"Не удалось экспортировать накладную: {ex.Message}");
}
} }
private void txtInvoiceNumber_KeyPress(object sender, KeyPressEventArgs e) private void txtInvoiceNumber_KeyPress(object sender, KeyPressEventArgs e)

View File

@ -11,6 +11,7 @@ using System.Data.SQLite;
using Электроннаяармация.UserControls; using Электроннаяармация.UserControls;
using System.Data.Common; using System.Data.Common;
using Электроннаяармация.Classes; using Электроннаяармация.Classes;
using Электроннаяармация.Properties;
using Elfisa.UI.Theming; using Elfisa.UI.Theming;
namespace Электроннаяармация.UserControls namespace Электроннаяармация.UserControls
@ -39,7 +40,24 @@ namespace Электронная_Фармация.UserControls
public void loadDataAboutOrderV2(string Supplier) public void loadDataAboutOrderV2(string Supplier)
{ {
string qShowMeOrderItems = "select * from TempOrderItems"; var consigneeSafe = (_consigneeName ?? string.Empty).Replace("'", "''");
string qShowMeOrderItems = $@"
SELECT
[guid_es],
[es_code],
[supplier_id],
[DrugName],
[SupplierName],
[Price],
[Quantity],
[ExpiryPeriod],
[Description],
[Zakaz],
[SummaZakaza],
[id_PriceList_Item],
[supplier_price_id]
FROM [TempOrderItems]
WHERE [ConsigneeName] = '{consigneeSafe}'";
//string qShowMeSumOfOrder = "select cast(sum(Price * Zakaz) as real) from TempOrderItems"; //string qShowMeSumOfOrder = "select cast(sum(Price * Zakaz) as real) from TempOrderItems";
@ -53,7 +71,8 @@ namespace Электронная_Фармация.UserControls
if (Supplier != string.Empty && Supplier != "Корзина") if (Supplier != string.Empty && Supplier != "Корзина")
{ {
qShowMeOrderItems += $" where [SupplierName] = '{Supplier}'"; var supplierSafe = Supplier.Replace("'", "''");
qShowMeOrderItems += $" and [SupplierName] = '{supplierSafe}'";
} }
SQLiteCommand cmdShowMeTempOrderItems = new SQLiteCommand(qShowMeOrderItems, conTempOrder); SQLiteCommand cmdShowMeTempOrderItems = new SQLiteCommand(qShowMeOrderItems, conTempOrder);
@ -286,8 +305,9 @@ and ConsigneesName = '{consigneeName}'
private void DeleteCartItem(string idPriceListItem, string drugName) private void DeleteCartItem(string idPriceListItem, string drugName)
{ {
var safeId = idPriceListItem.Replace("'", "''"); var safeId = idPriceListItem.Replace("'", "''");
var consigneeSafe = (_consigneeName ?? string.Empty).Replace("'", "''");
string qClearPriceList = $"update [PriceList] set [Zakaz] = null, [SummaZakaza] = null where id_PriceList_Item = '{safeId}'"; string qClearPriceList = $"update [PriceList] set [Zakaz] = null, [SummaZakaza] = null where id_PriceList_Item = '{safeId}'";
string qDeleteFromTempOrder = $"delete from [TempOrderItems] where [id_PriceList_Item] = '{safeId}'"; string qDeleteFromTempOrder = $"delete from [TempOrderItems] where [id_PriceList_Item] = '{safeId}' and [ConsigneeName] = '{consigneeSafe}'";
using (var sqlCon = new SQLiteConnection(connectionStringToLocalDB)) using (var sqlCon = new SQLiteConnection(connectionStringToLocalDB))
{ {
@ -324,18 +344,26 @@ and ConsigneesName = '{consigneeName}'
{ {
using(SQLiteConnection sqlConForDeleteTempOrder = new SQLiteConnection(connectionStringToLocalDB)) using(SQLiteConnection sqlConForDeleteTempOrder = new SQLiteConnection(connectionStringToLocalDB))
{ {
var supplierSafe = (Supplier ?? string.Empty).Replace("'", "''");
var consigneeSafe = (_consigneeName ?? string.Empty).Replace("'", "''");
string qDeletePriceListItemForASupplier; string qDeletePriceListItemForASupplier;
string qDeleteTempOrderForASupplier; string qDeleteTempOrderForASupplier;
if (Supplier != string.Empty) if (supplierSafe != string.Empty)
{ {
qDeletePriceListItemForASupplier = $"update [PriceList] set [Zakaz] = null, [SummaZakaza] = null where [id_PriceList_Item] in (select [id_PriceList_Item] from [TempOrderItems] where [SupplierName] = '{Supplier}')"; qDeletePriceListItemForASupplier =
qDeleteTempOrderForASupplier = $"delete from [TempOrderItems] where [SupplierName] = '{Supplier}'"; $"update [PriceList] set [Zakaz] = null, [SummaZakaza] = null where [id_PriceList_Item] in " +
$"(select [id_PriceList_Item] from [TempOrderItems] where [SupplierName] = '{supplierSafe}' and [ConsigneeName] = '{consigneeSafe}')";
qDeleteTempOrderForASupplier =
$"delete from [TempOrderItems] where [SupplierName] = '{supplierSafe}' and [ConsigneeName] = '{consigneeSafe}'";
} }
else else
{ {
qDeletePriceListItemForASupplier = $"update [PriceList] set [Zakaz] = null, [SummaZakaza] = null"; qDeletePriceListItemForASupplier =
qDeleteTempOrderForASupplier = $"delete from [TempOrderItems]"; $"update [PriceList] set [Zakaz] = null, [SummaZakaza] = null " +
$"where [id_PriceList_Item] in (select [id_PriceList_Item] from [TempOrderItems] where [ConsigneeName] = '{consigneeSafe}')";
qDeleteTempOrderForASupplier =
$"delete from [TempOrderItems] where [ConsigneeName] = '{consigneeSafe}'";
} }
@ -423,10 +451,18 @@ ConsigneesName = '{consigneeName}'
private void btnSendOrder_Click(object sender, EventArgs e) private void btnSendOrder_Click(object sender, EventArgs e)
{ {
var hasToken = !string.IsNullOrWhiteSpace(Settings.Default.stringToken);
if (!hasToken)
{
UiDialogs.ShowInfo("Сначала выполните авторизацию, чтобы создать заказ.", "Авторизация", FindForm());
return;
}
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string SupplierName = _supplierName; string SupplierName = _supplierName;
string consigneeName = _consigneeName; string consigneeName = _consigneeName;
var consigneeSafe = (consigneeName ?? string.Empty).Replace("'", "''");
@ -465,6 +501,7 @@ select
SupplierName SupplierName
from TempOrderItems from TempOrderItems
where zakaz > 0 where zakaz > 0
and [ConsigneeName] = '{consigneeSafe}'
{filterAddon} {filterAddon}
group by SupplierName group by SupplierName
"; ";
@ -517,9 +554,12 @@ group by SupplierName
void createOrderForNextSuppliers(string SupName) void createOrderForNextSuppliers(string SupName)
{ {
string OrderNumber = $"EP-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N").Substring(0, 6)}"; // Глобальный номер EX-0000001… (сервер / локальный fallback).
string OrderDate = DateTime.Now.ToString("dd.MM.yyyy"); string OrderNumber = OrderNumberHelper.AllocateForNewOrder();
string OrderDate = DateTime.Now.ToString("yyyy-MM-dd");
_consigneeName = ParentForm?.ConsigneeName ?? _consigneeName ?? string.Empty; _consigneeName = ParentForm?.ConsigneeName ?? _consigneeName ?? string.Empty;
var consigneeSafe = (_consigneeName ?? string.Empty).Replace("'", "''");
var supNameSafe = (SupName ?? string.Empty).Replace("'", "''");
if (HasStockIssues(SupName, out var stockIssue)) if (HasStockIssues(SupName, out var stockIssue))
{ {
@ -532,14 +572,16 @@ select
count(*) count(*)
from TempOrderItems from TempOrderItems
where Zakaz > 0 where Zakaz > 0
and SupplierName = '{SupName.Replace("'", "''")}' and SupplierName = '{supNameSafe}'
and [ConsigneeName] = '{consigneeSafe}'
"; ";
string qShowMeSumOrderedItems = $@" string qShowMeSumOrderedItems = $@"
select select
ifnull(sum(cast(replace(SummaZakaza, ',', '.') as real)), 0) ifnull(sum(cast(replace(SummaZakaza, ',', '.') as real)), 0)
from TempOrderItems from TempOrderItems
where Zakaz > 0 where Zakaz > 0
and SupplierName = '{SupName.Replace("'", "''")}' and SupplierName = '{supNameSafe}'
and [ConsigneeName] = '{consigneeSafe}'
"; ";
string qShowMeMinimalSumOfOrder = $@" string qShowMeMinimalSumOfOrder = $@"
select select
@ -549,7 +591,7 @@ where SuppliersName = '{SupName.Replace("'", "''")}'
"; ";
string qDisposeTempOrder = $@" string qDisposeTempOrder = $@"
delete from TempOrderItems where SupplierName = '{SupName}' delete from TempOrderItems where SupplierName = '{supNameSafe}' and [ConsigneeName] = '{consigneeSafe}'
"; ";
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
@ -594,12 +636,13 @@ select
'{OrderDate}', '{OrderDate}',
supplier_id, supplier_id,
SupplierName, SupplierName,
(select count(*) from TempOrderItems where SupplierName = '{SupName.Replace("'", "''")}'), (select count(*) from TempOrderItems where SupplierName = '{supNameSafe}' and [ConsigneeName] = '{consigneeSafe}'),
'{sumOrder.ToString(System.Globalization.CultureInfo.InvariantCulture)}', '{sumOrder.ToString(System.Globalization.CultureInfo.InvariantCulture)}',
'НОВЫЙ', 'НОВЫЙ',
'{_consigneeName.Replace("'", "''")}' '{_consigneeName.Replace("'", "''")}'
from TempOrderItems from TempOrderItems
where SupplierName = '{SupName.Replace("'", "''")}' where SupplierName = '{supNameSafe}'
and [ConsigneeName] = '{consigneeSafe}'
group by supplier_id, SupplierName group by supplier_id, SupplierName
"; ";
@ -616,7 +659,7 @@ select
[ExpiryPeriod], [ExpiryPeriod],
[supplier_price_id] [supplier_price_id]
from TempOrderItems from TempOrderItems
where SupplierName = '{SupName}' where SupplierName = '{supNameSafe}' and [ConsigneeName] = '{consigneeSafe}'
"; ";
#region старыеЗапросыСозданияЗаказа #region старыеЗапросыСозданияЗаказа

View File

@ -1,4 +1,4 @@
namespace Электроннаяармация.UserControls namespace Электроннаяармация.UserControls
{ {
partial class UCPriceList partial class UCPriceList
{ {
@ -99,7 +99,7 @@
this.SplitPriceList.Panel2.Controls.Add(this.POrderInfo); this.SplitPriceList.Panel2.Controls.Add(this.POrderInfo);
this.SplitPriceList.Size = new System.Drawing.Size(1053, 532); this.SplitPriceList.Size = new System.Drawing.Size(1053, 532);
this.SplitPriceList.SplitterDistance = 290; this.SplitPriceList.SplitterDistance = 290;
this.SplitPriceList.SplitterWidth = 2; this.SplitPriceList.SplitterWidth = 6;
this.SplitPriceList.TabIndex = 0; this.SplitPriceList.TabIndex = 0;
// //
// TLPPriceList // TLPPriceList

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Data; using System.Data;
@ -35,6 +35,7 @@ namespace Электронная_Фармация.UserControls
DataTable tablePriceList; DataTable tablePriceList;
string _WorkWithConsignee; string _WorkWithConsignee;
decimal _percentageAsDefault; decimal _percentageAsDefault;
bool _suppressMarkupPersist;
string _selectedSupplierFilter = string.Empty; string _selectedSupplierFilter = string.Empty;
bool _suppressSupplierFilterEvents; bool _suppressSupplierFilterEvents;
bool _suppressPriceListSelectionEvents; bool _suppressPriceListSelectionEvents;
@ -48,6 +49,11 @@ namespace Электронная_Фармация.UserControls
ModernSplitContainer splitNameMgBrowse; ModernSplitContainer splitNameMgBrowse;
ModernDataGridView dgvBrowseNames; ModernDataGridView dgvBrowseNames;
ModernDataGridView dgvBrowseMg; ModernDataGridView dgvBrowseMg;
List<string> _uniqueBaseNames = new List<string>();
Dictionary<string, List<string>> _mgByBaseName =
new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
Timer _searchDebounceTimer;
const int SearchDebounceMs = 180;
static readonly Regex DosageRegex = new Regex( static readonly Regex DosageRegex = new Regex(
@"(\d+(?:[.,]\d+)?\s*(?:мг|мкг|г|мл|МЕ|ме)\b)", @"(\d+(?:[.,]\d+)?\s*(?:мг|мкг|г|мл|МЕ|ме)\b)",
RegexOptions.IgnoreCase | RegexOptions.Compiled); RegexOptions.IgnoreCase | RegexOptions.Compiled);
@ -72,13 +78,14 @@ namespace Электронная_Фармация.UserControls
private void UCPriceList_Load(object sender, EventArgs e) private void UCPriceList_Load(object sender, EventArgs e)
{ {
EnsureBrowseSplit(); EnsureBrowseSplit();
EnsureChromeLayout();
UiThemeHelper.ApplyToControlTree(this); UiThemeHelper.ApplyToControlTree(this);
Tag = "chrome-child"; Tag = "chrome-child";
AlignSearchRow(); AlignSearchRow();
LayoutContentBelowSearch();
panel1.SizeChanged += (_, __) => LayoutMarkupRow(); SizeChanged += (_, __) => LayoutContentBelowSearch();
LayoutMarkupRow();
txtConsignee.Enabled = false; txtConsignee.Enabled = false;
txtConsignee.Text = _WorkWithConsignee; txtConsignee.Text = _WorkWithConsignee;
@ -91,6 +98,120 @@ namespace Электронная_Фармация.UserControls
txtSearchGoodNameInPriceList.Focus(); txtSearchGoodNameInPriceList.Focus();
} }
/// <summary>
/// Поиск остаётся сверху как в оригинале; контент явно ниже — без Dock-наложения.
/// </summary>
private void EnsureChromeLayout()
{
// Убираем старую dock-панель, если осталась от прошлых сборок в той же сессии.
var oldToolbar = Controls["priceToolbar"];
if (oldToolbar != null)
{
var toRestore = oldToolbar.Controls.Cast<Control>().ToArray();
foreach (var control in toRestore)
{
oldToolbar.Controls.Remove(control);
Controls.Add(control);
control.BringToFront();
}
Controls.Remove(oldToolbar);
oldToolbar.Dispose();
}
// Строка наценки скрыта — проценты берутся из настроек/дефолта без UI-прослойки.
if (TLPPriceList.RowStyles.Count >= 2)
{
TLPPriceList.RowStyles[1].SizeType = SizeType.Absolute;
TLPPriceList.RowStyles[1].Height = 0;
}
panel1.Visible = false;
panel1.Height = 0;
SplitPriceList.Panel1MinSize = 120;
SplitPriceList.Panel2MinSize = 80;
SplitPriceList.IsSplitterFixed = false;
SplitPriceList.FixedPanel = FixedPanel.None;
SplitPriceList.SplitterWidth = 6;
// Восстановить положение сплиттера корзины, если пользователь уже двигал.
RestoreCartSplitterDistance();
SplitPriceList.SplitterMoved += (_, __) => PersistCartSplitterDistance();
dgvPriceList.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None;
dgvPriceList.ColumnHeadersVisible = true;
dgvPriceList.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing;
if (dgvPriceList.ColumnHeadersHeight < 34)
{
dgvPriceList.ColumnHeadersHeight = 36;
}
if (!POrderInfo.Controls.ContainsKey("cartHeader"))
{
var cartHeader = new ModernSectionHeader
{
Name = "cartHeader",
Height = 32,
HeaderText = "Корзина"
};
cartHeader.ApplyTheme(Elfisa.UI.Theming.ThemeManager.Colors, cartStyle: true);
cartHeader.Dock = DockStyle.Top;
POrderInfo.Controls.Add(cartHeader);
tabOrderItems.SendToBack();
cartHeader.BringToFront();
}
}
private void LayoutContentBelowSearch()
{
const int searchBand = 52;
PContentPriceList.Dock = DockStyle.None;
PContentPriceList.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
PContentPriceList.SetBounds(
0,
searchBand,
Math.Max(100, ClientSize.Width),
Math.Max(120, ClientSize.Height - searchBand));
}
private void RestoreCartSplitterDistance()
{
try
{
var saved = Properties.Settings.Default.PriceListSplitterDistance;
if (saved <= 0 || SplitPriceList.Height < 200)
{
return;
}
var max = Math.Max(
SplitPriceList.Panel1MinSize,
SplitPriceList.Height - SplitPriceList.Panel2MinSize - SplitPriceList.SplitterWidth);
SplitPriceList.SplitterDistance = Math.Max(SplitPriceList.Panel1MinSize, Math.Min(saved, max));
}
catch
{
// ignore until layout ready
}
}
private void PersistCartSplitterDistance()
{
try
{
if (SplitPriceList.SplitterDistance > 80)
{
Properties.Settings.Default.PriceListSplitterDistance = SplitPriceList.SplitterDistance;
Properties.Settings.Default.Save();
}
}
catch
{
// ignore
}
}
private void EnsureBrowseSplit() private void EnsureBrowseSplit()
{ {
if (splitNameMgBrowse != null) if (splitNameMgBrowse != null)
@ -106,13 +227,35 @@ namespace Электронная_Фармация.UserControls
Name = "splitNameMgBrowse", Name = "splitNameMgBrowse",
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
Orientation = Orientation.Vertical, Orientation = Orientation.Vertical,
SplitterWidth = 4, SplitterWidth = 6,
Visible = false Visible = false,
Panel1MinSize = 100,
Panel2MinSize = 80,
IsSplitterFixed = false,
FixedPanel = FixedPanel.None
}; };
splitNameMgBrowse.Panel1.Controls.Add(dgvBrowseNames); splitNameMgBrowse.Panel1.Controls.Add(dgvBrowseNames);
splitNameMgBrowse.Panel2.Controls.Add(dgvBrowseMg); splitNameMgBrowse.Panel2.Controls.Add(dgvBrowseMg);
TLPPriceList.Controls.Add(splitNameMgBrowse, 0, 0); var gridHost = TLPPriceList.Controls["priceGridHost"] as Panel;
if (gridHost == null)
{
gridHost = new Panel
{
Name = "priceGridHost",
Dock = DockStyle.Fill,
Margin = new Padding(0),
Padding = new Padding(0),
Tag = "chrome-child"
};
TLPPriceList.Controls.Remove(dgvPriceList);
dgvPriceList.Dock = DockStyle.Fill;
gridHost.Controls.Add(dgvPriceList);
TLPPriceList.Controls.Add(gridHost, 0, 0);
}
gridHost.Controls.Add(splitNameMgBrowse);
splitNameMgBrowse.BringToFront(); splitNameMgBrowse.BringToFront();
dgvBrowseNames.SelectionChanged += dgvBrowseNames_SelectionChanged; dgvBrowseNames.SelectionChanged += dgvBrowseNames_SelectionChanged;
@ -151,11 +294,13 @@ namespace Электронная_Фармация.UserControls
private void AlignSearchRow() private void AlignSearchRow()
{ {
const int searchY = 9; const int searchY = 10;
const int searchHeight = 32; const int searchHeight = 32;
const int clearGap = 2; const int clearGap = 4;
const int leftPad = 10;
txtSearchGoodNameInPriceList.SetBounds(78, searchY, 313, searchHeight); label2.Location = new Point(leftPad, searchY + 4);
txtSearchGoodNameInPriceList.SetBounds(label2.Right + 6, searchY, 300, searchHeight);
btnClearSearchInPriceList.SetBounds( btnClearSearchInPriceList.SetBounds(
txtSearchGoodNameInPriceList.Right + clearGap, txtSearchGoodNameInPriceList.Right + clearGap,
searchY, searchY,
@ -163,38 +308,23 @@ namespace Электронная_Фармация.UserControls
searchHeight); searchHeight);
ModernButtonStyles.ApplyClear(btnClearSearchInPriceList); ModernButtonStyles.ApplyClear(btnClearSearchInPriceList);
lblSupplierFilter.Location = new Point(btnClearSearchInPriceList.Right + 16, searchY + 4);
comboSupplierFilter.SetBounds(lblSupplierFilter.Right + 6, searchY, 240, searchHeight);
btnClearSupplierFilter.SetBounds(comboSupplierFilter.Right + clearGap, searchY, 32, searchHeight);
btnClearSupplierFilter.Height = searchHeight; btnClearSupplierFilter.Height = searchHeight;
// Поиск всегда поверх контента, но контент начинается ниже searchBand.
label2.BringToFront();
txtSearchGoodNameInPriceList.BringToFront();
btnClearSearchInPriceList.BringToFront();
lblSupplierFilter.BringToFront();
comboSupplierFilter.BringToFront();
btnClearSupplierFilter.BringToFront();
} }
private void LayoutMarkupRow() private void LayoutMarkupRow()
{ {
// Keep markup row readable at different DPI/window sizes. // UI наценки скрыт; метод оставлен на случай повторного включения.
try
{
var paddingLeft = 8;
var y = 30;
var gap = 8;
label4.Location = new Point(paddingLeft, y + 2);
numericPercent.Location = new Point(label4.Right + 6, y);
numericPercent.Width = 56;
checkUsePercentageAsDefault.AutoSize = true;
checkUsePercentageAsDefault.Location = new Point(
Math.Max(numericPercent.Right + gap, panel1.ClientSize.Width - checkUsePercentageAsDefault.PreferredSize.Width - 10),
y + 2);
lblPriceForGood.AutoSize = false;
lblPriceForGood.AutoEllipsis = true;
lblPriceForGood.Location = new Point(numericPercent.Right + gap, y + 2);
lblPriceForGood.Height = 22;
lblPriceForGood.Width = Math.Max(90, checkUsePercentageAsDefault.Left - lblPriceForGood.Left - gap);
}
catch
{
// best-effort layout; ignore
}
} }
private void InitializeSupplierFilter() private void InitializeSupplierFilter()
@ -399,13 +529,84 @@ namespace Электронная_Фармация.UserControls
tablePrice.Columns["Dosage"].ColumnName = "Дозировка"; tablePrice.Columns["Dosage"].ColumnName = "Дозировка";
} }
if (tablePrice.Columns.Contains("MarkupPercent"))
{
tablePrice.Columns["MarkupPercent"].ColumnName = "Наценка %";
}
EnsureDerivedBrowseColumns(tablePrice); EnsureDerivedBrowseColumns(tablePrice);
_fullPriceTable = tablePrice; _fullPriceTable = tablePrice;
RebuildBrowseIndex(tablePrice);
_selectedBaseName = string.Empty; _selectedBaseName = string.Empty;
_selectedMg = string.Empty; _selectedMg = string.Empty;
ShowBrowseView(); ShowBrowseView();
} }
private void RebuildBrowseIndex(DataTable tablePrice)
{
var names = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var mgMap = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (DataRow row in tablePrice.Rows)
{
var baseName = row["Базовое наименование"]?.ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(baseName))
{
continue;
}
names.Add(baseName);
if (!mgMap.TryGetValue(baseName, out var dosages))
{
dosages = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
mgMap[baseName] = dosages;
}
dosages.Add(row["МГ"]?.ToString() ?? string.Empty);
}
_uniqueBaseNames = names.ToList();
_mgByBaseName = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in mgMap)
{
var list = new List<string>(pair.Value.Count);
foreach (var dosage in pair.Value)
{
list.Add(string.IsNullOrEmpty(dosage) ? EmptyMgDisplay : dosage);
}
_mgByBaseName[pair.Key] = list;
}
}
private void EnsureSearchDebounceTimer()
{
if (_searchDebounceTimer != null)
{
return;
}
_searchDebounceTimer = new Timer { Interval = SearchDebounceMs };
_searchDebounceTimer.Tick += (_, __) =>
{
_searchDebounceTimer.Stop();
ResetBrowseToNames();
};
}
private void ScheduleBrowseSearchRefresh(bool immediate = false)
{
EnsureSearchDebounceTimer();
_searchDebounceTimer.Stop();
if (immediate)
{
ResetBrowseToNames();
return;
}
_searchDebounceTimer.Start();
}
private void HideOptionalColumn(string columnName) private void HideOptionalColumn(string columnName)
{ {
if (dgvPriceList.Columns.Contains(columnName)) if (dgvPriceList.Columns.Contains(columnName))
@ -500,55 +701,51 @@ namespace Электронная_Фармация.UserControls
} }
var search = (GoodsFilter ?? string.Empty).Trim(); var search = (GoodsFilter ?? string.Empty).Trim();
var namesTable = new DataTable(); IList<string> namesToShow;
namesTable.Columns.Add("Наименование", typeof(string)); if (string.IsNullOrWhiteSpace(search))
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); namesToShow = _uniqueBaseNames;
}
else
{
var matching = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (DataRow row in _fullPriceTable.Rows) foreach (DataRow row in _fullPriceTable.Rows)
{ {
var baseName = row["Базовое наименование"]?.ToString() ?? string.Empty; var baseName = row["Базовое наименование"]?.ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(baseName) || !seen.Add(baseName)) if (string.IsNullOrWhiteSpace(baseName) || matching.Contains(baseName))
{ {
continue; continue;
} }
if (!string.IsNullOrWhiteSpace(search)) var drugName = row["Наименование"]?.ToString() ?? string.Empty;
{ var mgText = row["МГ"]?.ToString() ?? string.Empty;
var keep = false;
foreach (DataRow candidate in _fullPriceTable.Rows)
{
if (!string.Equals(candidate["Базовое наименование"]?.ToString(), baseName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var drugName = candidate["Наименование"]?.ToString() ?? string.Empty;
var mgText = candidate["МГ"]?.ToString() ?? string.Empty;
if (baseName.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0 if (baseName.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0
|| drugName.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0 || drugName.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0
|| mgText.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0) || mgText.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0)
{ {
keep = true; matching.Add(baseName);
break;
} }
} }
if (!keep) namesToShow = matching
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList();
}
var namesTable = new DataTable();
namesTable.Columns.Add("Наименование", typeof(string));
namesTable.BeginLoadData();
foreach (var baseName in namesToShow)
{ {
continue;
}
}
namesTable.Rows.Add(baseName); namesTable.Rows.Add(baseName);
} }
namesTable.EndLoadData();
namesTable.DefaultView.Sort = "Наименование ASC";
var sortedNames = namesTable.DefaultView.ToTable();
try try
{ {
_suppressBrowseEvents = true; _suppressBrowseEvents = true;
dgvBrowseNames.DataSource = sortedNames; dgvBrowseNames.SuspendLayout();
dgvBrowseNames.DataSource = namesTable;
ConfigureSingleColumnGrid(dgvBrowseNames, "Наименование"); ConfigureSingleColumnGrid(dgvBrowseNames, "Наименование");
ClearMgPanel(); ClearMgPanel();
@ -563,6 +760,7 @@ namespace Электронная_Фармация.UserControls
} }
finally finally
{ {
dgvBrowseNames.ResumeLayout();
_suppressBrowseEvents = false; _suppressBrowseEvents = false;
} }
@ -589,28 +787,18 @@ namespace Электронная_Фармация.UserControls
var mgTable = new DataTable(); var mgTable = new DataTable();
mgTable.Columns.Add("МГ", typeof(string)); mgTable.Columns.Add("МГ", typeof(string));
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); if (_mgByBaseName.TryGetValue(baseName, out var dosages))
foreach (DataRow row in _fullPriceTable.Rows)
{ {
if (!string.Equals(row["Базовое наименование"]?.ToString(), baseName, StringComparison.OrdinalIgnoreCase)) foreach (var dosage in dosages)
{ {
continue; mgTable.Rows.Add(dosage);
}
} }
var dosage = row["МГ"]?.ToString() ?? string.Empty;
if (!seen.Add(dosage))
{
continue;
}
mgTable.Rows.Add(string.IsNullOrEmpty(dosage) ? EmptyMgDisplay : dosage);
}
mgTable.DefaultView.Sort = "МГ ASC";
try try
{ {
_suppressBrowseEvents = true; _suppressBrowseEvents = true;
dgvBrowseMg.DataSource = mgTable.DefaultView.ToTable(); dgvBrowseMg.DataSource = mgTable;
ConfigureSingleColumnGrid(dgvBrowseMg, "МГ"); ConfigureSingleColumnGrid(dgvBrowseMg, "МГ");
dgvBrowseMg.ClearSelection(); dgvBrowseMg.ClearSelection();
} }
@ -661,10 +849,21 @@ namespace Электронная_Фармация.UserControls
{ {
EnsureBrowseSplit(); EnsureBrowseSplit();
var gridHost = TLPPriceList.Controls["priceGridHost"] as Panel;
if (gridHost != null)
{
gridHost.Dock = DockStyle.Fill;
if (gridHost.Padding.Top < 8)
{
gridHost.Padding = new Padding(0, 8, 0, 0);
}
}
if (browseVisible) if (browseVisible)
{ {
dgvPriceList.Visible = false; dgvPriceList.Visible = false;
splitNameMgBrowse.Visible = true; splitNameMgBrowse.Visible = true;
splitNameMgBrowse.Dock = DockStyle.Fill;
splitNameMgBrowse.BringToFront(); splitNameMgBrowse.BringToFront();
var width = Math.Max(200, splitNameMgBrowse.Width); var width = Math.Max(200, splitNameMgBrowse.Width);
@ -690,6 +889,9 @@ namespace Электронная_Фармация.UserControls
splitNameMgBrowse.Visible = false; splitNameMgBrowse.Visible = false;
dgvPriceList.Visible = true; dgvPriceList.Visible = true;
dgvPriceList.Dock = DockStyle.Fill;
dgvPriceList.ColumnHeadersVisible = true;
dgvPriceList.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None;
dgvPriceList.BringToFront(); dgvPriceList.BringToFront();
} }
} }
@ -736,6 +938,79 @@ namespace Электронная_Фармация.UserControls
AppDebugLog.Error("PriceList", "Не удалось применить фильтр наименование+МГ", ex); AppDebugLog.Error("PriceList", "Не удалось применить фильтр наименование+МГ", ex);
bsPriceList.RemoveFilter(); bsPriceList.RemoveFilter();
} }
FocusOffersGridForInput();
}
private void FocusOffersGridForInput()
{
if (!IsHandleCreated)
{
return;
}
BeginInvoke(new Action(() =>
{
if (_browseMode != PriceBrowseMode.Offers || !dgvPriceList.Visible)
{
return;
}
EnsureOffersRowSelected();
dgvPriceList.Focus();
}));
}
private bool EnsureOffersRowSelected()
{
if (_browseMode != PriceBrowseMode.Offers || dgvPriceList.Rows.Count == 0)
{
return false;
}
if (dgvPriceList.SelectedRows.Count > 0)
{
return true;
}
for (var i = 0; i < dgvPriceList.Rows.Count; i++)
{
var row = dgvPriceList.Rows[i];
if (row.IsNewRow)
{
continue;
}
_suppressPriceListSelectionEvents = true;
try
{
row.Selected = true;
var columnIndex = GetPreferredOffersSelectionColumnIndex();
if (columnIndex >= 0 && columnIndex < row.Cells.Count)
{
dgvPriceList.CurrentCell = row.Cells[columnIndex];
}
}
finally
{
_suppressPriceListSelectionEvents = false;
}
ShowMePriceWithMarkupPercentForSelectedGood();
return true;
}
return false;
}
private int GetPreferredOffersSelectionColumnIndex()
{
if (dgvPriceList.Columns.Contains("Наименование"))
{
return dgvPriceList.Columns["Наименование"].Index;
}
return dgvPriceList.Columns.Count > 3 ? 3 : 0;
} }
private void BindOffersTable() private void BindOffersTable()
@ -797,6 +1072,7 @@ namespace Электронная_Фармация.UserControls
HideOptionalColumn("Торговое название"); HideOptionalColumn("Торговое название");
HideOptionalColumn("Дозировка"); HideOptionalColumn("Дозировка");
HideOptionalColumn("Наценка %");
HideOptionalColumn("Базовое наименование"); HideOptionalColumn("Базовое наименование");
HideOptionalColumn("МГ"); HideOptionalColumn("МГ");
@ -910,7 +1186,7 @@ namespace Электронная_Фармация.UserControls
GoodsFilter = GoodsFilter.Substring(0, GoodsFilter.Length - 1); GoodsFilter = GoodsFilter.Substring(0, GoodsFilter.Length - 1);
txtSearchGoodNameInPriceList.Text = GoodsFilter; txtSearchGoodNameInPriceList.Text = GoodsFilter;
ResetBrowseToNames(); ScheduleBrowseSearchRefresh(immediate: GoodsFilter.Length == 0);
if (GoodsFilter.Length == 0) if (GoodsFilter.Length == 0)
{ {
@ -927,14 +1203,60 @@ namespace Электронная_Фармация.UserControls
void loadInfoAboutDefaultMarkup() void loadInfoAboutDefaultMarkup()
{ {
var percentageMarkupDefault = Properties.Settings.Default.IntDefaultPercentMarkup; decimal percentageMarkupDefault = 0m;
var fromConsignee = ConsigneeHelper.GetClientMarkupPercent(_WorkWithConsignee);
if (fromConsignee.HasValue)
{
percentageMarkupDefault = fromConsignee.Value;
}
else
{
percentageMarkupDefault = Properties.Settings.Default.ClientMarkupPercent;
if (percentageMarkupDefault <= 0) if (percentageMarkupDefault <= 0)
{ {
percentageMarkupDefault = 30; percentageMarkupDefault = Properties.Settings.Default.IntDefaultPercentMarkup;
}
if (percentageMarkupDefault < 0)
{
percentageMarkupDefault = MarkupHelper.FallbackMarkupPercent;
}
// Первый запуск для этой аптеки — сразу закрепим текущее значение за аптекой.
if (!string.IsNullOrWhiteSpace(_WorkWithConsignee))
{
try
{
ConsigneeHelper.SetClientMarkupPercent(_WorkWithConsignee, percentageMarkupDefault);
}
catch
{
// колонка может ещё не быть — при следующем старте миграция добавит
}
}
} }
_percentageAsDefault = percentageMarkupDefault; _percentageAsDefault = percentageMarkupDefault;
numericPercent.Value = Math.Max(numericPercent.Minimum, Math.Min(numericPercent.Maximum, percentageMarkupDefault)); _suppressMarkupPersist = true;
try
{
numericPercent.DecimalPlaces = 2;
numericPercent.Increment = 0.1m;
numericPercent.Value = Math.Max(
numericPercent.Minimum,
Math.Min(numericPercent.Maximum, percentageMarkupDefault));
}
finally
{
_suppressMarkupPersist = false;
}
}
private decimal GetMarkupPercentForRow(DataGridViewRow row)
{
// Для превью/спиннера всегда используем процент аптеки, который задал клиент.
// Серверные значения в колонке «Наценка %» не перетирают ручной ввод.
return _percentageAsDefault;
} }
public void loadSupplierTabs(string SupplierName) public void loadSupplierTabs(string SupplierName)
@ -972,10 +1294,12 @@ namespace Электронная_Фармация.UserControls
List<String> SuppliersFromOrder = new List<String>(); List<String> SuppliersFromOrder = new List<String>();
var consigneeSafe = (_WorkWithConsignee ?? string.Empty).Replace("'", "''");
string qShowMeSuppliersFromOrder = $@" string qShowMeSuppliersFromOrder = $@"
select select
[SupplierName] [SupplierName]
from [TempOrderItems] from [TempOrderItems]
where [ConsigneeName] = '{consigneeSafe}'
group by [SupplierName] group by [SupplierName]
"; ";
@ -1197,7 +1521,7 @@ BestBefore = '{BestBefore}'
{ {
string connectionStringToLocalDB = AppConfig.SqliteConnectionString; string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string OrderNumber = $"EP-{DateTime.Now.Millisecond.ToString()}{DateTime.Now.Date.ToString("mmdd")}"; string OrderNumber = OrderNumberHelper.AllocateForNewOrder();
string OrderDate = DateTime.Now.ToString("yyyy-MM-dd");//DateTime.Now.ToShortDateString(); string OrderDate = DateTime.Now.ToString("yyyy-MM-dd");//DateTime.Now.ToShortDateString();
@ -1373,7 +1697,7 @@ select strftime('%Y-%m-%d','{OrderDate}'),
{ {
txtSearchGoodNameInPriceList.Text = string.Empty; txtSearchGoodNameInPriceList.Text = string.Empty;
GoodsFilter = string.Empty; GoodsFilter = string.Empty;
ResetBrowseToNames(); ScheduleBrowseSearchRefresh(immediate: true);
//loadPriceList(string.Empty); //loadPriceList(string.Empty);
} }
@ -1432,6 +1756,27 @@ and tempOrder.GoodName = PriceList.GoodName
string GoodsFilter = string.Empty; string GoodsFilter = string.Empty;
string stringIWantBuy = string.Empty; string stringIWantBuy = string.Empty;
private static bool IsGoodsSearchKey(char keyChar, bool allowDigitsAndSeparators = true)
{
if ((keyChar >= 'A' && keyChar <= 'Z')
|| (keyChar >= 'a' && keyChar <= 'z')
|| (keyChar >= 'А' && keyChar <= 'Я')
|| (keyChar >= 'а' && keyChar <= 'я')
|| keyChar == (char)Keys.Subtract)
{
return true;
}
if (!allowDigitsAndSeparators)
{
return false;
}
return (keyChar >= '0' && keyChar <= '9')
|| keyChar == ' '
|| keyChar == '.'
|| keyChar == ',';
}
private void dgvPriceList_KeyPress(object sender, KeyPressEventArgs e) private void dgvPriceList_KeyPress(object sender, KeyPressEventArgs e)
{ {
@ -1445,7 +1790,7 @@ and tempOrder.GoodName = PriceList.GoodName
{ {
txtSearchGoodNameInPriceList.Text = string.Empty; txtSearchGoodNameInPriceList.Text = string.Empty;
GoodsFilter = string.Empty; GoodsFilter = string.Empty;
ResetBrowseToNames(); ScheduleBrowseSearchRefresh(immediate: true);
} }
return; return;
@ -1454,7 +1799,18 @@ and tempOrder.GoodName = PriceList.GoodName
int RowIndex = -1; int RowIndex = -1;
string idPriceListItem = string.Empty; string idPriceListItem = string.Empty;
if (_browseMode == PriceBrowseMode.Offers && dgvPriceList.SelectedRows.Count > 0) if (_browseMode == PriceBrowseMode.Offers)
{
if (IsOrderQuantityStartKey(e.KeyChar))
{
EnsureOffersRowSelected();
if (!dgvPriceList.Focused)
{
dgvPriceList.Focus();
}
}
if (dgvPriceList.SelectedRows.Count > 0)
{ {
RowIndex = dgvPriceList.SelectedRows[0].Index; RowIndex = dgvPriceList.SelectedRows[0].Index;
if (RowIndex >= 0 if (RowIndex >= 0
@ -1464,6 +1820,7 @@ and tempOrder.GoodName = PriceList.GoodName
idPriceListItem = dgvPriceList.Rows[RowIndex].Cells[11].Value?.ToString() ?? string.Empty; idPriceListItem = dgvPriceList.Rows[RowIndex].Cells[11].Value?.ToString() ?? string.Empty;
} }
} }
}
if (e.KeyChar == (Char)Keys.Back) if (e.KeyChar == (Char)Keys.Back)
{ {
@ -1473,67 +1830,27 @@ and tempOrder.GoodName = PriceList.GoodName
if (_browseMode != PriceBrowseMode.Offers) if (_browseMode != PriceBrowseMode.Offers)
{ {
if ((e.KeyChar >= 'A' && e.KeyChar <= 'Z') if (IsGoodsSearchKey(e.KeyChar))
|| (e.KeyChar >= 'a' && e.KeyChar <= 'z')
|| (e.KeyChar >= 'А' && e.KeyChar <= 'Я')
|| (e.KeyChar >= 'а' && e.KeyChar <= 'я')
|| (e.KeyChar == (Char)Keys.Subtract))
{ {
dgvPriceList.ClearSelection(); dgvPriceList.ClearSelection();
GoodsFilter += e.KeyChar.ToString(); GoodsFilter += e.KeyChar.ToString();
txtSearchGoodNameInPriceList.Text = GoodsFilter; txtSearchGoodNameInPriceList.Text = GoodsFilter;
ResetBrowseToNames(); ScheduleBrowseSearchRefresh();
if (dgvBrowseNames != null && dgvBrowseNames.Rows.Count == 0) timerForResetSearch.Enabled = true;
{ timerForResetSearch.Start();
UiDialogs.ShowInfo("Подходящий товар не найден.", "Поиск", FindForm());
GoodsFilter = string.Empty;
txtSearchGoodNameInPriceList.Text = string.Empty;
ResetBrowseToNames();
}
} }
return; return;
} }
if ((e.KeyChar >= 'A' && e.KeyChar <= 'Z') || (e.KeyChar >= 'a' && e.KeyChar <= 'z') || (e.KeyChar >= 'А' && e.KeyChar <= 'Я') || (e.KeyChar >= 'а' && e.KeyChar <= 'я') || (e.KeyChar == (Char)Keys.Subtract)) if (IsGoodsSearchKey(e.KeyChar, allowDigitsAndSeparators: false))
{
if (e.KeyChar == (Char)Keys.Enter)
{
}
else
{ {
dgvPriceList.ClearSelection(); dgvPriceList.ClearSelection();
GoodsFilter += e.KeyChar.ToString(); GoodsFilter += e.KeyChar.ToString();
txtSearchGoodNameInPriceList.Text = GoodsFilter; txtSearchGoodNameInPriceList.Text = GoodsFilter;
ScheduleBrowseSearchRefresh();
#region поиск без фильтрации (НЕ РАБОТАЕТ) timerForResetSearch.Enabled = true;
//foreach(DataGridViewRow row in dgvPriceList.Rows) timerForResetSearch.Start();
//{
// //string cellValue = row.Cells["Наименование"].Value.ToString().ToLower();
// if (row.Cells[2].Value.ToString().Equals(GoodsFilter))
// {
// int indexOfSearchValue = row.Index;
// dgvPriceList.Rows[indexOfSearchValue].Selected = true;
// }
//}
#endregion
#region старый способ поиска с фильтрацией данных
if (!string.IsNullOrEmpty(GoodsFilter))
{
ResetBrowseToNames();
if (dgvPriceList.Rows.Count == 0)
{
UiDialogs.ShowInfo("Подходящий товар не найден.", "Поиск", FindForm());
GoodsFilter = string.Empty;
txtSearchGoodNameInPriceList.Text = string.Empty;
ResetBrowseToNames();
}
}
#endregion
}
return; return;
} }
else if ((e.KeyChar >= (Char)Keys.D1 && e.KeyChar <= (Char)Keys.D9) || else if ((e.KeyChar >= (Char)Keys.D1 && e.KeyChar <= (Char)Keys.D9) ||
@ -1647,9 +1964,10 @@ and tempOrder.GoodName = PriceList.GoodName
void creatingTempOrder(string idPriceListItem, string zakaz, string summaZakaza) void creatingTempOrder(string idPriceListItem, string zakaz, string summaZakaza)
{ {
var consigneeSafe = (_WorkWithConsignee ?? string.Empty).Replace("'", "''");
string qUpdatePriceList = $"update [PriceList] set [Zakaz] = '{zakaz}', [SummaZakaza] = '{summaZakaza}' where [id_PriceList_Item] = '{idPriceListItem}'"; string qUpdatePriceList = $"update [PriceList] set [Zakaz] = '{zakaz}', [SummaZakaza] = '{summaZakaza}' where [id_PriceList_Item] = '{idPriceListItem}'";
string qDeleteFromTempOrder = $"delete from [TempOrderItems] where [id_PriceList_Item] = '{idPriceListItem}'"; string qDeleteFromTempOrder = $"delete from [TempOrderItems] where [id_PriceList_Item] = '{idPriceListItem}' and [ConsigneeName] = '{consigneeSafe}'";
string qCreateNewTempOrder = $"insert into TempOrderItems([guid_es],[es_code],[supplier_id],[DrugName],[SupplierName],[Price],[Quantity],[ExpiryPeriod],[Description],[Zakaz],[SummaZakaza],[id_PriceList_Item],[supplier_price_id]) select [guid_es],[es_code],[supplier_id],[DrugName],[SupplierName],[Price],[Quantity],[ExpiryPeriod],[Description],[Zakaz],[SummaZakaza],[id_PriceList_Item],[supplier_price_id] from [PriceList] where [id_PriceList_Item] = '{idPriceListItem}'"; string qCreateNewTempOrder = $"insert into TempOrderItems([ConsigneeName],[guid_es],[es_code],[supplier_id],[DrugName],[SupplierName],[Price],[Quantity],[ExpiryPeriod],[Description],[Zakaz],[SummaZakaza],[id_PriceList_Item],[supplier_price_id]) select '{consigneeSafe}',[guid_es],[es_code],[supplier_id],[DrugName],[SupplierName],[Price],[Quantity],[ExpiryPeriod],[Description],[Zakaz],[SummaZakaza],[id_PriceList_Item],[supplier_price_id] from [PriceList] where [id_PriceList_Item] = '{idPriceListItem}'";
using(SQLiteConnection sqlCon = new SQLiteConnection(connectionStringToLocalDB)) using(SQLiteConnection sqlCon = new SQLiteConnection(connectionStringToLocalDB))
{ {
@ -1683,13 +2001,14 @@ and tempOrder.GoodName = PriceList.GoodName
void deleteGood(int index, string IdPriceListItem) void deleteGood(int index, string IdPriceListItem)
{ {
var consigneeSafe = (_WorkWithConsignee ?? string.Empty).Replace("'", "''");
dgvPriceList.Rows[index].Cells[9].Value = string.Empty; dgvPriceList.Rows[index].Cells[9].Value = string.Empty;
dgvPriceList.Rows[index].Cells[10].Value = string.Empty; dgvPriceList.Rows[index].Cells[10].Value = string.Empty;
string IdPLI = IdPriceListItem; string IdPLI = IdPriceListItem;
string qClearPriceList = $"update [PriceList] set [Zakaz] = null, [SummaZakaza] = null where id_PriceList_Item = '{IdPLI}'"; string qClearPriceList = $"update [PriceList] set [Zakaz] = null, [SummaZakaza] = null where id_PriceList_Item = '{IdPLI}'";
string qDeleteFromTempOrder = $"delete from [TempOrderItems] where [id_PriceList_Item] = '{IdPLI}'"; string qDeleteFromTempOrder = $"delete from [TempOrderItems] where [id_PriceList_Item] = '{IdPLI}' and [ConsigneeName] = '{consigneeSafe}'";
using(SQLiteConnection sqlCon = new SQLiteConnection(connectionStringToLocalDB)) using(SQLiteConnection sqlCon = new SQLiteConnection(connectionStringToLocalDB))
{ {
@ -1911,9 +2230,20 @@ and SumOrderedItems = '{SumOrder}'";
return; return;
} }
var markupPercentage = (double)numericPercent.Value; var markupPercentage = GetMarkupPercentForRow(currentRow);
var priceWithPercentage = priceForGood + (priceForGood / 100 * markupPercentage); _suppressMarkupPersist = true;
lblPriceForGood.Text = $"= {priceWithPercentage} руб"; try
{
numericPercent.Value = Math.Max(
numericPercent.Minimum,
Math.Min(numericPercent.Maximum, markupPercentage));
}
finally
{
_suppressMarkupPersist = false;
}
lblPriceForGood.Text = $"= {priceForGood:0.##} руб";
} }
catch catch
{ {
@ -2089,6 +2419,12 @@ and SumOrderedItems = '{SumOrder}'";
return builder.ToString(); return builder.ToString();
} }
private static bool IsOrderQuantityStartKey(char keyChar)
{
return (keyChar >= (char)Keys.D1 && keyChar <= (char)Keys.D9)
|| (keyChar >= (char)Keys.NumPad1 && keyChar <= (char)Keys.NumPad9);
}
private void dgvPriceList_SelectionChanged(object sender, EventArgs e) private void dgvPriceList_SelectionChanged(object sender, EventArgs e)
{ {
if (_suppressPriceListSelectionEvents) if (_suppressPriceListSelectionEvents)
@ -2283,6 +2619,11 @@ and SumOrderedItems = '{SumOrder}'";
private void numericPercent_ValueChanged(object sender, EventArgs e) private void numericPercent_ValueChanged(object sender, EventArgs e)
{ {
if (!_suppressMarkupPersist)
{
PersistClientMarkupPercent();
}
if (numericPercent.Value != _percentageAsDefault) if (numericPercent.Value != _percentageAsDefault)
{ {
checkUsePercentageAsDefault.Checked = false; checkUsePercentageAsDefault.Checked = false;
@ -2291,6 +2632,30 @@ and SumOrderedItems = '{SumOrder}'";
ShowMePriceWithMarkupPercentForSelectedGood(); ShowMePriceWithMarkupPercentForSelectedGood();
} }
private void PersistClientMarkupPercent()
{
var value = numericPercent.Value;
_percentageAsDefault = value;
if (!string.IsNullOrWhiteSpace(_WorkWithConsignee))
{
try
{
ConsigneeHelper.SetClientMarkupPercent(_WorkWithConsignee, value);
}
catch
{
// ignore transient DB errors; value stays in UI
}
}
// Глобальный fallback на случай новой аптеки без своего значения.
Properties.Settings.Default.ClientMarkupPercent = value;
Properties.Settings.Default.IntDefaultPercentMarkup =
Convert.ToInt32(Math.Round(value, MidpointRounding.AwayFromZero));
Properties.Settings.Default.Save();
}
private void UCPriceList_Paint(object sender, PaintEventArgs e) private void UCPriceList_Paint(object sender, PaintEventArgs e)
{ {
if (comboSupplierFilter.DroppedDown) if (comboSupplierFilter.DroppedDown)
@ -2313,12 +2678,9 @@ and SumOrderedItems = '{SumOrder}'";
private void checkUsePercentageAsDefault_CheckedChanged(object sender, EventArgs e) private void checkUsePercentageAsDefault_CheckedChanged(object sender, EventArgs e)
{ {
int newDefaultPercent; if (checkUsePercentageAsDefault.Checked)
if (_percentageAsDefault != numericPercent.Value && checkUsePercentageAsDefault.Checked == true)
{ {
newDefaultPercent = Convert.ToInt32(numericPercent.Value); PersistClientMarkupPercent();
Properties.Settings.Default.IntDefaultPercentMarkup = Convert.ToInt32(newDefaultPercent);
Properties.Settings.Default.Save();
} }
} }
@ -2326,7 +2688,7 @@ and SumOrderedItems = '{SumOrder}'";
{ {
txtSearchGoodNameInPriceList.Text = string.Empty; txtSearchGoodNameInPriceList.Text = string.Empty;
GoodsFilter = string.Empty; GoodsFilter = string.Empty;
ResetBrowseToNames(); ScheduleBrowseSearchRefresh(immediate: true);
timerForResetSearch.Enabled = false; timerForResetSearch.Enabled = false;
timerForResetSearch.Stop(); timerForResetSearch.Stop();
} }
@ -2373,3 +2735,5 @@ and SumOrderedItems = '{SumOrder}'";
//} //}
} }
} }

View File

@ -124,12 +124,7 @@ namespace Электронная_Фармация.UserControls
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString)) using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{ {
con.Open(); con.Open();
using (var cmd = new SQLiteCommand(GetQueryForNewSchema(), con))
var orderColumns = GetColumns(con, "Orders");
var usesNewSchema = orderColumns.Contains("Id_Order") && orderColumns.Contains("OrderedCountItems");
var q = usesNewSchema ? GetQueryForNewSchema() : GetQueryForOldSchema();
using (var cmd = new SQLiteCommand(q, con))
using (var da = new SQLiteDataAdapter(cmd)) using (var da = new SQLiteDataAdapter(cmd))
{ {
var table = new DataTable(); var table = new DataTable();
@ -186,26 +181,6 @@ order by oi.[DrugName]", con))
} }
} }
} }
private static HashSet<string> GetColumns(SQLiteConnection con, string tableName)
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = new SQLiteCommand($"pragma table_info([{tableName}]);", con))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var name = reader["name"] as string;
if (!string.IsNullOrWhiteSpace(name))
{
columns.Add(name);
}
}
}
return columns;
}
private static string GetQueryForNewSchema() private static string GetQueryForNewSchema()
{ {
return @" return @"
@ -237,26 +212,5 @@ group by
having sum(ifnull(oi.[RefuseItemsCount], 0)) > 0 having sum(ifnull(oi.[RefuseItemsCount], 0)) > 0
order by o.[OrderDate] desc;"; order by o.[OrderDate] desc;";
} }
private static string GetQueryForOldSchema()
{
return @"
select
o.[OrderNumber] as [Номер заказа],
o.[OrderDate] as [Дата],
supp.[SuppliersName] as [Поставщик],
consign.[ConsigneesName] as [Грузополучатель],
o.[OrderedItemsCount] as [К-во товара в заказе],
o.[OrderSum] as [Сумма заказа],
o.[RefuseItemsCount] as [К-во отказанного товара],
o.[RefuseSum] as [Сумма отказа],
o.[Comment] as [Комментарий],
o.[OrderState] as [Статус]
from Orders as o
inner join Consignees consign on consign.codeConsignees = o.codeConsignees
inner join Suppliers supp on supp.codeSuppliers = o.codeSuppliers
where ifnull(o.[RefuseItemsCount], 0) > 0 or ifnull(o.[RefuseSum], 0) > 0
order by [OrderDate] desc;";
}
} }
} }

View File

@ -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
{
/// <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 readonly TextBox _txtPrompt;
private readonly ComboBox _cboFormat;
private readonly Button _btnAi;
private List<BuyerReportLineDto> _lines = new List<BuyerReportLineDto>();
// Палитра под общий стиль приложения (бирюза шапки #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<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)
{
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}";
}
}
}

View File

@ -0,0 +1,52 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Elfisa.Avalonia.App"
xmlns:local="using:Elfisa.Avalonia"
RequestedThemeVariant="Light">
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="Bg" Color="#EDF2F2"/>
<SolidColorBrush x:Key="Surface" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Text" Color="#1F2933"/>
<SolidColorBrush x:Key="Muted" Color="#6B7785"/>
<SolidColorBrush x:Key="Border" Color="#E1E7E8"/>
<SolidColorBrush x:Key="RowAlt" Color="#F5F9F9"/>
<SolidColorBrush x:Key="InputBg" Color="#FFFFFF"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="Bg" Color="#0E1416"/>
<SolidColorBrush x:Key="Surface" Color="#182226"/>
<SolidColorBrush x:Key="Text" Color="#E7EDEE"/>
<SolidColorBrush x:Key="Muted" Color="#8FA0A5"/>
<SolidColorBrush x:Key="Border" Color="#28353A"/>
<SolidColorBrush x:Key="RowAlt" Color="#141D20"/>
<SolidColorBrush x:Key="InputBg" Color="#0F1719"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<!-- Акцент бренда (#0F766E — бирюза шапки), постоянен в обеих темах -->
<Color x:Key="AccentColor">#0F766E</Color>
<SolidColorBrush x:Key="Accent" Color="#0F766E"/>
<SolidColorBrush x:Key="AccentHover" Color="#0D9488"/>
<SolidColorBrush x:Key="AccentPressed" Color="#115E59"/>
<SolidColorBrush x:Key="AccentSoft" Color="#CCFBF1"/>
<SolidColorBrush x:Key="Sidebar" Color="#0E5F58"/>
<SolidColorBrush x:Key="SidebarActive" Color="#0A4A44"/>
<SolidColorBrush x:Key="SidebarText" Color="#D5F5F0"/>
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
<StyleInclude Source="/Styles/Elfisa.axaml"/>
</Application.Styles>
</Application>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<Version>2.0.1</Version>
<AssemblyVersion>2.0.1.0</AssemblyVersion>
<FileVersion>2.0.1.0</FileVersion>
<Product>ЭльФиСА — Электронная Фармация</Product>
<Company>PharmData</Company>
<!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). -->
<SatelliteResourceLanguages>ru;en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elfisa.Core\Elfisa.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -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<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}

View File

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

View File

@ -0,0 +1,193 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ============ Типографика ============ -->
<Style Selector="TextBlock.h1">
<Setter Property="FontSize" Value="22"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
</Style>
<Style Selector="TextBlock.h2">
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
<Setter Property="FontSize" Value="12.5"/>
</Style>
<Style Selector="TextBlock.label">
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- ============ Карточка ============ -->
<Style Selector="Border.card">
<Setter Property="Background" Value="{DynamicResource Surface}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="18"/>
<Setter Property="BoxShadow" Value="0 2 14 0 #12000000"/>
</Style>
<!-- ============ Кнопки ============ -->
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource Accent}"/>
<Setter Property="Foreground" Value="{DynamicResource OnAccent}"/>
<Setter Property="Padding" Value="18,10"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.primary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentHover}"/>
<Setter Property="TextBlock.Foreground" Value="{DynamicResource OnAccent}"/>
</Style>
<Style Selector="Button.primary:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentPressed}"/>
</Style>
<Style Selector="Button.primary:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Muted}"/>
</Style>
<Style Selector="Button.ghost">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource Accent}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="16,9"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentSoft}"/>
</Style>
<!-- ============ Поля ввода ============ -->
<Style Selector="TextBox">
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
<Setter Property="MinHeight" Value="38"/>
</Style>
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
</Style>
<Style Selector="ComboBox">
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="MinHeight" Value="38"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
</Style>
<Style Selector="DatePicker">
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="MinHeight" Value="38"/>
</Style>
<!-- ============ Сайдбар-навигация ============ -->
<Style Selector="ListBox.nav">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
</Style>
<Style Selector="ListBox.nav ListBoxItem">
<Setter Property="Foreground" Value="{DynamicResource SidebarText}"/>
<Setter Property="Padding" Value="14,11"/>
<Setter Property="Margin" Value="10,2"/>
<Setter Property="CornerRadius" Value="9"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="ListBox.nav ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
</Style>
<Style Selector="ListBox.nav ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
</Style>
<Style Selector="ListBox.nav ListBoxItem:selected">
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<!-- ============ Верхняя навигация (как в WinForms-версии) ============ -->
<Style Selector="ListBox.topnav">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
</Style>
<Style Selector="ListBox.topnav ListBoxItem">
<Setter Property="Padding" Value="6,7"/>
<Setter Property="Margin" Value="2,0"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="ListBox.topnav ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
</Style>
<Style Selector="ListBox.topnav ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
</Style>
<!-- ============ Вкладки-документы ============ -->
<Style Selector="ListBox.tabs">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
</Style>
<Style Selector="ListBox.tabs ListBoxItem">
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Margin" Value="3,6"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="ListBox.tabs ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource RowAlt}"/>
</Style>
<Style Selector="ListBox.tabs ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentSoft}"/>
</Style>
<Style Selector="ListBox.tabs ListBoxItem:selected">
<Setter Property="Foreground" Value="{DynamicResource AccentPressed}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<!-- ============ Таблица ============ -->
<Style Selector="DataGrid">
<Setter Property="Background" Value="{DynamicResource Surface}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Border}"/>
<Setter Property="RowBackground" Value="{DynamicResource Surface}"/>
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
<Setter Property="RowHeight" Value="34"/>
<Setter Property="FontSize" Value="13"/>
</Style>
<Style Selector="DataGridColumnHeader">
<Setter Property="Background" Value="{DynamicResource Accent}"/>
<Setter Property="Foreground" Value="{DynamicResource OnAccent}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="MinHeight" Value="36"/>
<Setter Property="SeparatorBrush" Value="#2FFFFFFF"/>
</Style>
<Style Selector="DataGridCell">
<Setter Property="Padding" Value="10,0"/>
</Style>
<!-- числовые ячейки — вправо -->
<Style Selector="DataGridCell.num TextBlock">
<Setter Property="TextAlignment" Value="Right"/>
</Style>
<Style Selector="DataGridRow:selected /template/ Rectangle#BackgroundRectangle">
<Setter Property="Fill" Value="{DynamicResource AccentSoft}"/>
<Setter Property="Opacity" Value="1"/>
</Style>
</Styles>

View File

@ -0,0 +1,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Elfisa.Avalonia.ViewModels;
namespace Elfisa.Avalonia;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[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;
}
}

View File

@ -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();
}
}
/// <summary>Единая корзина заказа — общая для «Прайс-листа» и «Отправить».</summary>
public sealed class CartService
{
public static CartService Instance { get; } = new();
public ObservableCollection<CartItem> 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();
}

View File

@ -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;
/// <summary>Справочник покупателя: профиль + грузополучатели (адреса).</summary>
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<BuyerLocationDto> 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; }
}
}

View File

@ -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; }
}
/// <summary>
/// Накладные — реестр приходных документов. Строится из /api/buyer/report
/// (после деплоя фикса /api/buyer/orders можно перейти на «родной» источник).
/// </summary>
public partial class InvoicesViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
public ObservableCollection<InvoiceRow> 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; }
}
}

View File

@ -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;
/// <summary>«Загрузить» — скачивание подключённых прайсов с сервера в память.</summary>
public partial class LoadPriceViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
/// <summary>Прайсы, подключённые покупателю (какие именно скачиваем).</summary>
public ObservableCollection<BuyerPriceListDto> 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();
}
/// <summary>Тянет список подключённых прайсов — чтобы было видно, что именно скачиваем.</summary>
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<PriceItem>();
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; }
}
}

View File

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

View File

@ -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<OrderItemRow> Items { get; init; } = new();
public bool IsDraft { get; init; }
public string? DraftId { get; init; }
public string? LocationId { get; init; }
public string? Comment { get; init; }
}
/// <summary>
/// Заказы: локальные черновики (статус «Не отправлен») + серверные заказы (из /api/buyer/report).
/// Черновик можно отправить (создать реальный заказ) или удалить.
/// </summary>
public partial class OrdersViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
public ObservableCollection<OrderSummary> 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<OrderItemRow>(
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<OrderItemRow>(
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 = "Черновик удалён.";
}
}

View File

@ -0,0 +1,15 @@
namespace Elfisa.Avalonia.ViewModels;
/// <summary>Заглушка ещё не портированного раздела.</summary>
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;
}
}

View File

@ -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;
/// <summary>Строка предложения с редактируемым количеством «Заказ» (как колонка «Заказ» в WinForms).</summary>
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;
}
/// <summary>
/// Прайс-лист + сборка заказа. Каталог — как в старой программе:
/// база (название) → дозировка (МГ) → предложения поставщиков. Справа «Мой заказ».
/// «Сохранить заказ» кладёт черновик в «Заказы» (статус «Не отправлен»).
/// </summary>
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<string> BaseNames { get; } = new();
public ObservableCollection<string> MgOptions { get; } = new();
public ObservableCollection<OfferRow> Offers { get; } = new();
public ObservableCollection<CartItem> Cart => _cart.Items;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
public ObservableCollection<BuyerPriceListDto> 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;
/// <summary>Загружает подключённые покупателю прайсы в дропдаун «Мои прайсы».</summary>
private async Task LoadPriceListsAsync()
{
List<BuyerPriceListDto> 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; }
}
/// <summary>Пересобирает индекс база/МГ и список базовых наименований.</summary>
private void RebuildIndex(List<PriceItem> 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();
/// <summary>Добавляет выбранное предложение в заказ с его количеством (Enter в таблице / кнопка).</summary>
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; }
}
}

View File

@ -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)
}
/// <summary>Строка отчёта: ячейки как строки, доступ по индексу для DataGrid.</summary>
public sealed class ReportRow
{
private readonly string[] _cells;
public ReportRow(string[] cells) => _cells = cells;
public string this[int i] => i >= 0 && i < _cells.Length ? _cells[i] : "";
}
public partial class ReportsViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
private List<BuyerReportLineDto> _lines = new();
/// <summary>Сигнал View пересобрать колонки DataGrid.</summary>
public event Action? ColumnsChanged;
public ObservableCollection<ReportColumn> Columns { get; } = new();
public ObservableCollection<ReportRow> Rows { get; } = new();
[ObservableProperty] private DateTimeOffset _dateFrom = DateTimeOffset.Now.AddMonths(-1);
[ObservableProperty] private DateTimeOffset _dateTo = DateTimeOffset.Now;
public string[] ReportTypes { get; } = { "Мои заказы", "По поставщикам", "По товарам", "По аптекам" };
[ObservableProperty] private int _selectedReportIndex;
public string[] AiFormats { get; } = { "Word", "Excel" };
[ObservableProperty] private int _selectedAiFormat;
[ObservableProperty] private string _aiPrompt = "";
[ObservableProperty] private string _totals = "";
[ObservableProperty] private string? _status;
[ObservableProperty] private bool _busy;
[ObservableProperty] private bool _aiBusy;
public ReportsViewModel()
{
_api.SetToken(Session.Current.Token);
if (Session.Current.IsAuthenticated)
_ = BuildAsync(); // авто-загрузка за последний месяц при открытии
}
partial void OnSelectedReportIndexChanged(int value) => Render();
[RelayCommand]
private async Task BuildAsync()
{
Status = null;
try
{
Busy = true;
_lines = await _api.GetBuyerReportAsync(DateFrom.DateTime.Date, DateTo.DateTime.Date);
Render();
if (_lines.Count == 0) Status = "За выбранный период заказов нет.";
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task GenerateAiAsync()
{
Status = null;
if (string.IsNullOrWhiteSpace(AiPrompt)) { Status = "Введите запрос для ИИ."; return; }
try
{
AiBusy = true;
var fmt = SelectedAiFormat == 1 ? "xlsx" : "docx";
var res = await _api.GenerateAiReportAsync(AiPrompt.Trim(), fmt);
var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var safe = string.Join("_", res.FileName.Split(Path.GetInvalidFileNameChars()));
var path = Path.Combine(dir, safe);
if (File.Exists(path))
{
var name = Path.GetFileNameWithoutExtension(safe);
var ext = Path.GetExtension(safe);
path = Path.Combine(dir, $"{name}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}");
}
File.WriteAllBytes(path, res.Content);
Status = "Отчёт сохранён: " + path;
OpenFile(path);
}
catch (Exception ex) { Status = ex.Message; }
finally { AiBusy = false; }
}
private void Render()
{
Columns.Clear();
Rows.Clear();
var (cols, rows) = Aggregate(_lines, SelectedReportIndex);
foreach (var c in cols) Columns.Add(c);
foreach (var r in rows) Rows.Add(new ReportRow(r));
var orders = _lines.Select(l => l.OrderId).Distinct().Count();
var sum = _lines.Sum(l => l.Sum);
Totals = $"Заказов: {orders} Позиций: {_lines.Count} Сумма: {sum:N2}";
ColumnsChanged?.Invoke();
}
private static (List<ReportColumn> cols, List<string[]> rows) Aggregate(List<BuyerReportLineDto> lines, int type)
{
string N2(decimal d) => d.ToString("N2");
string N3(decimal d) => d.ToString("N3");
string D(DateTime? dt) => dt?.ToLocalTime().ToString("dd.MM.yyyy") ?? "";
switch (type)
{
case 1: // По поставщикам
{
var cols = new List<ReportColumn>
{
new() { Header = "Поставщик", Stretch = true },
new() { Header = "Заказов", Numeric = true, Weight = 90 },
new() { Header = "Позиций", Numeric = true, Weight = 90 },
new() { Header = "Сумма", Numeric = true, Weight = 120 },
};
var rows = lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Supplier) ? "(не указан)" : l.Supplier!)
.OrderByDescending(g => g.Sum(x => x.Sum))
.Select(g => new[] { g.Key, g.Select(x => x.OrderId).Distinct().Count().ToString(),
g.Count().ToString(), N2(g.Sum(x => x.Sum)) }).ToList();
return (cols, rows);
}
case 2: // По товарам
{
var cols = new List<ReportColumn>
{
new() { Header = "Товар", Stretch = true },
new() { Header = "Код", Weight = 100 },
new() { Header = "Кол-во", Numeric = true, Weight = 100 },
new() { Header = "Сумма", Numeric = true, Weight = 120 },
};
var rows = lines.GroupBy(l => ((l.ItemName ?? "").Trim() + "|" + (l.ItemCode ?? "")))
.OrderByDescending(g => g.Sum(x => x.Sum))
.Select(g => { var f = g.First(); return new[] { f.ItemName ?? "", f.ItemCode ?? "",
N3(g.Sum(x => x.Qty)), N2(g.Sum(x => x.Sum)) }; }).ToList();
return (cols, rows);
}
case 3: // По аптекам
{
var cols = new List<ReportColumn>
{
new() { Header = "Аптека", Stretch = true },
new() { Header = "Заказов", Numeric = true, Weight = 90 },
new() { Header = "Позиций", Numeric = true, Weight = 90 },
new() { Header = "Сумма", Numeric = true, Weight = 120 },
};
var rows = lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Location) ? "(не указана)" : l.Location!)
.OrderByDescending(g => g.Sum(x => x.Sum))
.Select(g => new[] { g.Key, g.Select(x => x.OrderId).Distinct().Count().ToString(),
g.Count().ToString(), N2(g.Sum(x => x.Sum)) }).ToList();
return (cols, rows);
}
default: // Мои заказы
{
var cols = new List<ReportColumn>
{
new() { Header = "Дата", Weight = 105 },
new() { Header = "Номер", Weight = 138 },
new() { Header = "Поставщик", Weight = 150 },
new() { Header = "Аптека", Stretch = true },
new() { Header = "Позиций", Numeric = true, Weight = 95 },
new() { Header = "Сумма", Numeric = true, Weight = 125 },
new() { Header = "Статус", Weight = 100 },
};
var rows = lines.GroupBy(l => l.OrderId)
.OrderByDescending(g => g.First().OrderDate ?? DateTime.MinValue)
.Select(g =>
{
var f = g.First();
var sup = string.Join(", ", g.Select(x => x.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct());
return new[] { D(f.OrderDate), f.GlobalSign ?? "", sup, f.Location ?? "",
g.Count().ToString(), N2(g.Sum(x => x.Sum)), f.Status ?? "" };
}).ToList();
return (cols, rows);
}
}
}
private static void OpenFile(string path)
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true });
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
Process.Start("open", $"\"{path}\"");
else
Process.Start("xdg-open", $"\"{path}\"");
}
catch { /* открыть не удалось — путь показан в статусе */ }
}
}

View File

@ -0,0 +1,48 @@
using System;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>Корневой VM: переключает экран входа и основной шелл.</summary>
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);
}
}

View File

@ -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;
/// <summary>
/// Отправить: поиск по прайсу → корзина (общая с Прайс-листом) → создание заказа.
/// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика.
/// </summary>
public partial class SendOrderViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
private readonly CartService _cart = CartService.Instance;
public ObservableCollection<PriceItem> Results { get; } = new();
public ObservableCollection<CartItem> Cart => _cart.Items;
public ObservableCollection<BuyerLocationDto> 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; }
}
}

View File

@ -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<BuyerLocationDto> 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 { /* локации не критичны для настроек */ }
}
}

View File

@ -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<ViewModelBase> Factory { get; }
public NavItem(string title, string icon, Func<ViewModelBase> factory)
{
Title = title; Icon = icon; Factory = factory;
}
}
/// <summary>Открытая вкладка-документ.</summary>
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<NavItem> NavItems { get; }
public ObservableCollection<DocumentTab> 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<NavItem>
{
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();
}

View File

@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Elfisa.Avalonia.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View File

@ -0,0 +1,56 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:core="using:Elfisa.Core"
x:Class="Elfisa.Avalonia.Views.DirectoryView"
x:DataType="vm:DirectoryViewModel">
<ScrollViewer>
<StackPanel Margin="24" Spacing="14">
<StackPanel Spacing="2">
<TextBlock Text="Справочник" Classes="h1"/>
<TextBlock Text="Профиль и грузополучатели" Classes="muted"/>
</StackPanel>
<!-- Профиль -->
<Border Classes="card" MaxWidth="720" HorizontalAlignment="Left">
<StackPanel Spacing="10">
<TextBlock Text="Учётная запись" Classes="h2"/>
<Grid ColumnDefinitions="160,*" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Логин" Classes="muted"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding UserName}" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Роль" Classes="muted" Margin="0,6,0,0"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding RoleLabel}" Foreground="{DynamicResource Text}" Margin="0,6,0,0"/>
</Grid>
</StackPanel>
</Border>
<!-- Грузополучатели -->
<Border Classes="card" MaxWidth="720" HorizontalAlignment="Left">
<StackPanel Spacing="10">
<TextBlock Text="Грузополучатели (адреса)" Classes="h2"/>
<ItemsControl ItemsSource="{Binding Locations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="core:BuyerLocationDto">
<Border Padding="12" Margin="0,0,0,8" CornerRadius="9"
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
<StackPanel Spacing="3">
<TextBlock Text="{Binding Address}" Foreground="{DynamicResource Text}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{Binding RegionName}" Classes="muted"/>
<Border Background="{DynamicResource AccentSoft}" CornerRadius="5" Padding="6,1"
IsVisible="{Binding IsDefault}">
<TextBlock Text="по умолчанию" FontSize="11" Foreground="{DynamicResource AccentPressed}"/>
</Border>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding Status}" Classes="muted"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

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

View File

@ -0,0 +1,39 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters"
x:Class="Elfisa.Avalonia.Views.InvoicesView"
x:DataType="vm:InvoicesViewModel">
<DockPanel Margin="24">
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Накладные" Classes="h1"/>
<TextBlock Text="Реестр приходных документов" Classes="muted"/>
</StackPanel>
<TextBlock Text="{Binding Totals}" HorizontalAlignment="Right" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
</Grid>
<Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6"
Background="{DynamicResource AccentSoft}"
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" FontSize="12.5"/>
</Border>
<Border Classes="card" Padding="0" ClipToBounds="True">
<DataGrid ItemsSource="{Binding Rows}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Дата" Width="112" Binding="{Binding Date}"/>
<DataGridTextColumn Header="Номер" Width="150" Binding="{Binding Number}"/>
<DataGridTextColumn Header="Поставщик" Width="160" Binding="{Binding Supplier}"/>
<DataGridTextColumn Header="Аптека" Width="*" Binding="{Binding Location}"/>
<DataGridTextColumn Header="Позиций" Width="90" Binding="{Binding ItemsCount}" CellStyleClasses="num"/>
<DataGridTextColumn Header="Сумма" Width="132" Binding="{Binding Sum, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
</DataGrid.Columns>
</DataGrid>
</Border>
</DockPanel>
</UserControl>

View File

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

View File

@ -0,0 +1,61 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters"
xmlns:core="using:Elfisa.Core"
x:Class="Elfisa.Avalonia.Views.LoadPriceView"
x:DataType="vm:LoadPriceViewModel">
<Grid Margin="24" RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="Загрузить" Classes="h1"/>
<TextBlock Text="Скачать актуальный прайс с сервера" Classes="muted"/>
</StackPanel>
<Border Grid.Row="1" Classes="card" MaxWidth="560" Padding="30"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="18" HorizontalAlignment="Center">
<Border Width="60" Height="60" CornerRadius="30" Background="{DynamicResource AccentSoft}">
<TextBlock Text="📥" FontSize="26" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding LastLoaded}" Classes="muted" HorizontalAlignment="Center" TextAlignment="Center"/>
<!-- Подключённые прайсы — что именно скачиваем -->
<Border Background="{DynamicResource RowAlt}" CornerRadius="10" Padding="16,12" Width="380"
BorderThickness="1" BorderBrush="{DynamicResource Border}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding PriceListsInfo}" FontWeight="SemiBold" Foreground="{DynamicResource Text}"/>
<ItemsControl ItemsSource="{Binding PriceLists}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="core:BuyerPriceListDto">
<Grid ColumnDefinitions="Auto,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="• " Foreground="{DynamicResource Accent}" FontWeight="Bold"/>
<StackPanel Grid.Column="1" Spacing="1">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SupplierName}" Classes="muted" FontSize="12"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Button Classes="primary" Content="Загрузить прайс" HorizontalAlignment="Center"
Padding="28,12" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
<StackPanel Spacing="6" IsVisible="{Binding Busy}">
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="8" Width="360"/>
<TextBlock Text="{Binding ProgressText}" Classes="muted" HorizontalAlignment="Center"/>
</StackPanel>
<Border Background="{DynamicResource AccentSoft}" CornerRadius="8" Padding="14,10"
IsVisible="{Binding Result, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Result}" Foreground="{DynamicResource AccentPressed}"
TextWrapping="Wrap" TextAlignment="Center"/>
</Border>
</StackPanel>
</Border>
</Grid>
</UserControl>

Some files were not shown because too many files have changed in this diff Show More