Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98a024ba10 | |||
|
|
f98978a5d2 | ||
|
|
028c588d2a | ||
|
|
3fc825e54b | ||
|
|
0536d480cb | ||
|
|
cd2fb57bca | ||
|
|
2420a71ace | ||
|
|
c677345ba6 | ||
|
|
43468ac693 | ||
|
|
b13a867f8d | ||
|
|
77dfb08ef4 | ||
|
|
132ce1531f | ||
|
|
f6a1012e9b | ||
|
|
a4880d7c50 | ||
|
|
4b43907353 | ||
|
|
4d398aca84 | ||
|
|
f7fb7ed1b0 | ||
|
|
557f8667ad | ||
|
|
b10eba0984 | ||
|
|
63abfd4e31 | ||
|
|
d2a1a86794 | ||
| 2659de14b9 | |||
|
|
166a431bda | ||
|
|
cdb2d353a5 | ||
|
|
0253bdf022 | ||
|
|
621f6a6cf3 | ||
|
|
03c71b8e78 | ||
|
|
2230d4c73f | ||
|
|
28f0886b32 | ||
|
|
d80953baad | ||
|
|
3039c324be | ||
|
|
7497ee2d4f | ||
|
|
b69e1b4498 | ||
|
|
09535e9b02 | ||
|
|
9dfdb913cb | ||
|
|
d68155b8ef | ||
|
|
3d253a2f54 |
10
.gitignore
vendored
@ -20,3 +20,13 @@ Desktop.ini
|
||||
efClient.db
|
||||
*.db-journal
|
||||
**/user.config
|
||||
|
||||
packages/
|
||||
installer/output/
|
||||
dist/
|
||||
|
||||
|
||||
# Кросс-платформенные публикации и артефакты сборки
|
||||
publish/
|
||||
build/
|
||||
installer/output/
|
||||
|
||||
6
Elfisa.Cross.slnx
Normal 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>
|
||||
@ -29,6 +29,9 @@
|
||||
<setting name="IntDefaultPercentMarkup" serializeAs="String">
|
||||
<value>0</value>
|
||||
</setting>
|
||||
<setting name="ClientMarkupPercent" serializeAs="String">
|
||||
<value>0</value>
|
||||
</setting>
|
||||
<setting name="stringLogin" serializeAs="String">
|
||||
<value />
|
||||
</setting>
|
||||
@ -38,6 +41,21 @@
|
||||
<setting name="stringToken" serializeAs="String">
|
||||
<value />
|
||||
</setting>
|
||||
<setting name="InvoiceExportPath" serializeAs="String">
|
||||
<value />
|
||||
</setting>
|
||||
<setting name="UpdateCheckBaseUrl" serializeAs="String">
|
||||
<value>https://git.24pharmdata.ru</value>
|
||||
</setting>
|
||||
<setting name="UpdateRepo" serializeAs="String">
|
||||
<value>pharmdata/elfisa-pharmacy</value>
|
||||
</setting>
|
||||
<setting name="UpdateApiToken" serializeAs="String">
|
||||
<value />
|
||||
</setting>
|
||||
<setting name="UpgradeSettings" serializeAs="String">
|
||||
<value>True</value>
|
||||
</setting>
|
||||
</Электронная_Фармация.Properties.Settings>
|
||||
</userSettings>
|
||||
<runtime>
|
||||
|
||||
24
docs/gitea-updates.md
Normal 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.
|
||||
|
||||
Старый клиент с меньшей версией увидит модалку «Доступно новое обновление» и не пустит в работу.
|
||||
108
installer/ElfisaPharmacy.iss
Normal 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
@ -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).
|
||||
BIN
installer/assets/elfisa-1024.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
installer/assets/elfisa-256.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
installer/assets/elfisa-512.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
installer/assets/elfisa-logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
installer/assets/elfisa.ico
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
installer/assets/wizard-side.bmp
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
installer/assets/wizard-small.bmp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
18
installer/license.txt
Normal file
@ -0,0 +1,18 @@
|
||||
ЛИЦЕНЗИОННОЕ СОГЛАШЕНИЕ
|
||||
Электронная Фармация (ЭльФиСА)
|
||||
|
||||
1. Программа предоставляется для использования в рамках договора
|
||||
с поставщиком услуг PharmData / 24pharmdata.
|
||||
|
||||
2. Запрещается передавать третьим лицам учётные данные доступа,
|
||||
а также модифицировать клиент способом, нарушающим безопасность
|
||||
обмена данными.
|
||||
|
||||
3. Пользователь несёт ответственность за корректность оформляемых
|
||||
заказов и за сохранность локальных данных на рабочем месте.
|
||||
|
||||
4. Устанавливая программу, вы подтверждаете согласие с условиями
|
||||
использования сервиса и политикой обработки данных, действующими
|
||||
у оператора сервиса.
|
||||
|
||||
При возникновении вопросов обратитесь в службу поддержки.
|
||||
20
installer/welcome.txt
Normal file
@ -0,0 +1,20 @@
|
||||
ЭльФиСА — Электронная Фармация
|
||||
================================
|
||||
|
||||
Добро пожаловать в установку настольного клиента «Электронная Фармация».
|
||||
|
||||
Программа предназначена для работы аптек с прайс-листами поставщиков,
|
||||
заказами, накладными и обменом данными с сервером PharmData.
|
||||
|
||||
Скачать установщик всегда можно по адресу:
|
||||
https://cdn.24pharmdata.ru
|
||||
|
||||
Установщик:
|
||||
• скопирует программу в выбранную папку;
|
||||
• предложит ярлык в меню «Пуск»;
|
||||
• по желанию создаст ярлык на рабочем столе.
|
||||
|
||||
Рекомендуется закрыть запущенную «Электронную Фармацию» перед установкой.
|
||||
После установки при первом запуске выполните вход в систему.
|
||||
|
||||
© PharmData / ЭльФиСА
|
||||
59
scripts/build-installer.ps1
Normal 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"
|
||||
@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<configSections>
|
||||
<!-- For more information on Entity Framework configuration, visit http://go.microsoft.com/fwlink/?LinkID=237468 -->
|
||||
@ -42,6 +42,18 @@
|
||||
<setting name="InvoiceExportPath" serializeAs="String">
|
||||
<value />
|
||||
</setting>
|
||||
<setting name="UpdateCheckBaseUrl" serializeAs="String">
|
||||
<value>https://git.24pharmdata.ru</value>
|
||||
</setting>
|
||||
<setting name="UpdateRepo" serializeAs="String">
|
||||
<value>pharmdata/elfisa-pharmacy</value>
|
||||
</setting>
|
||||
<setting name="UpdateApiToken" serializeAs="String">
|
||||
<value />
|
||||
</setting>
|
||||
<setting name="UpgradeSettings" serializeAs="String">
|
||||
<value>True</value>
|
||||
</setting>
|
||||
</Электронная_Фармация.Properties.Settings>
|
||||
</userSettings>
|
||||
</configuration>
|
||||
@ -374,6 +374,108 @@ namespace Электронная_Фармация.Classes
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт покупателя: позиции его заказов за период. Сервер скоупит по JWT покупателя.
|
||||
/// </summary>
|
||||
public async Task<List<BuyerReportLineDto>> GetBuyerReportAsync(DateTime from, DateTime to)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var path = $"/api/buyer/report?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
|
||||
var responseBody = await SendAuthorizedGetAsync(path, "формирование отчёта");
|
||||
try
|
||||
{
|
||||
var response = JsonSerializer.Deserialize<BuyerReportResponse>(responseBody, JsonOptions);
|
||||
return response?.Lines ?? new List<BuyerReportLineDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppDebugLog.Error("ApiClient", "Не удалось разобрать отчёт", ex);
|
||||
throw new HttpRequestException($"Некорректный ответ сервера при формировании отчёта: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
@ -734,6 +836,87 @@ namespace Электронная_Фармация.Classes
|
||||
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")]
|
||||
|
||||
@ -10,14 +10,72 @@ namespace Электронная_Фармация.Classes
|
||||
public static class AppConfig
|
||||
{
|
||||
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 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>
|
||||
/// Replaces known dead API URLs in saved user settings.
|
||||
/// </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()
|
||||
{
|
||||
var current = Settings.Default.ApiBaseUrl;
|
||||
@ -57,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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
@ -74,24 +147,77 @@ namespace Электронная_Фармация.Classes
|
||||
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
|
||||
{
|
||||
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 root = Path.Combine(baseDir, "efClient.db");
|
||||
var data = Path.Combine(baseDir, "Data", "efClient.db");
|
||||
if (File.Exists(root))
|
||||
var candidates = new[]
|
||||
{
|
||||
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))
|
||||
{
|
||||
return data;
|
||||
File.Copy(source, target, overwrite: false);
|
||||
AppDebugLog.Info("AppConfig", $"SQLite DB migrated: {source} -> {target}");
|
||||
return;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppDebugLog.Info("AppConfig", $"SQLite DB migrate skipped: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,10 +252,73 @@ namespace Электронная_Фармация.Classes
|
||||
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 часто лежит в подпапках x86/x64.
|
||||
// Важно: разрядность DLL должна совпадать с процессом (Prefer32Bit => x86).
|
||||
if (_sqliteInteropPrepared)
|
||||
{
|
||||
return;
|
||||
@ -144,13 +333,6 @@ namespace Электронная_Фармация.Classes
|
||||
|
||||
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var targetDll = Path.Combine(baseDir, "SQLite.Interop.dll");
|
||||
if (File.Exists(targetDll))
|
||||
{
|
||||
_sqliteInteropPrepared = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// AnyCPU в .NET Framework обычно выбирает разрядность процесса автоматически.
|
||||
var archFolder = Environment.Is64BitProcess ? "x64" : "x86";
|
||||
var sourceDll = Path.Combine(baseDir, archFolder, "SQLite.Interop.dll");
|
||||
var sourcePdb = Path.Combine(baseDir, archFolder, "SQLite.Interop.pdb");
|
||||
@ -159,7 +341,7 @@ namespace Электронная_Фармация.Classes
|
||||
{
|
||||
if (File.Exists(sourceDll))
|
||||
{
|
||||
Directory.CreateDirectory(baseDir);
|
||||
// Всегда подкладываем DLL нужной разрядности (даже если в корне уже лежит "чужая").
|
||||
File.Copy(sourceDll, targetDll, overwrite: true);
|
||||
|
||||
var targetPdb = Path.Combine(baseDir, "SQLite.Interop.pdb");
|
||||
|
||||
@ -8,7 +8,7 @@ namespace Электронная_Фармация.Classes
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public static class AppDebugLog
|
||||
{
|
||||
@ -33,8 +33,7 @@ namespace Электронная_Фармация.Classes
|
||||
}
|
||||
}
|
||||
|
||||
public static string LogDirectory =>
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||||
public static string LogDirectory => AppConfig.LogsDirectory;
|
||||
|
||||
public static string CurrentLogFilePath =>
|
||||
Path.Combine(LogDirectory, $"api-debug-{DateTime.Now:yyyy-MM-dd}.log");
|
||||
|
||||
287
src/ElectronicPharmacy/Classes/AutoUpdater.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -215,6 +215,238 @@ namespace Электронная_Фармация.Classes
|
||||
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 обновляется
|
||||
|
||||
@ -143,6 +143,7 @@ namespace Электронная_Фармация.Classes
|
||||
string query = @"
|
||||
SELECT
|
||||
Orders.Id_Order,
|
||||
Orders.OrderNumber,
|
||||
Orders.ConsigneeName,
|
||||
OrderItems.supplier_price_id,
|
||||
OrderItems.DrugName,
|
||||
@ -172,6 +173,9 @@ WHERE Orders.OrderState = 'НОВЫЙ'
|
||||
while (reader.Read())
|
||||
{
|
||||
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();
|
||||
@ -196,6 +200,9 @@ WHERE Orders.OrderState = 'НОВЫЙ'
|
||||
order = new BuyerOrderRequest
|
||||
{
|
||||
LocalOrderId = orderId,
|
||||
GlobalSign = OrderNumberHelper.IsValidEx(orderNumber)
|
||||
? OrderNumberHelper.Normalize(orderNumber)
|
||||
: null,
|
||||
LocationId = locationId,
|
||||
Comment = reader["Comment"] == DBNull.Value ? null : reader["Comment"].ToString()
|
||||
};
|
||||
@ -324,10 +331,25 @@ WHERE Orders.OrderState = 'НОВЫЙ'
|
||||
throw new InvalidOperationException("Сервер не вернул order_id созданного заказа.");
|
||||
}
|
||||
|
||||
MarkOrderSent(order.LocalOrderId, createdOrder.OrderID, createdOrder.Status);
|
||||
AppDebugLog.Info("DataSender", $"Заказ {order.LocalOrderId} отправлен");
|
||||
// Старые серверы создавали 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;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
AppDebugLog.Error("DataSender", $"Ошибка отправки заказа {order.LocalOrderId}", ex);
|
||||
ToastNotification.ShowError(ex.Message);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
@ -338,7 +360,67 @@ WHERE Orders.OrderState = 'НОВЫЙ'
|
||||
}
|
||||
}
|
||||
|
||||
private static void MarkOrderSent(string localOrderId, string remoteOrderId, string remoteStatus)
|
||||
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))
|
||||
{
|
||||
@ -351,12 +433,18 @@ WHERE Orders.OrderState = 'НОВЫЙ'
|
||||
using (var cmd = new SQLiteCommand(
|
||||
@"UPDATE [Orders]
|
||||
SET [OrderState] = @state,
|
||||
[RemoteOrderId] = @remoteOrderId
|
||||
[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))
|
||||
{
|
||||
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.ExecuteNonQuery();
|
||||
}
|
||||
@ -393,6 +481,9 @@ WHERE Orders.OrderState = 'НОВЫЙ'
|
||||
[JsonPropertyName("location_id")]
|
||||
public string LocationId { get; set; }
|
||||
|
||||
[JsonPropertyName("global_sign")]
|
||||
public string GlobalSign { get; set; }
|
||||
|
||||
[JsonPropertyName("comment")]
|
||||
public string Comment { get; set; }
|
||||
|
||||
@ -422,5 +513,8 @@ WHERE Orders.OrderState = 'НОВЫЙ'
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; }
|
||||
|
||||
[JsonPropertyName("global_sign")]
|
||||
public string GlobalSign { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
157
src/ElectronicPharmacy/Classes/OrderNumberHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,15 +125,36 @@ namespace Электронная_Фармация.Classes
|
||||
}
|
||||
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
|
||||
}
|
||||
else if (control.Tag as string == "chrome-child")
|
||||
else if (tag == "chrome-child")
|
||||
{
|
||||
control.BackColor = theme.BackgroundPrimary;
|
||||
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
|
||||
{
|
||||
control.BackColor = theme.BackgroundSecondary;
|
||||
|
||||
302
src/ElectronicPharmacy/Classes/UpdateChecker.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,9 +17,11 @@
|
||||
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
<ApplicationIcon>Resources\elfisa.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
@ -30,6 +32,7 @@
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
@ -96,6 +99,8 @@
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Deployment" />
|
||||
<Reference Include="System.Drawing" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Windows.Forms" />
|
||||
<Reference Include="System.Xml" />
|
||||
@ -104,9 +109,11 @@
|
||||
<Compile Include="Classes\ApiClient.cs" />
|
||||
<Compile Include="Classes\AppConfig.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\OrderNumberHelper.cs" />
|
||||
<Compile Include="Classes\ErrorResponse.cs" />
|
||||
<Compile Include="Classes\InvoiceExportService.cs" />
|
||||
<Compile Include="Classes\InvoiceRequestService.cs" />
|
||||
@ -127,6 +134,7 @@
|
||||
<Compile Include="Classes\SuppliersItem.cs" />
|
||||
<Compile Include="Classes\SupplierResponse.cs" />
|
||||
<Compile Include="Classes\ToastNotification.cs" />
|
||||
<Compile Include="Classes\UpdateChecker.cs" />
|
||||
<Compile Include="Form1.cs">
|
||||
<SubType>Form</SubType>
|
||||
</Compile>
|
||||
@ -199,6 +207,12 @@
|
||||
<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">
|
||||
<SubType>Form</SubType>
|
||||
</Compile>
|
||||
@ -234,6 +248,9 @@
|
||||
<Compile Include="UserControls\UCRefusals.cs">
|
||||
<SubType>UserControl</SubType>
|
||||
</Compile>
|
||||
<Compile Include="UserControls\UCReports.cs">
|
||||
<SubType>UserControl</SubType>
|
||||
</Compile>
|
||||
<Compile Include="UserControls\UCPriceList.cs">
|
||||
<SubType>UserControl</SubType>
|
||||
</Compile>
|
||||
@ -341,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\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')" />
|
||||
<ItemGroup>
|
||||
<None Include="Resources\elfisa.ico" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
7
src/ElectronicPharmacy/Form1.Designer.cs
generated
@ -1,4 +1,4 @@
|
||||
namespace Электронная_Фармация
|
||||
namespace Электронная_Фармация
|
||||
{
|
||||
partial class ElectroPharmacy
|
||||
{
|
||||
@ -381,8 +381,11 @@
|
||||
this.ClientSize = new System.Drawing.Size(1089, 554);
|
||||
this.Controls.Add(this.bindControl);
|
||||
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.MinimumSize = new System.Drawing.Size(900, 620);
|
||||
this.Name = "ElectroPharmacy";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
|
||||
this.Text = "Электронная Фармация";
|
||||
|
||||
@ -23,6 +23,7 @@ namespace Электронная_Фармация
|
||||
private ToastService _toastService;
|
||||
private LoadingOverlay _loadingOverlay;
|
||||
private ModernAppHeader _appHeader;
|
||||
private ModernTitleBar _titleBar;
|
||||
private ModernDocumentTabStrip _documentTabStrip;
|
||||
private Panel _documentHost;
|
||||
private Panel _statusFooter;
|
||||
@ -38,6 +39,9 @@ namespace Электронная_Фармация
|
||||
Font = new Font("Segoe UI", 12f);
|
||||
AutoScaleMode = AutoScaleMode.Dpi;
|
||||
KeyPreview = true;
|
||||
FormBorderStyle = FormBorderStyle.None;
|
||||
MaximizeBox = true;
|
||||
MinimizeBox = true;
|
||||
MinimumSize = new Size(900, 620);
|
||||
|
||||
bindControl.Visible = false;
|
||||
@ -47,17 +51,22 @@ namespace Электронная_Фармация
|
||||
AppServices.Initialize(_toastService);
|
||||
_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.SetItems(new[]
|
||||
{
|
||||
new SidebarItem { Key = "price", Text = "ПРАЙС-ЛИСТ", IconGlyph = "\uE8FD" },
|
||||
new SidebarItem { Key = "orders", Text = "ЗАКАЗЫ", IconGlyph = "\uE719" },
|
||||
new SidebarItem { Key = "invoices", Text = "НАКЛАДНЫЕ", IconGlyph = "\uE8A5" },
|
||||
new SidebarItem { Key = "upload", Text = "ЗАГРУЗИТЬ", IconGlyph = "\uE896" },
|
||||
new SidebarItem { Key = "send", Text = "ОТПРАВИТЬ", IconGlyph = "\uE725" },
|
||||
new SidebarItem { Key = "refusals", Text = "ОТКАЗЫ", IconGlyph = "\uE711" },
|
||||
new SidebarItem { Key = "directory", Text = "СПРАВОЧНИК", IconGlyph = "\uE8FD" },
|
||||
new SidebarItem { Key = "auth", Text = "АВТОРИЗАЦИЯ", IconGlyph = "\uE192" }
|
||||
new SidebarItem { Key = "price", Text = "Прайс-лист", IconGlyph = "\uE14C" },
|
||||
new SidebarItem { Key = "orders", Text = "Заказы", IconGlyph = "\uE7BF" },
|
||||
new SidebarItem { Key = "invoices", Text = "Накладные", IconGlyph = "\uE8A5" },
|
||||
new SidebarItem { Key = "reports", Text = "Отчёты", IconGlyph = "" },
|
||||
new SidebarItem { Key = "upload", Text = "Загрузить", IconGlyph = "\uE896" },
|
||||
new SidebarItem { Key = "send", Text = "Отправить", IconGlyph = "\uE725" },
|
||||
new SidebarItem { Key = "refusals", Text = "Отказы", IconGlyph = "\uE7BA" },
|
||||
new SidebarItem { Key = "directory", Text = "Справочник", IconGlyph = "\uE8F1" },
|
||||
new SidebarItem { Key = "auth", Text = "Вход", IconGlyph = "\uE192" }
|
||||
});
|
||||
|
||||
_documentTabStrip = new ModernDocumentTabStrip { Name = "documentTabStrip", Dock = DockStyle.Top };
|
||||
@ -67,16 +76,22 @@ namespace Электронная_Фармация
|
||||
{
|
||||
Name = "statusFooter",
|
||||
Dock = DockStyle.Bottom,
|
||||
Height = 28,
|
||||
Height = 32,
|
||||
Tag = "status-footer",
|
||||
Padding = new Padding(10, 0, 4, 0)
|
||||
Padding = new Padding(12, 0, 8, 0)
|
||||
};
|
||||
_statusFooter.Paint += (_, e) =>
|
||||
{
|
||||
var border = ThemeManager.Colors.Border;
|
||||
using (var pen = new Pen(border))
|
||||
var theme = ThemeManager.Colors;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -118,6 +133,7 @@ namespace Электронная_Фармация
|
||||
Controls.Add(_statusFooter);
|
||||
Controls.Add(_documentTabStrip);
|
||||
Controls.Add(_appHeader);
|
||||
Controls.Add(_titleBar);
|
||||
|
||||
_directoryMenu = new ContextMenuStrip();
|
||||
var contractorsItem = new ToolStripMenuItem("Поставщики");
|
||||
@ -167,12 +183,24 @@ namespace Электронная_Фармация
|
||||
{
|
||||
UiThemeHelper.ApplyToControlTree(this);
|
||||
UpdateThemeToggleState();
|
||||
_titleBar?.ApplyTheme(ThemeManager.Colors);
|
||||
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()
|
||||
{
|
||||
if (_documentTabStrip != null)
|
||||
@ -193,14 +221,49 @@ namespace Электронная_Фармация
|
||||
{
|
||||
ThemeManager.CurrentVariant = Settings.Default.UseDarkTheme
|
||||
? ThemeVariant.DarkProductivity
|
||||
: ThemeVariant.PharmaTrust;
|
||||
: ThemeVariant.ClinicalPro;
|
||||
_themeService.Initialize(this);
|
||||
UpdateThemeToggleState();
|
||||
UiThemeHelper.ApplyToControlTree(this);
|
||||
_titleBar?.ApplyTheme(ThemeManager.Colors);
|
||||
WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
|
||||
try
|
||||
{
|
||||
Icon = CreateAppIcon();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep default
|
||||
}
|
||||
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()
|
||||
{
|
||||
if (_lblCurrentUser == null)
|
||||
@ -259,6 +322,9 @@ namespace Электронная_Фармация
|
||||
case "invoices":
|
||||
OpenInvoicesTab();
|
||||
break;
|
||||
case "reports":
|
||||
OpenReportsTab();
|
||||
break;
|
||||
case "upload":
|
||||
tsBtnDownloadData_Click(this, EventArgs.Empty);
|
||||
_appHeader.SetActive("upload");
|
||||
@ -437,6 +503,12 @@ namespace Электронная_Фармация
|
||||
OpenDocumentTab("Отказы", "refusals", refusals, allowDuplicate: false);
|
||||
}
|
||||
|
||||
private void OpenReportsTab()
|
||||
{
|
||||
var reports = new UCReports();
|
||||
OpenDocumentTab("Отчёты", "reports", reports, allowDuplicate: false);
|
||||
}
|
||||
|
||||
partial void ShowShellLoading(string message)
|
||||
{
|
||||
_loadingOverlay?.Show(message);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
@ -67,6 +67,29 @@ namespace Электронная_Фармация
|
||||
{
|
||||
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();
|
||||
UiThemeHelper.ApplyToControlTree(fCheckDatabaseIntegrary);
|
||||
fCheckDatabaseIntegrary.ShowDialog();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
@ -509,76 +509,17 @@ FROM [TempOrderItems];";
|
||||
}
|
||||
}
|
||||
|
||||
// Перед seed-вставкой грузополучателей нужно гарантировать наличие LocationId,
|
||||
// иначе INSERT в Consignees (LocationId) упадёт на старых версиях БД.
|
||||
// LocationId нужен для связи с BuyerLocation на сервере.
|
||||
TryAddColumn(conForCheckTables, "Consignees", "LocationId", "nvarchar(64)");
|
||||
|
||||
// Seed-тестовые грузополучатели (аптеки) для удобства ручного тестирования.
|
||||
// Вставляем только если таблица пустая.
|
||||
using (var countConsigneesCmd = new SQLiteCommand(
|
||||
@"SELECT COUNT(*) FROM Consignees;",
|
||||
// Удаляем старые тестовые грузополучатели — точки создаются в веб-кабинете.
|
||||
using (var delTest = new SQLiteCommand(
|
||||
@"DELETE FROM Consignees
|
||||
WHERE ConsigneesName LIKE 'Тестовая аптека%'
|
||||
OR ConsigneesAddress LIKE 'Тестовый адрес%';",
|
||||
conForCheckTables))
|
||||
{
|
||||
var countObj = countConsigneesCmd.ExecuteScalar();
|
||||
var count = 0;
|
||||
try { count = Convert.ToInt32(countObj); } catch { count = 0; }
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
var testLocationIds = new[]
|
||||
{
|
||||
"87368f6d-488b-41a9-b170-edc629522844",
|
||||
"c4cd845f-820d-45ac-a473-3df1a0b96feb",
|
||||
"d6187013-00f5-4c1b-b258-c670cf71d115"
|
||||
};
|
||||
|
||||
// Тестовые имена/адреса — можно менять под вашу тестовую среду.
|
||||
var testNames = new[]
|
||||
{
|
||||
"Тестовая аптека 1",
|
||||
"Тестовая аптека 2",
|
||||
"Тестовая аптека 3"
|
||||
};
|
||||
|
||||
var testAddresses = new[]
|
||||
{
|
||||
"Тестовый адрес 1",
|
||||
"Тестовый адрес 2",
|
||||
"Тестовый адрес 3"
|
||||
};
|
||||
|
||||
using (var tx = conForCheckTables.BeginTransaction())
|
||||
{
|
||||
int nextCode;
|
||||
using (var nextCodeCmd = new SQLiteCommand(
|
||||
@"SELECT ifnull(MAX(codeConsignees), 0) + 1 FROM Consignees;",
|
||||
conForCheckTables,
|
||||
tx))
|
||||
{
|
||||
nextCode = Convert.ToInt32(nextCodeCmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using (var insertCmd = new SQLiteCommand(
|
||||
@"INSERT INTO Consignees
|
||||
(codeConsignees, ConsigneesName, ConsigneesAddress, ConsigneesUseAsDefault, LocationId)
|
||||
VALUES (@code, @name, @address, @isDefault, @loc);",
|
||||
conForCheckTables,
|
||||
tx))
|
||||
{
|
||||
insertCmd.Parameters.AddWithValue("@code", nextCode + i);
|
||||
insertCmd.Parameters.AddWithValue("@name", testNames[i]);
|
||||
insertCmd.Parameters.AddWithValue("@address", testAddresses[i]);
|
||||
insertCmd.Parameters.AddWithValue("@isDefault", i == 0 ? 1 : 0);
|
||||
insertCmd.Parameters.AddWithValue("@loc", testLocationIds[i]);
|
||||
insertCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
}
|
||||
delTest.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
TryAddColumn(conForCheckTables, "Orders", "ConsigneeName", "nvarchar(128)");
|
||||
@ -593,13 +534,14 @@ FROM [TempOrderItems];";
|
||||
TryAddColumn(conForCheckTables, "PriceList", "TradeName", "nvarchar(256)");
|
||||
TryAddColumn(conForCheckTables, "PriceList", "Dosage", "nvarchar(128)");
|
||||
TryAddColumn(conForCheckTables, "PriceList", "MarkupPercent", "real");
|
||||
TryAddColumn(conForCheckTables, "Consignees", "ClientMarkupPercent", "real");
|
||||
ConsigneeHelper.MigrateGlobalLocationIdIfNeeded(conForCheckTables);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||||
var logDir = AppConfig.LogsDirectory;
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logPath = Path.Combine(
|
||||
logDir,
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Data.SQLite;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using Электронная_Фармация.Classes;
|
||||
using Электронная_Фармация.Properties;
|
||||
|
||||
namespace Электронная_Фармация.Forms
|
||||
{
|
||||
@ -15,10 +17,12 @@ namespace Электронная_Фармация.Forms
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void FConsignees_Load(object sender, EventArgs e)
|
||||
private async void FConsignees_Load(object sender, EventArgs e)
|
||||
{
|
||||
UiThemeHelper.ApplyToControlTree(this);
|
||||
btnAdd.Text = "С сервера";
|
||||
showMeContentFromConsigneesTable();
|
||||
await SyncFromServerAsync(silent: true);
|
||||
}
|
||||
|
||||
void showMeContentFromConsigneesTable()
|
||||
@ -49,7 +53,8 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
|
||||
|
||||
foreach (DataGridViewColumn column in dgvConsignees.Columns)
|
||||
{
|
||||
column.ReadOnly = column.Name != "Location ID" && column.Name != "Адрес";
|
||||
// Редактируем только адрес локально; Location ID и имя приходят с сервера.
|
||||
column.ReadOnly = column.Name != "Адрес";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -88,8 +93,6 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
|
||||
|
||||
dgvConsignees.EndEdit();
|
||||
|
||||
// Кто должен стать активной аптекой после сохранения.
|
||||
// По вашему ТЗ: "аптека выбранная в этом окне" => текущая выделенная строка.
|
||||
var selectedName = dgvConsignees.CurrentRow?.Cells["Наименование"]?.Value?.ToString()?.Trim()
|
||||
?? string.Empty;
|
||||
SelectedConsigneeNameAfterSave = selectedName;
|
||||
@ -108,14 +111,12 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
|
||||
|
||||
using (var cmd = new SQLiteCommand(
|
||||
@"UPDATE Consignees
|
||||
SET ConsigneesAddress = @address,
|
||||
LocationId = @locationId
|
||||
SET ConsigneesAddress = @address
|
||||
WHERE idConsignees = @id",
|
||||
connection,
|
||||
tx))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@address", row["Адрес"]?.ToString() ?? string.Empty);
|
||||
cmd.Parameters.AddWithValue("@locationId", row["Location ID"]?.ToString()?.Trim() ?? string.Empty);
|
||||
cmd.Parameters.AddWithValue("@id", Convert.ToInt64(row["Id"]));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
@ -137,107 +138,58 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
|
||||
Close();
|
||||
}
|
||||
|
||||
private void btnAdd_Click(object sender, EventArgs e)
|
||||
private async void btnAdd_Click(object sender, EventArgs e)
|
||||
{
|
||||
using (var dialog = new Form())
|
||||
{
|
||||
dialog.Text = "Новая аптека";
|
||||
dialog.FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
dialog.StartPosition = FormStartPosition.CenterParent;
|
||||
// Немного увеличиваем высоту/ширину на случай DPI/шрифтов.
|
||||
dialog.ClientSize = new System.Drawing.Size(480, 310);
|
||||
dialog.MaximizeBox = false;
|
||||
dialog.MinimizeBox = false;
|
||||
dialog.ShowInTaskbar = false;
|
||||
|
||||
var lblName = new Label { Text = "Наименование", Left = 16, Top = 16, AutoSize = true };
|
||||
var txtName = new TextBox { Left = 16, Top = 42, Width = 440, Height = 28 };
|
||||
var lblAddress = new Label { Text = "Адрес", Left = 16, Top = 82, AutoSize = true };
|
||||
var txtAddress = new TextBox { Left = 16, Top = 108, Width = 440, Height = 28 };
|
||||
var lblLocation = new Label { Text = "Location ID", Left = 16, Top = 148, AutoSize = true };
|
||||
var txtLocation = new TextBox { Left = 16, Top = 174, Width = 440, Height = 28 };
|
||||
|
||||
var btnOk = new Elfisa.UI.Controls.ModernButton
|
||||
{
|
||||
Text = "Добавить",
|
||||
DialogResult = DialogResult.OK
|
||||
};
|
||||
var btnCancel = new Elfisa.UI.Controls.ModernButton
|
||||
{
|
||||
Text = "Отмена",
|
||||
DialogResult = DialogResult.Cancel
|
||||
};
|
||||
|
||||
dialog.Controls.AddRange(new Control[]
|
||||
{
|
||||
lblName, txtName, lblAddress, txtAddress, lblLocation, txtLocation, btnOk, btnCancel
|
||||
});
|
||||
dialog.AcceptButton = btnOk;
|
||||
dialog.CancelButton = btnCancel;
|
||||
UiThemeHelper.ApplyToControlTree(dialog);
|
||||
|
||||
btnOk.Padding = new Padding(12, 4, 12, 4);
|
||||
btnCancel.Padding = new Padding(12, 4, 12, 4);
|
||||
Elfisa.UI.Controls.ModernButtonStyles.FitToText(btnOk, 36);
|
||||
Elfisa.UI.Controls.ModernButtonStyles.FitToText(btnCancel, 36);
|
||||
btnOk.Height = 36;
|
||||
btnCancel.Height = 36;
|
||||
|
||||
const int padding = 16;
|
||||
const int gap = 10;
|
||||
|
||||
// Минимально гарантируем ширину, чтобы обе кнопки поместились.
|
||||
var requiredWidth = btnOk.Width + btnCancel.Width + gap + padding * 2;
|
||||
if (dialog.ClientSize.Width < requiredWidth)
|
||||
{
|
||||
dialog.ClientSize = new System.Drawing.Size(requiredWidth, dialog.ClientSize.Height);
|
||||
await SyncFromServerAsync(silent: false);
|
||||
}
|
||||
|
||||
// Всегда зажимаем в границы формы после вычисления ширины ModernButton'ов.
|
||||
btnCancel.Left = Math.Max(padding, dialog.ClientSize.Width - btnCancel.Width - padding);
|
||||
btnCancel.Top = dialog.ClientSize.Height - btnCancel.Height - padding;
|
||||
|
||||
btnOk.Left = Math.Max(padding, btnCancel.Left - btnOk.Width - gap);
|
||||
btnOk.Top = btnCancel.Top;
|
||||
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK)
|
||||
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;
|
||||
}
|
||||
|
||||
var name = (txtName.Text ?? string.Empty).Trim();
|
||||
var address = (txtAddress.Text ?? string.Empty).Trim();
|
||||
var locationId = (txtLocation.Text ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
UiDialogs.ShowError("Укажите наименование аптеки.", "Грузополучатели", this);
|
||||
return;
|
||||
client.SetToken(token);
|
||||
}
|
||||
|
||||
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
|
||||
{
|
||||
connection.Open();
|
||||
int nextCode = 1;
|
||||
using (var codeCmd = new SQLiteCommand("SELECT ifnull(MAX(codeConsignees), 0) + 1 FROM Consignees", connection))
|
||||
{
|
||||
nextCode = Convert.ToInt32(codeCmd.ExecuteScalar());
|
||||
}
|
||||
|
||||
using (var insert = new SQLiteCommand(
|
||||
@"INSERT INTO Consignees (codeConsignees, ConsigneesName, ConsigneesAddress, ConsigneesUseAsDefault, LocationId)
|
||||
VALUES (@code, @name, @address, 0, @locationId)",
|
||||
connection))
|
||||
{
|
||||
insert.Parameters.AddWithValue("@code", nextCode);
|
||||
insert.Parameters.AddWithValue("@name", name);
|
||||
insert.Parameters.AddWithValue("@address", address);
|
||||
insert.Parameters.AddWithValue("@locationId", locationId);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
ToastNotification.ShowSuccess("Аптека добавлена");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -249,15 +201,15 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
|
||||
return;
|
||||
}
|
||||
|
||||
var name = dgvConsignees.CurrentRow.Cells["Наименование"]?.Value?.ToString();
|
||||
var name = dgvConsignees.CurrentRow.Cells["Наименование"]?.Value?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConsigneeHelper.SetActiveConsignee(name);
|
||||
ToastNotification.ShowSuccess($"Аптека по умолчанию: {name}");
|
||||
showMeContentFromConsigneesTable();
|
||||
ToastNotification.ShowSuccess($"Аптека по умолчанию: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,14 +45,14 @@
|
||||
this.dgvConsignees.BackgroundColor = System.Drawing.SystemColors.ButtonFace;
|
||||
this.dgvConsignees.BorderStyle = System.Windows.Forms.BorderStyle.None;
|
||||
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.Name = "dgvConsignees";
|
||||
this.dgvConsignees.ReadOnly = true;
|
||||
this.dgvConsignees.RowHeadersVisible = false;
|
||||
this.dgvConsignees.RowHeadersWidth = 51;
|
||||
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;
|
||||
//
|
||||
// label1
|
||||
@ -69,7 +69,7 @@
|
||||
//
|
||||
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.Location = new System.Drawing.Point(336, 361);
|
||||
this.btnConfirmConsignee.Location = new System.Drawing.Point(567, 361);
|
||||
this.btnConfirmConsignee.Name = "btnConfirmConsignee";
|
||||
this.btnConfirmConsignee.Size = new System.Drawing.Size(89, 43);
|
||||
this.btnConfirmConsignee.TabIndex = 2;
|
||||
@ -81,7 +81,7 @@
|
||||
//
|
||||
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.Location = new System.Drawing.Point(174, 361);
|
||||
this.btnCloseThis.Location = new System.Drawing.Point(420, 361);
|
||||
this.btnCloseThis.Name = "btnCloseThis";
|
||||
this.btnCloseThis.Size = new System.Drawing.Size(125, 43);
|
||||
this.btnCloseThis.TabIndex = 3;
|
||||
@ -93,7 +93,7 @@
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
|
||||
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.btnConfirmConsignee);
|
||||
this.Controls.Add(this.label1);
|
||||
|
||||
@ -28,7 +28,6 @@ namespace Электронная_Фармация.HelpForms
|
||||
|
||||
string qShowMeConsigneesList = @"
|
||||
select ConsigneesName as [Грузополучатель],
|
||||
ifnull(LocationId, '') as [Location ID],
|
||||
ConsigneesAddress as [Адрес]
|
||||
from Consignees
|
||||
order by ConsigneesUseAsDefault desc, ConsigneesName";
|
||||
@ -38,6 +37,16 @@ order by ConsigneesUseAsDefault desc, ConsigneesName";
|
||||
DataTable tableConsignees = new DataTable();
|
||||
daConsigneesList.Fill(tableConsignees);
|
||||
dgvConsignees.DataSource = tableConsignees;
|
||||
|
||||
if (dgvConsignees.Columns.Contains("Грузополучатель"))
|
||||
{
|
||||
dgvConsignees.Columns["Грузополучатель"].FillWeight = 45;
|
||||
}
|
||||
|
||||
if (dgvConsignees.Columns.Contains("Адрес"))
|
||||
{
|
||||
dgvConsignees.Columns["Адрес"].FillWeight = 55;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@ -54,28 +54,29 @@ namespace Электронная_Фармация.HelpForms
|
||||
SetOverallProgress(1, "Авторизация...");
|
||||
await EnsureAuthenticatedAsync(client);
|
||||
|
||||
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);
|
||||
int totalItems = allSuppliersSummary.Summary?.Count ?? 0;
|
||||
|
||||
AppendStatus("Загружаю скидки покупателя / прайса / региона...");
|
||||
var discountLookup = await DiscountResolver.LoadAsync(client, _login);
|
||||
if (!string.IsNullOrWhiteSpace(discountLookup.BuyerId))
|
||||
{
|
||||
AppendStatus($"buyer_id: {discountLookup.BuyerId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendStatus("buyer_id не найден — клиентская скидка будет 0, если не пришла иначе");
|
||||
}
|
||||
|
||||
AppendStatus($"Прайс-листов со скидками: {discountLookup.ByPriceListId.Count}");
|
||||
ApplyClientMarkupSettings(allSuppliersSummary);
|
||||
if (allSuppliersSummary.MarkupPercent.HasValue)
|
||||
{
|
||||
AppendStatus($"Скидка из summary: {allSuppliersSummary.MarkupPercent.Value:0.##}%");
|
||||
}
|
||||
// Наценки прайса/региона/покупателя считает сервер.
|
||||
// Десктоп сохраняет цену из API как есть, без локального пересчёта.
|
||||
AppendStatus("Цена берётся с сервера без локального пересчёта наценок");
|
||||
|
||||
AppendStatus($"Получено {totalItems} позиций");
|
||||
SetOverallProgress(DownloadPhaseEnd, $"Получено {totalItems} / {totalItems} позиций (100%)");
|
||||
@ -88,7 +89,7 @@ namespace Электронная_Фармация.HelpForms
|
||||
phasePercent,
|
||||
$"Обработка: {p.Current} / {p.Total} ({PercentOf(p.Current, p.Total)}%)");
|
||||
});
|
||||
var tablePrice = await Task.Run(() => BuildPriceTable(allSuppliersSummary, discountLookup, buildProgress));
|
||||
var tablePrice = await Task.Run(() => BuildPriceTable(allSuppliersSummary, buildProgress));
|
||||
|
||||
SetOverallProgress(BuildPhaseEnd, "Сохранение в локальную базу...");
|
||||
var saveProgress = new Progress<CountProgress>(p =>
|
||||
@ -366,19 +367,6 @@ namespace Электронная_Фармация.HelpForms
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyClientMarkupSettings(PriceSummaryResponse response)
|
||||
{
|
||||
if (response?.MarkupPercent == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var markup = response.MarkupPercent.Value;
|
||||
Settings.Default.ClientMarkupPercent = markup;
|
||||
Settings.Default.IntDefaultPercentMarkup = (int)Math.Round(markup, MidpointRounding.AwayFromZero);
|
||||
Settings.Default.Save();
|
||||
}
|
||||
|
||||
private void ReportTransferProgress(HttpTransferProgress transfer)
|
||||
{
|
||||
if (transfer.TotalBytes.HasValue && transfer.TotalBytes.Value > 0)
|
||||
@ -398,7 +386,6 @@ namespace Электронная_Фармация.HelpForms
|
||||
|
||||
private static DataTable BuildPriceTable(
|
||||
PriceSummaryResponse allSuppliersSummary,
|
||||
DiscountLookup discountLookup,
|
||||
IProgress<CountProgress> progress = null)
|
||||
{
|
||||
var tablePrice = new DataTable();
|
||||
@ -448,7 +435,6 @@ namespace Электронная_Фармация.HelpForms
|
||||
return tablePrice;
|
||||
}
|
||||
|
||||
var summaryClientDiscount = allSuppliersSummary.MarkupPercent;
|
||||
int total = allSuppliersSummary.Summary.Count;
|
||||
int current = 0;
|
||||
foreach (var item in allSuppliersSummary.Summary)
|
||||
@ -461,19 +447,9 @@ namespace Электронная_Фармация.HelpForms
|
||||
row["DrugName"] = item.DrugName ?? string.Empty;
|
||||
row["SupplierName"] = item.SupplierName ?? string.Empty;
|
||||
|
||||
var parts = DiscountResolver.ResolveForItem(discountLookup, item);
|
||||
// Вешаем только то, что пришло. Нет значения = 0.
|
||||
// summary.markup_percent / item.markup_percent — доп. источник, если API ещё отдаёт.
|
||||
var totalDiscount = MarkupHelper.SumDiscountPercents(
|
||||
parts.PriceListDiscountPct,
|
||||
parts.RegionDiscountPct,
|
||||
parts.ClientDiscountPct > 0m ? parts.ClientDiscountPct : (decimal?)null,
|
||||
parts.ClientDiscountPct <= 0m ? summaryClientDiscount : null,
|
||||
item.MarkupPercent);
|
||||
|
||||
var basePrice = item.Price ?? 0m;
|
||||
var finalPrice = MarkupHelper.ApplyDiscount(basePrice, totalDiscount);
|
||||
row["Price"] = finalPrice.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
// Цена уже итоговая с сервера — локально не пересчитываем.
|
||||
var serverPrice = item.Price ?? 0m;
|
||||
row["Price"] = serverPrice.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
row["Quantity"] = item.Quantity?.ToString() ?? string.Empty;
|
||||
row["ExpiryPeriod"] = item.ExpiryPeriod == null ? (object)DBNull.Value : item.ExpiryPeriod;
|
||||
row["Description"] = item.Description == null ? (object)DBNull.Value : item.Description;
|
||||
@ -481,7 +457,9 @@ namespace Электронная_Фармация.HelpForms
|
||||
row["SummaZakaza"] = string.Empty;
|
||||
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["MarkupPercent"] = totalDiscount;
|
||||
row["MarkupPercent"] = item.MarkupPercent.HasValue && item.MarkupPercent.Value > 0m
|
||||
? item.MarkupPercent.Value
|
||||
: 0m;
|
||||
tablePrice.Rows.Add(row);
|
||||
|
||||
current++;
|
||||
|
||||
107
src/ElectronicPharmacy/HelpForms/HF_UpdateAvailable.Designer.cs
generated
Normal 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;
|
||||
}
|
||||
}
|
||||
104
src/ElectronicPharmacy/HelpForms/HF_UpdateAvailable.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using System.Net;
|
||||
using Электронная_Фармация.Classes;
|
||||
@ -12,6 +12,7 @@ namespace Электронная_Фармация
|
||||
{
|
||||
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
|
||||
AppConfig.MigrateLegacySettings();
|
||||
AppConfig.UpgradeUserSettingsIfNeeded();
|
||||
AppDebugLog.Info("App", $"Запуск. ApiBaseUrl={AppConfig.ApiBaseUrl}, Log={AppDebugLog.CurrentLogFilePath}");
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
@ -2,35 +2,36 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// Общие сведения об этой сборке предоставляются следующим набором
|
||||
// набора атрибутов. Измените значения этих атрибутов для изменения сведений,
|
||||
// связанных со сборкой.
|
||||
[assembly: AssemblyTitle("Электронная Фармация")]
|
||||
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ. пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ,
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ.
|
||||
[assembly: AssemblyTitle("пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ")]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("Электронная Фармация")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2023")]
|
||||
[assembly: AssemblyProduct("пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ")]
|
||||
[assembly: AssemblyCopyright("Copyright пїЅ 2023")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
// Установка значения False для параметра ComVisible делает типы в этой сборке невидимыми
|
||||
// для компонентов COM. Если необходимо обратиться к типу в этой сборке через
|
||||
// COM, следует установить атрибут ComVisible в TRUE для этого типа.
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ False пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ ComVisible пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
|
||||
// пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ COM. пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ
|
||||
// COM, пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ ComVisible пїЅ TRUE пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ.
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
// Следующий GUID служит для идентификации библиотеки типов, если этот проект будет видимым для COM
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ GUID пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ, пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ COM
|
||||
[assembly: Guid("145d8060-4187-4871-b4fb-8df795727b66")]
|
||||
|
||||
// Сведения о версии сборки состоят из указанных ниже четырех значений:
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ:
|
||||
//
|
||||
// Основной номер версии
|
||||
// Дополнительный номер версии
|
||||
// Номер сборки
|
||||
// Редакция
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
|
||||
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
|
||||
//
|
||||
// Можно задать все значения или принять номера сборки и редакции по умолчанию
|
||||
// используя "*", как показано ниже:
|
||||
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
|
||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ "*", пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ:
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("1.0.3.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.3.0")]
|
||||
[assembly: AssemblyVersion("1.0.29.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.29.0")]
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
//------------------------------------------------------------------------------
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// Этот код создан программой.
|
||||
// Исполняемая версия:4.0.30319.42000
|
||||
@ -131,6 +131,18 @@ 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("")]
|
||||
@ -167,5 +179,53 @@ namespace Электронная_Фармация.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<?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">
|
||||
<Profiles />
|
||||
<Settings>
|
||||
@ -29,6 +29,9 @@
|
||||
<Setting Name="UseDarkTheme" Type="System.Boolean" Scope="User">
|
||||
<Value Profile="(Default)">False</Value>
|
||||
</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>
|
||||
@ -38,5 +41,17 @@
|
||||
<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>
|
||||
</SettingsFile>
|
||||
BIN
src/ElectronicPharmacy/Resources/elfisa.ico
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
@ -37,7 +37,7 @@ namespace Электронная_Фармация.UserControls
|
||||
dtpDateFrom.Value = DateTime.Now.AddDays(-3);
|
||||
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();
|
||||
comboSuppliers.Visible = true;
|
||||
@ -334,8 +334,8 @@ namespace Электронная_Фармация.UserControls
|
||||
|
||||
var addon = parts.Count == 0 ? string.Empty : " and " + string.Join(" and ", parts);
|
||||
LoadOrders(
|
||||
dtpDateFrom.Value.ToString("dd.MM.yyyy"),
|
||||
dtpDateTo.Value.ToString("dd.MM.yyyy"),
|
||||
dtpDateFrom.Value.ToString("yyyy-MM-dd"),
|
||||
dtpDateTo.Value.ToString("yyyy-MM-dd"),
|
||||
addon);
|
||||
}
|
||||
|
||||
@ -411,6 +411,14 @@ namespace Электронная_Фармация.UserControls
|
||||
{
|
||||
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 = $@"
|
||||
select
|
||||
[Id_Order],
|
||||
@ -425,8 +433,8 @@ select
|
||||
[ConsigneeName]
|
||||
from Orders
|
||||
where
|
||||
OrderDate >= '{dateFrom}'
|
||||
and OrderDate <= '{dateTo}'
|
||||
({orderDateIso}) >= '{dateFrom}'
|
||||
and ({orderDateIso}) <= '{dateTo}'
|
||||
{filters}
|
||||
";
|
||||
|
||||
@ -684,25 +692,25 @@ order by SupplierName asc";
|
||||
{
|
||||
txtGoodFromOrders.Text = "Товар";
|
||||
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)
|
||||
{
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -710,7 +718,7 @@ order by SupplierName asc";
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -734,7 +742,7 @@ order by SupplierName asc";
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -881,7 +889,7 @@ where [id_order] = '{idPriceList}'
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -906,7 +914,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
|
||||
{
|
||||
txtOrderNumberFilter.Text = "Номер заказа";
|
||||
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)
|
||||
@ -919,7 +927,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
|
||||
|
||||
HF_Comment hf_comment = new HF_Comment(OrderNumber, Id_Order);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -956,7 +964,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
|
||||
//ToastNotification.ShowCustom($"Заказ {OrderNumber} был удалён", Color.DarkOrange, Color.White);
|
||||
ToastNotification.ShowSuccess($"Заказ {OrderNumber} был удалён");
|
||||
//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)
|
||||
{
|
||||
|
||||
@ -554,8 +554,9 @@ group by SupplierName
|
||||
|
||||
void createOrderForNextSuppliers(string SupName)
|
||||
{
|
||||
string OrderNumber = $"EP-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
|
||||
string OrderDate = DateTime.Now.ToString("dd.MM.yyyy");
|
||||
// Глобальный номер EX-0000001… (сервер / локальный fallback).
|
||||
string OrderNumber = OrderNumberHelper.AllocateForNewOrder();
|
||||
string OrderDate = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
_consigneeName = ParentForm?.ConsigneeName ?? _consigneeName ?? string.Empty;
|
||||
var consigneeSafe = (_consigneeName ?? string.Empty).Replace("'", "''");
|
||||
var supNameSafe = (SupName ?? string.Empty).Replace("'", "''");
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
namespace Электронная_Фармация.UserControls
|
||||
namespace Электронная_Фармация.UserControls
|
||||
{
|
||||
partial class UCPriceList
|
||||
{
|
||||
@ -99,7 +99,7 @@
|
||||
this.SplitPriceList.Panel2.Controls.Add(this.POrderInfo);
|
||||
this.SplitPriceList.Size = new System.Drawing.Size(1053, 532);
|
||||
this.SplitPriceList.SplitterDistance = 290;
|
||||
this.SplitPriceList.SplitterWidth = 2;
|
||||
this.SplitPriceList.SplitterWidth = 6;
|
||||
this.SplitPriceList.TabIndex = 0;
|
||||
//
|
||||
// TLPPriceList
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
@ -35,6 +35,7 @@ namespace Электронная_Фармация.UserControls
|
||||
DataTable tablePriceList;
|
||||
string _WorkWithConsignee;
|
||||
decimal _percentageAsDefault;
|
||||
bool _suppressMarkupPersist;
|
||||
string _selectedSupplierFilter = string.Empty;
|
||||
bool _suppressSupplierFilterEvents;
|
||||
bool _suppressPriceListSelectionEvents;
|
||||
@ -48,6 +49,11 @@ namespace Электронная_Фармация.UserControls
|
||||
ModernSplitContainer splitNameMgBrowse;
|
||||
ModernDataGridView dgvBrowseNames;
|
||||
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(
|
||||
@"(\d+(?:[.,]\d+)?\s*(?:мг|мкг|г|мл|МЕ|ме)\b)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
@ -72,13 +78,14 @@ namespace Электронная_Фармация.UserControls
|
||||
private void UCPriceList_Load(object sender, EventArgs e)
|
||||
{
|
||||
EnsureBrowseSplit();
|
||||
EnsureChromeLayout();
|
||||
UiThemeHelper.ApplyToControlTree(this);
|
||||
Tag = "chrome-child";
|
||||
|
||||
AlignSearchRow();
|
||||
LayoutContentBelowSearch();
|
||||
|
||||
panel1.SizeChanged += (_, __) => LayoutMarkupRow();
|
||||
LayoutMarkupRow();
|
||||
SizeChanged += (_, __) => LayoutContentBelowSearch();
|
||||
|
||||
txtConsignee.Enabled = false;
|
||||
txtConsignee.Text = _WorkWithConsignee;
|
||||
@ -91,6 +98,120 @@ namespace Электронная_Фармация.UserControls
|
||||
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()
|
||||
{
|
||||
if (splitNameMgBrowse != null)
|
||||
@ -106,13 +227,35 @@ namespace Электронная_Фармация.UserControls
|
||||
Name = "splitNameMgBrowse",
|
||||
Dock = DockStyle.Fill,
|
||||
Orientation = Orientation.Vertical,
|
||||
SplitterWidth = 4,
|
||||
Visible = false
|
||||
SplitterWidth = 6,
|
||||
Visible = false,
|
||||
Panel1MinSize = 100,
|
||||
Panel2MinSize = 80,
|
||||
IsSplitterFixed = false,
|
||||
FixedPanel = FixedPanel.None
|
||||
};
|
||||
splitNameMgBrowse.Panel1.Controls.Add(dgvBrowseNames);
|
||||
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();
|
||||
|
||||
dgvBrowseNames.SelectionChanged += dgvBrowseNames_SelectionChanged;
|
||||
@ -151,11 +294,13 @@ namespace Электронная_Фармация.UserControls
|
||||
|
||||
private void AlignSearchRow()
|
||||
{
|
||||
const int searchY = 9;
|
||||
const int searchY = 10;
|
||||
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(
|
||||
txtSearchGoodNameInPriceList.Right + clearGap,
|
||||
searchY,
|
||||
@ -163,38 +308,23 @@ namespace Электронная_Фармация.UserControls
|
||||
searchHeight);
|
||||
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;
|
||||
|
||||
// Поиск всегда поверх контента, но контент начинается ниже searchBand.
|
||||
label2.BringToFront();
|
||||
txtSearchGoodNameInPriceList.BringToFront();
|
||||
btnClearSearchInPriceList.BringToFront();
|
||||
lblSupplierFilter.BringToFront();
|
||||
comboSupplierFilter.BringToFront();
|
||||
btnClearSupplierFilter.BringToFront();
|
||||
}
|
||||
|
||||
private void LayoutMarkupRow()
|
||||
{
|
||||
// Keep markup row readable at different DPI/window sizes.
|
||||
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
|
||||
}
|
||||
// UI наценки скрыт; метод оставлен на случай повторного включения.
|
||||
}
|
||||
|
||||
private void InitializeSupplierFilter()
|
||||
@ -406,11 +536,77 @@ namespace Электронная_Фармация.UserControls
|
||||
|
||||
EnsureDerivedBrowseColumns(tablePrice);
|
||||
_fullPriceTable = tablePrice;
|
||||
RebuildBrowseIndex(tablePrice);
|
||||
_selectedBaseName = string.Empty;
|
||||
_selectedMg = string.Empty;
|
||||
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)
|
||||
{
|
||||
if (dgvPriceList.Columns.Contains(columnName))
|
||||
@ -505,55 +701,51 @@ namespace Электронная_Фармация.UserControls
|
||||
}
|
||||
|
||||
var search = (GoodsFilter ?? string.Empty).Trim();
|
||||
var namesTable = new DataTable();
|
||||
namesTable.Columns.Add("Наименование", typeof(string));
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
IList<string> namesToShow;
|
||||
if (string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
namesToShow = _uniqueBaseNames;
|
||||
}
|
||||
else
|
||||
{
|
||||
var matching = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (DataRow row in _fullPriceTable.Rows)
|
||||
{
|
||||
var baseName = row["Базовое наименование"]?.ToString() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(baseName) || !seen.Add(baseName))
|
||||
if (string.IsNullOrWhiteSpace(baseName) || matching.Contains(baseName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
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;
|
||||
var drugName = row["Наименование"]?.ToString() ?? string.Empty;
|
||||
var mgText = row["МГ"]?.ToString() ?? string.Empty;
|
||||
if (baseName.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| drugName.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| mgText.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
keep = true;
|
||||
break;
|
||||
matching.Add(baseName);
|
||||
}
|
||||
}
|
||||
|
||||
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.DefaultView.Sort = "Наименование ASC";
|
||||
var sortedNames = namesTable.DefaultView.ToTable();
|
||||
namesTable.EndLoadData();
|
||||
|
||||
try
|
||||
{
|
||||
_suppressBrowseEvents = true;
|
||||
dgvBrowseNames.DataSource = sortedNames;
|
||||
dgvBrowseNames.SuspendLayout();
|
||||
dgvBrowseNames.DataSource = namesTable;
|
||||
ConfigureSingleColumnGrid(dgvBrowseNames, "Наименование");
|
||||
ClearMgPanel();
|
||||
|
||||
@ -568,6 +760,7 @@ namespace Электронная_Фармация.UserControls
|
||||
}
|
||||
finally
|
||||
{
|
||||
dgvBrowseNames.ResumeLayout();
|
||||
_suppressBrowseEvents = false;
|
||||
}
|
||||
|
||||
@ -594,28 +787,18 @@ namespace Электронная_Фармация.UserControls
|
||||
var mgTable = new DataTable();
|
||||
mgTable.Columns.Add("МГ", typeof(string));
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (DataRow row in _fullPriceTable.Rows)
|
||||
if (_mgByBaseName.TryGetValue(baseName, out var dosages))
|
||||
{
|
||||
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
|
||||
{
|
||||
_suppressBrowseEvents = true;
|
||||
dgvBrowseMg.DataSource = mgTable.DefaultView.ToTable();
|
||||
dgvBrowseMg.DataSource = mgTable;
|
||||
ConfigureSingleColumnGrid(dgvBrowseMg, "МГ");
|
||||
dgvBrowseMg.ClearSelection();
|
||||
}
|
||||
@ -666,10 +849,21 @@ namespace Электронная_Фармация.UserControls
|
||||
{
|
||||
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)
|
||||
{
|
||||
dgvPriceList.Visible = false;
|
||||
splitNameMgBrowse.Visible = true;
|
||||
splitNameMgBrowse.Dock = DockStyle.Fill;
|
||||
splitNameMgBrowse.BringToFront();
|
||||
|
||||
var width = Math.Max(200, splitNameMgBrowse.Width);
|
||||
@ -695,6 +889,9 @@ namespace Электронная_Фармация.UserControls
|
||||
|
||||
splitNameMgBrowse.Visible = false;
|
||||
dgvPriceList.Visible = true;
|
||||
dgvPriceList.Dock = DockStyle.Fill;
|
||||
dgvPriceList.ColumnHeadersVisible = true;
|
||||
dgvPriceList.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None;
|
||||
dgvPriceList.BringToFront();
|
||||
}
|
||||
}
|
||||
@ -989,7 +1186,7 @@ namespace Электронная_Фармация.UserControls
|
||||
|
||||
GoodsFilter = GoodsFilter.Substring(0, GoodsFilter.Length - 1);
|
||||
txtSearchGoodNameInPriceList.Text = GoodsFilter;
|
||||
ResetBrowseToNames();
|
||||
ScheduleBrowseSearchRefresh(immediate: GoodsFilter.Length == 0);
|
||||
|
||||
if (GoodsFilter.Length == 0)
|
||||
{
|
||||
@ -1006,34 +1203,59 @@ namespace Электронная_Фармация.UserControls
|
||||
|
||||
void loadInfoAboutDefaultMarkup()
|
||||
{
|
||||
decimal percentageMarkupDefault = Properties.Settings.Default.ClientMarkupPercent;
|
||||
decimal percentageMarkupDefault = 0m;
|
||||
var fromConsignee = ConsigneeHelper.GetClientMarkupPercent(_WorkWithConsignee);
|
||||
if (fromConsignee.HasValue)
|
||||
{
|
||||
percentageMarkupDefault = fromConsignee.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
percentageMarkupDefault = Properties.Settings.Default.ClientMarkupPercent;
|
||||
if (percentageMarkupDefault <= 0)
|
||||
{
|
||||
percentageMarkupDefault = Properties.Settings.Default.IntDefaultPercentMarkup;
|
||||
}
|
||||
|
||||
if (percentageMarkupDefault <= 0)
|
||||
if (percentageMarkupDefault < 0)
|
||||
{
|
||||
percentageMarkupDefault = MarkupHelper.FallbackMarkupPercent;
|
||||
}
|
||||
|
||||
// Первый запуск для этой аптеки — сразу закрепим текущее значение за аптекой.
|
||||
if (!string.IsNullOrWhiteSpace(_WorkWithConsignee))
|
||||
{
|
||||
try
|
||||
{
|
||||
ConsigneeHelper.SetClientMarkupPercent(_WorkWithConsignee, percentageMarkupDefault);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// колонка может ещё не быть — при следующем старте миграция добавит
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_percentageAsDefault = percentageMarkupDefault;
|
||||
_suppressMarkupPersist = true;
|
||||
try
|
||||
{
|
||||
numericPercent.DecimalPlaces = 2;
|
||||
numericPercent.Increment = 0.1m;
|
||||
numericPercent.Value = Math.Max(numericPercent.Minimum, Math.Min(numericPercent.Maximum, percentageMarkupDefault));
|
||||
numericPercent.Value = Math.Max(
|
||||
numericPercent.Minimum,
|
||||
Math.Min(numericPercent.Maximum, percentageMarkupDefault));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressMarkupPersist = false;
|
||||
}
|
||||
}
|
||||
|
||||
private decimal GetMarkupPercentForRow(DataGridViewRow row)
|
||||
{
|
||||
if (row?.DataGridView?.Columns.Contains("Наценка %") == true)
|
||||
{
|
||||
var cellValue = row.Cells["Наценка %"]?.Value;
|
||||
if (MarkupHelper.TryParseMarkup(cellValue, out var itemMarkup) && itemMarkup >= 0)
|
||||
{
|
||||
return itemMarkup;
|
||||
}
|
||||
}
|
||||
|
||||
// Для превью/спиннера всегда используем процент аптеки, который задал клиент.
|
||||
// Серверные значения в колонке «Наценка %» не перетирают ручной ввод.
|
||||
return _percentageAsDefault;
|
||||
}
|
||||
|
||||
@ -1299,7 +1521,7 @@ BestBefore = '{BestBefore}'
|
||||
{
|
||||
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();
|
||||
|
||||
|
||||
@ -1475,7 +1697,7 @@ select strftime('%Y-%m-%d','{OrderDate}'),
|
||||
{
|
||||
txtSearchGoodNameInPriceList.Text = string.Empty;
|
||||
GoodsFilter = string.Empty;
|
||||
ResetBrowseToNames();
|
||||
ScheduleBrowseSearchRefresh(immediate: true);
|
||||
//loadPriceList(string.Empty);
|
||||
}
|
||||
|
||||
@ -1534,6 +1756,27 @@ and tempOrder.GoodName = PriceList.GoodName
|
||||
string GoodsFilter = 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)
|
||||
{
|
||||
@ -1547,7 +1790,7 @@ and tempOrder.GoodName = PriceList.GoodName
|
||||
{
|
||||
txtSearchGoodNameInPriceList.Text = string.Empty;
|
||||
GoodsFilter = string.Empty;
|
||||
ResetBrowseToNames();
|
||||
ScheduleBrowseSearchRefresh(immediate: true);
|
||||
}
|
||||
|
||||
return;
|
||||
@ -1587,67 +1830,27 @@ and tempOrder.GoodName = PriceList.GoodName
|
||||
|
||||
if (_browseMode != PriceBrowseMode.Offers)
|
||||
{
|
||||
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))
|
||||
{
|
||||
dgvPriceList.ClearSelection();
|
||||
GoodsFilter += e.KeyChar.ToString();
|
||||
txtSearchGoodNameInPriceList.Text = GoodsFilter;
|
||||
ResetBrowseToNames();
|
||||
if (dgvBrowseNames != null && dgvBrowseNames.Rows.Count == 0)
|
||||
{
|
||||
UiDialogs.ShowInfo("Подходящий товар не найден.", "Поиск", FindForm());
|
||||
GoodsFilter = string.Empty;
|
||||
txtSearchGoodNameInPriceList.Text = string.Empty;
|
||||
ResetBrowseToNames();
|
||||
}
|
||||
ScheduleBrowseSearchRefresh();
|
||||
timerForResetSearch.Enabled = true;
|
||||
timerForResetSearch.Start();
|
||||
}
|
||||
|
||||
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 (e.KeyChar == (Char)Keys.Enter)
|
||||
{
|
||||
|
||||
}
|
||||
else
|
||||
if (IsGoodsSearchKey(e.KeyChar, allowDigitsAndSeparators: false))
|
||||
{
|
||||
dgvPriceList.ClearSelection();
|
||||
GoodsFilter += e.KeyChar.ToString();
|
||||
txtSearchGoodNameInPriceList.Text = GoodsFilter;
|
||||
|
||||
#region поиск без фильтрации (НЕ РАБОТАЕТ)
|
||||
//foreach(DataGridViewRow row in dgvPriceList.Rows)
|
||||
//{
|
||||
// //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
|
||||
}
|
||||
ScheduleBrowseSearchRefresh();
|
||||
timerForResetSearch.Enabled = true;
|
||||
timerForResetSearch.Start();
|
||||
return;
|
||||
}
|
||||
else if ((e.KeyChar >= (Char)Keys.D1 && e.KeyChar <= (Char)Keys.D9) ||
|
||||
@ -2028,7 +2231,18 @@ and SumOrderedItems = '{SumOrder}'";
|
||||
}
|
||||
|
||||
var markupPercentage = GetMarkupPercentForRow(currentRow);
|
||||
numericPercent.Value = Math.Max(numericPercent.Minimum, Math.Min(numericPercent.Maximum, markupPercentage));
|
||||
_suppressMarkupPersist = true;
|
||||
try
|
||||
{
|
||||
numericPercent.Value = Math.Max(
|
||||
numericPercent.Minimum,
|
||||
Math.Min(numericPercent.Maximum, markupPercentage));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressMarkupPersist = false;
|
||||
}
|
||||
|
||||
lblPriceForGood.Text = $"= {priceForGood:0.##} руб";
|
||||
}
|
||||
catch
|
||||
@ -2405,6 +2619,11 @@ and SumOrderedItems = '{SumOrder}'";
|
||||
|
||||
private void numericPercent_ValueChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!_suppressMarkupPersist)
|
||||
{
|
||||
PersistClientMarkupPercent();
|
||||
}
|
||||
|
||||
if (numericPercent.Value != _percentageAsDefault)
|
||||
{
|
||||
checkUsePercentageAsDefault.Checked = false;
|
||||
@ -2413,6 +2632,30 @@ and SumOrderedItems = '{SumOrder}'";
|
||||
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)
|
||||
{
|
||||
if (comboSupplierFilter.DroppedDown)
|
||||
@ -2435,13 +2678,9 @@ and SumOrderedItems = '{SumOrder}'";
|
||||
|
||||
private void checkUsePercentageAsDefault_CheckedChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (_percentageAsDefault != numericPercent.Value && checkUsePercentageAsDefault.Checked == true)
|
||||
if (checkUsePercentageAsDefault.Checked)
|
||||
{
|
||||
var newDefaultPercent = numericPercent.Value;
|
||||
Properties.Settings.Default.ClientMarkupPercent = newDefaultPercent;
|
||||
Properties.Settings.Default.IntDefaultPercentMarkup = Convert.ToInt32(Math.Round(newDefaultPercent, MidpointRounding.AwayFromZero));
|
||||
Properties.Settings.Default.Save();
|
||||
_percentageAsDefault = newDefaultPercent;
|
||||
PersistClientMarkupPercent();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2449,7 +2688,7 @@ and SumOrderedItems = '{SumOrder}'";
|
||||
{
|
||||
txtSearchGoodNameInPriceList.Text = string.Empty;
|
||||
GoodsFilter = string.Empty;
|
||||
ResetBrowseToNames();
|
||||
ScheduleBrowseSearchRefresh(immediate: true);
|
||||
timerForResetSearch.Enabled = false;
|
||||
timerForResetSearch.Stop();
|
||||
}
|
||||
|
||||
318
src/ElectronicPharmacy/UserControls/UCReports.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/Elfisa.Avalonia/App.axaml
Normal 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>
|
||||
35
src/Elfisa.Avalonia/App.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
BIN
src/Elfisa.Avalonia/Assets/avalonia-logo.ico
Normal file
|
After Width: | Height: | Size: 172 KiB |
38
src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
Normal 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>
|
||||
24
src/Elfisa.Avalonia/Program.cs
Normal 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();
|
||||
}
|
||||
73
src/Elfisa.Avalonia/README.md
Normal 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).
|
||||
193
src/Elfisa.Avalonia/Styles/Elfisa.axaml
Normal 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>
|
||||
37
src/Elfisa.Avalonia/ViewLocator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/Elfisa.Avalonia/ViewModels/CartService.cs
Normal 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();
|
||||
}
|
||||
48
src/Elfisa.Avalonia/ViewModels/DirectoryViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
72
src/Elfisa.Avalonia/ViewModels/InvoicesViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
100
src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
44
src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
165
src/Elfisa.Avalonia/ViewModels/OrdersViewModel.cs
Normal 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 = "Черновик удалён.";
|
||||
}
|
||||
}
|
||||
15
src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
281
src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
215
src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs
Normal 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 { /* открыть не удалось — путь показан в статусе */ }
|
||||
}
|
||||
}
|
||||
48
src/Elfisa.Avalonia/ViewModels/RootViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
106
src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
43
src/Elfisa.Avalonia/ViewModels/SettingsViewModel.cs
Normal 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 { /* локации не критичны для настроек */ }
|
||||
}
|
||||
}
|
||||
136
src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs
Normal 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();
|
||||
}
|
||||
7
src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
56
src/Elfisa.Avalonia/Views/DirectoryView.axaml
Normal 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>
|
||||
9
src/Elfisa.Avalonia/Views/DirectoryView.axaml.cs
Normal 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);
|
||||
}
|
||||
39
src/Elfisa.Avalonia/Views/InvoicesView.axaml
Normal 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>
|
||||
9
src/Elfisa.Avalonia/Views/InvoicesView.axaml.cs
Normal 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);
|
||||
}
|
||||
61
src/Elfisa.Avalonia/Views/LoadPriceView.axaml
Normal 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>
|
||||
9
src/Elfisa.Avalonia/Views/LoadPriceView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class LoadPriceView : UserControl
|
||||
{
|
||||
public LoadPriceView() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
39
src/Elfisa.Avalonia/Views/LoginView.axaml
Normal 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.LoginView"
|
||||
x:DataType="vm:LoginViewModel">
|
||||
|
||||
<Grid Background="{DynamicResource Bg}">
|
||||
<Border Classes="card" Width="390" Padding="30"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="16">
|
||||
<StackPanel Spacing="2" Margin="0,0,0,6">
|
||||
<TextBlock Text="ЭльФиСА" FontSize="28" FontWeight="Bold"
|
||||
Foreground="{DynamicResource Accent}" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Электронная Фармация" Classes="muted" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Логин" Classes="label"/>
|
||||
<TextBox Text="{Binding Username}" Watermark="e-mail или логин"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Пароль" Classes="label"/>
|
||||
<TextBox Text="{Binding Password}" PasswordChar="●" Watermark="пароль"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="#1FDC2626" CornerRadius="6" Padding="10,7"
|
||||
IsVisible="{Binding Error, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding Error}" Foreground="#DC2626" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
</Border>
|
||||
|
||||
<Button Classes="primary" Content="Войти" IsDefault="True"
|
||||
HorizontalAlignment="Stretch" Margin="0,4,0,0"
|
||||
Command="{Binding LoginCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
9
src/Elfisa.Avalonia/Views/LoginView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class LoginView : UserControl
|
||||
{
|
||||
public LoginView() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
16
src/Elfisa.Avalonia/Views/MainWindow.axaml
Normal file
@ -0,0 +1,16 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="740"
|
||||
x:Class="Elfisa.Avalonia.Views.MainWindow"
|
||||
x:DataType="vm:RootViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Width="1200" Height="740" MinWidth="920" MinHeight="600"
|
||||
Background="{DynamicResource Bg}"
|
||||
Title="ЭльФиСА">
|
||||
|
||||
<ContentControl Content="{Binding Current}"/>
|
||||
|
||||
</Window>
|
||||
11
src/Elfisa.Avalonia/Views/MainWindow.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
83
src/Elfisa.Avalonia/Views/OrdersView.axaml
Normal file
@ -0,0 +1,83 @@
|
||||
<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.OrdersView"
|
||||
x:DataType="vm:OrdersViewModel">
|
||||
|
||||
<Grid Margin="24" RowDefinitions="Auto,*">
|
||||
<StackPanel Grid.Row="0" Spacing="2" Margin="0,0,0,14">
|
||||
<TextBlock Text="Заказы" Classes="h1"/>
|
||||
<TextBlock Text="История заказов и их состав" Classes="muted"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,16,430">
|
||||
|
||||
<!-- Список заказов -->
|
||||
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
|
||||
<DataGrid ItemsSource="{Binding Orders}" SelectedItem="{Binding SelectedOrder}"
|
||||
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="140" Binding="{Binding Number}"/>
|
||||
<DataGridTextColumn Header="Поставщик" Width="*" Binding="{Binding Supplier}"/>
|
||||
<DataGridTextColumn Header="Позиций" Width="90" Binding="{Binding ItemsCount}" CellStyleClasses="num"/>
|
||||
<DataGridTextColumn Header="Сумма" Width="132" Binding="{Binding Sum, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||
<DataGridTextColumn Header="Статус" Width="95" Binding="{Binding Status}"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
|
||||
<!-- Карточка заказа -->
|
||||
<Border Grid.Column="2" Classes="card" Padding="18">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="4" Margin="0,0,0,14">
|
||||
<TextBlock Text="{Binding SelectedOrder.Number}" Classes="h2"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="{Binding SelectedOrder.Date}" Classes="muted"/>
|
||||
<TextBlock Text="{Binding SelectedOrder.Status}" Classes="muted"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding SelectedOrder.Supplier}" Classes="muted" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding SelectedOrder.Location}" Classes="muted" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
|
||||
<Border Padding="0,10,0,0" BorderThickness="0,1,0,0" BorderBrush="{DynamicResource Border}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" Foreground="{DynamicResource Text}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding SelectedOrder.Sum, StringFormat='{}{0:N2}'}"
|
||||
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- Действия для черновика (не отправленного заказа) -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" IsVisible="{Binding SelectedOrder.IsDraft, FallbackValue=False}">
|
||||
<Button Classes="primary" Content="Отправить заказ" Command="{Binding SendDraftCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
<Button Content="Удалить" Command="{Binding DeleteDraftCommand}"
|
||||
Background="Transparent" Foreground="{DynamicResource Muted}"
|
||||
BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="8" Padding="16,9" Cursor="Hand"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid ItemsSource="{Binding SelectedOrder.Items}" x:CompileBindings="False"
|
||||
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Товар" Width="*" Binding="{Binding Name}"/>
|
||||
<DataGridTextColumn Header="Кол-во" Width="78" Binding="{Binding Qty, StringFormat='{}{0:N0}'}" CellStyleClasses="num"/>
|
||||
<DataGridTextColumn Header="Сумма" Width="110" Binding="{Binding Sum, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Статус -->
|
||||
<Border Grid.Row="1" VerticalAlignment="Bottom" HorizontalAlignment="Left" Margin="4"
|
||||
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>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
9
src/Elfisa.Avalonia/Views/OrdersView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class OrdersView : UserControl
|
||||
{
|
||||
public OrdersView() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
24
src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml
Normal file
@ -0,0 +1,24 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
x:Class="Elfisa.Avalonia.Views.PlaceholderPageView"
|
||||
x:DataType="vm:PlaceholderPageViewModel">
|
||||
|
||||
<Grid Margin="28" RowDefinitions="Auto,*">
|
||||
<StackPanel Grid.Row="0" Spacing="4">
|
||||
<TextBlock Text="{Binding Title}" Classes="h1"/>
|
||||
<TextBlock Text="{Binding Description}" Classes="muted"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="1" Classes="card" MaxWidth="480" Padding="34"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="14" HorizontalAlignment="Center">
|
||||
<Border Width="56" Height="56" CornerRadius="28" Background="{DynamicResource AccentSoft}">
|
||||
<TextBlock Text="⏳" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="Скоро" Classes="h2" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Note}" Classes="muted" TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
9
src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class PlaceholderPageView : UserControl
|
||||
{
|
||||
public PlaceholderPageView() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
136
src/Elfisa.Avalonia/Views/PriceListView.axaml
Normal file
@ -0,0 +1,136 @@
|
||||
<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.PriceListView"
|
||||
x:DataType="vm:PriceListViewModel">
|
||||
|
||||
<DockPanel Margin="24">
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,14" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Text="Прайс-лист" Classes="h1"/>
|
||||
<TextBlock Text="Каталог позиций — собери заказ и сохрани" Classes="muted"/>
|
||||
</StackPanel>
|
||||
<!-- Мои прайсы: видно, по какому прайсу заказываем; если подключено несколько — можно выбрать -->
|
||||
<Border Grid.Column="1" VerticalAlignment="Center" Padding="12,8" CornerRadius="10"
|
||||
Background="{DynamicResource Panel}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<TextBlock Text="Мои прайсы" Classes="label" VerticalAlignment="Center"/>
|
||||
<ComboBox ItemsSource="{Binding MyPriceLists}" SelectedItem="{Binding SelectedPriceList}"
|
||||
MinWidth="240" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</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}" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,16,430">
|
||||
|
||||
<!-- Каталог -->
|
||||
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
|
||||
<TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию (от 2 символов)…"/>
|
||||
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
<Border DockPanel.Dock="Bottom" Margin="14" Padding="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<TextBlock Text="Кол-во" Classes="label" VerticalAlignment="Center"/>
|
||||
<NumericUpDown Value="{Binding SelectedOffer.OrderQty}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" Width="120"
|
||||
IsEnabled="{Binding SelectedOffer, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
|
||||
<Button Classes="primary" Content="Добавить в заказ ▸" Command="{Binding AddToOrderCommand}"/>
|
||||
<TextBlock Text="или выдели строку и набери число на клавиатуре, Enter — добавить" Classes="muted" VerticalAlignment="Center" TextWrapping="Wrap" MaxWidth="240"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Дрилдаун как в старой программе: Название → МГ → Предложения -->
|
||||
<Grid ColumnDefinitions="1.3*,10,120,10,2.2*" Margin="14,0,14,0">
|
||||
|
||||
<!-- Название (базовое наименование) -->
|
||||
<DockPanel Grid.Column="0">
|
||||
<TextBlock DockPanel.Dock="Top" Text="Название" Classes="label" Margin="2,0,0,5"/>
|
||||
<ListBox ItemsSource="{Binding BaseNames}" SelectedItem="{Binding SelectedBaseName}"
|
||||
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- МГ (дозировка) -->
|
||||
<DockPanel Grid.Column="2">
|
||||
<TextBlock DockPanel.Dock="Top" Text="МГ" Classes="label" Margin="2,0,0,5"/>
|
||||
<ListBox ItemsSource="{Binding MgOptions}" SelectedItem="{Binding SelectedMg}"
|
||||
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Предложения (фильтр: название + МГ) -->
|
||||
<DockPanel Grid.Column="4">
|
||||
<TextBlock DockPanel.Dock="Top" Text="Предложения" Classes="label" Margin="2,0,0,5"/>
|
||||
<DataGrid x:Name="OffersGrid" ItemsSource="{Binding Offers}" SelectedItem="{Binding SelectedOffer}" x:CompileBindings="False"
|
||||
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="False"
|
||||
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
|
||||
<DataGridTextColumn Header="Поставщик" Width="140" Binding="{Binding SupplierName}"/>
|
||||
<DataGridTextColumn Header="Цена" Width="85" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||
<DataGridTextColumn Header="Остаток" Width="75" Binding="{Binding Quantity, StringFormat='{}{0:0}'}" CellStyleClasses="num"/>
|
||||
<DataGridTextColumn Header="Заказ" Width="70" Binding="{Binding OrderQty, StringFormat='{}{0:0}'}" CellStyleClasses="num"
|
||||
FontWeight="Bold" Foreground="{DynamicResource Accent}"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Мой заказ -->
|
||||
<Border Grid.Column="2" Classes="card" Padding="16">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Мой заказ" Classes="h2" Margin="0,0,0,10"/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Грузополучатель" Classes="muted"/>
|
||||
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
<TextBox Text="{Binding Comment}" Watermark="комментарий (необязательно)"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{DynamicResource Text}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding CartTotal, StringFormat='{}{0:N2}'}" FontWeight="Bold" FontSize="16" Foreground="{DynamicResource Accent}"/>
|
||||
</Grid>
|
||||
<Button Classes="primary" Content="Сохранить заказ" HorizontalAlignment="Stretch" Command="{Binding SaveDraftCommand}"/>
|
||||
<TextBlock Text="Черновик появится в «Заказы» со статусом «Не отправлен»." Classes="muted" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Cart}" x:CompileBindings="False">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CartItem">
|
||||
<Border Padding="10" Margin="0,0,0,8" CornerRadius="9"
|
||||
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="✕" Width="30" Height="30" Padding="0"
|
||||
VerticalAlignment="Top" Background="Transparent" Foreground="{DynamicResource Muted}" Cursor="Hand"
|
||||
Command="{Binding $parent[ItemsControl].DataContext.RemoveFromCartCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}" TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding Supplier}" Classes="muted"/>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<NumericUpDown Grid.Column="0" Value="{Binding Qty}" Minimum="1" Maximum="100000" Increment="1" FormatString="0" Width="120"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Price, StringFormat='× {0:N2}'}" Classes="muted" VerticalAlignment="Center" Margin="10,0"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding LineSum, StringFormat='{}{0:N2}'}" FontWeight="SemiBold" Foreground="{DynamicResource Text}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
58
src/Elfisa.Avalonia/Views/PriceListView.axaml.cs
Normal file
@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Elfisa.Avalonia.ViewModels;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class PriceListView : UserControl
|
||||
{
|
||||
// Строка, в которую сейчас набирают количество с клавиатуры (для многозначного ввода).
|
||||
private OfferRow? _typingRow;
|
||||
|
||||
public PriceListView()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
var grid = this.FindControl<DataGrid>("OffersGrid");
|
||||
if (grid != null) grid.KeyDown += OffersGrid_KeyDown;
|
||||
}
|
||||
|
||||
// Выделил предложение → набираешь число цифрами (как колонка «Заказ» в старой программе).
|
||||
// Backspace — стереть цифру, Enter — добавить в заказ.
|
||||
private void OffersGrid_KeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (sender is not DataGrid grid || grid.SelectedItem is not OfferRow row) return;
|
||||
|
||||
var digit = DigitFromKey(e.Key);
|
||||
if (digit >= 0)
|
||||
{
|
||||
if (!ReferenceEquals(_typingRow, row)) { row.OrderQty = 0; _typingRow = row; }
|
||||
var next = row.OrderQty * 10 + digit;
|
||||
if (next <= 100000) row.OrderQty = next;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Back:
|
||||
row.OrderQty = Math.Floor(row.OrderQty / 10);
|
||||
_typingRow = row;
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Enter:
|
||||
(DataContext as PriceListViewModel)?.AddSelectedOffer();
|
||||
_typingRow = null;
|
||||
e.Handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static int DigitFromKey(Key k)
|
||||
{
|
||||
if (k >= Key.D0 && k <= Key.D9) return k - Key.D0;
|
||||
if (k >= Key.NumPad0 && k <= Key.NumPad9) return k - Key.NumPad0;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
58
src/Elfisa.Avalonia/Views/ReportsView.axaml
Normal file
@ -0,0 +1,58 @@
|
||||
<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.ReportsView"
|
||||
x:DataType="vm:ReportsViewModel">
|
||||
|
||||
<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" Classes="card" Margin="0,0,0,12" Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<TextBlock Text="С" Classes="label" Margin="0,0,8,6"/>
|
||||
<DatePicker SelectedDate="{Binding DateFrom}" Margin="0,0,10,6"/>
|
||||
<TextBlock Text="по" Classes="label" Margin="0,0,8,6"/>
|
||||
<DatePicker SelectedDate="{Binding DateTo}" Margin="0,0,10,6"/>
|
||||
<ComboBox ItemsSource="{Binding ReportTypes}" SelectedIndex="{Binding SelectedReportIndex}" Width="170" Margin="0,0,10,6"/>
|
||||
<Button Classes="primary" Content="Сформировать" Command="{Binding BuildCommand}" IsEnabled="{Binding !Busy}" Margin="0,0,0,6"/>
|
||||
</WrapPanel>
|
||||
|
||||
<Border Height="1" Background="{DynamicResource Border}"/>
|
||||
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Запрос ИИ" Classes="label" Margin="0,0,8,6"/>
|
||||
<TextBox Text="{Binding AiPrompt}" Width="420" Margin="0,0,10,6"
|
||||
Watermark="напр.: «закупки по поставщикам за июль», «заказы за всё время с товарами»"/>
|
||||
<ComboBox ItemsSource="{Binding AiFormats}" SelectedIndex="{Binding SelectedAiFormat}" Width="100" Margin="0,0,10,6"/>
|
||||
<Button Classes="primary" Content="Сформировать ИИ" Command="{Binding GenerateAiCommand}" IsEnabled="{Binding !AiBusy}" Margin="0,0,0,6"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Статус -->
|
||||
<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}" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
</Border>
|
||||
|
||||
<!-- Таблица -->
|
||||
<Border Classes="card" Padding="0" ClipToBounds="True">
|
||||
<DataGrid x:Name="Grid" ItemsSource="{Binding Rows}"
|
||||
AutoGenerateColumns="False" IsReadOnly="True"
|
||||
HeadersVisibility="Column" CanUserResizeColumns="True" CanUserSortColumns="False"/>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
60
src/Elfisa.Avalonia/Views/ReportsView.axaml.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Elfisa.Avalonia.ViewModels;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class ReportsView : UserControl
|
||||
{
|
||||
private ReportsViewModel? _vm;
|
||||
|
||||
public ReportsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_vm != null) _vm.ColumnsChanged -= RebuildColumns;
|
||||
_vm = DataContext as ReportsViewModel;
|
||||
if (_vm != null)
|
||||
{
|
||||
_vm.ColumnsChanged += RebuildColumns;
|
||||
RebuildColumns();
|
||||
}
|
||||
}
|
||||
|
||||
// DataGrid не умеет динамические колонки через биндинг — строим их из VM в коде.
|
||||
private void RebuildColumns()
|
||||
{
|
||||
Grid.Columns.Clear();
|
||||
if (_vm == null) return;
|
||||
|
||||
for (int i = 0; i < _vm.Columns.Count; i++)
|
||||
{
|
||||
int idx = i;
|
||||
var c = _vm.Columns[i];
|
||||
Grid.Columns.Add(new DataGridTemplateColumn
|
||||
{
|
||||
Header = c.Header,
|
||||
// одна тянущаяся колонка (star) заполняет свободное место, остальные фикс. пиксель
|
||||
Width = c.Stretch
|
||||
? new DataGridLength(1, DataGridLengthUnitType.Star)
|
||||
: new DataGridLength(c.Weight, DataGridLengthUnitType.Pixel),
|
||||
MinWidth = 56,
|
||||
CellTemplate = new FuncDataTemplate<ReportRow>((row, _) => new TextBlock
|
||||
{
|
||||
Text = row?[idx] ?? "",
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = c.Numeric ? TextAlignment.Right : TextAlignment.Left,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/Elfisa.Avalonia/Views/SendOrderView.axaml
Normal file
@ -0,0 +1,104 @@
|
||||
<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.SendOrderView"
|
||||
x:DataType="vm:SendOrderViewModel">
|
||||
|
||||
<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>
|
||||
</Grid>
|
||||
|
||||
<Border DockPanel.Dock="Bottom" Margin="0,10,0,0" Padding="10,7" CornerRadius="6"
|
||||
Background="{DynamicResource AccentSoft}"
|
||||
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,16,440">
|
||||
|
||||
<!-- Прайс: поиск + результаты -->
|
||||
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
|
||||
<TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию…"/>
|
||||
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
<Button DockPanel.Dock="Bottom" Classes="primary" Content="Добавить в заказ ▸"
|
||||
Margin="14" HorizontalAlignment="Left"
|
||||
Command="{Binding AddToCartCommand}"/>
|
||||
<DataGrid ItemsSource="{Binding Results}" SelectedItem="{Binding SelectedResult}"
|
||||
x:CompileBindings="False" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
HeadersVisibility="Column" CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
|
||||
<DataGridTextColumn Header="Поставщик" Width="120" Binding="{Binding SupplierName}"/>
|
||||
<DataGridTextColumn Header="Цена" Width="100" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Корзина -->
|
||||
<Border Grid.Column="2" Classes="card" Padding="16">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Корзина" Classes="h2" Margin="0,0,0,10"/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Грузополучатель" Classes="muted"/>
|
||||
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
<TextBox Text="{Binding Comment}" Watermark="комментарий к заказу (необязательно)"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource Text}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding CartTotal, StringFormat='{}{0:N2}'}"
|
||||
FontWeight="Bold" FontSize="16" Foreground="{DynamicResource Accent}"/>
|
||||
</Grid>
|
||||
<Button Classes="primary" Content="Отправить заказ" HorizontalAlignment="Stretch"
|
||||
Command="{Binding SendCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Cart}" x:CompileBindings="False">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CartItem">
|
||||
<Border Padding="10" Margin="0,0,0,8" CornerRadius="9"
|
||||
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="✕" Width="30" Height="30" Padding="0"
|
||||
VerticalAlignment="Top" Background="Transparent"
|
||||
Foreground="{DynamicResource Muted}" Cursor="Hand"
|
||||
Command="{Binding $parent[ItemsControl].DataContext.RemoveFromCartCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding Supplier}" Classes="muted"/>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<NumericUpDown Grid.Column="0" Value="{Binding Qty}" Minimum="1" Maximum="100000"
|
||||
Increment="1" FormatString="0" Width="120"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Price, StringFormat='× {0:N2}'}"
|
||||
Classes="muted" VerticalAlignment="Center" Margin="10,0"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding LineSum, StringFormat='{}{0:N2}'}"
|
||||
FontWeight="SemiBold" Foreground="{DynamicResource Text}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
9
src/Elfisa.Avalonia/Views/SendOrderView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class SendOrderView : UserControl
|
||||
{
|
||||
public SendOrderView() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
34
src/Elfisa.Avalonia/Views/SettingsWindow.axaml
Normal file
@ -0,0 +1,34 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
x:Class="Elfisa.Avalonia.Views.SettingsWindow"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
Width="460" Height="360" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource Bg}"
|
||||
Title="Настройки">
|
||||
|
||||
<StackPanel Margin="24" Spacing="16">
|
||||
<TextBlock Text="Настройки" Classes="h1"/>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Адрес сервера (API)" Classes="label"/>
|
||||
<TextBox Text="{Binding ApiBaseUrl}" Watermark="https://24pharmdata.ru"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Тема" Classes="label"/>
|
||||
<ComboBox ItemsSource="{Binding Themes}" SelectedIndex="{Binding ThemeIndex}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Грузополучатель по умолчанию" Classes="label"/>
|
||||
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,8,0,0">
|
||||
<Button Content="Отмена" Click="OnCancel" Padding="18,9"/>
|
||||
<Button Classes="primary" Content="Сохранить" Click="OnSave"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
31
src/Elfisa.Avalonia/Views/SettingsWindow.axaml.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using Elfisa.Avalonia.ViewModels;
|
||||
using Elfisa.Core;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is SettingsViewModel vm)
|
||||
{
|
||||
SettingsStore.Current.ApiBaseUrl = string.IsNullOrWhiteSpace(vm.ApiBaseUrl) ? null : vm.ApiBaseUrl.Trim().TrimEnd('/');
|
||||
SettingsStore.Current.Theme = vm.ThemeIndex == 1 ? "Dark" : "Light";
|
||||
SettingsStore.Current.DefaultLocationId = vm.SelectedLocation?.BuyerLocationId;
|
||||
SettingsStore.Save();
|
||||
|
||||
if (Application.Current is { } app)
|
||||
app.RequestedThemeVariant = vm.ThemeIndex == 1 ? ThemeVariant.Dark : ThemeVariant.Light;
|
||||
}
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
95
src/Elfisa.Avalonia/Views/ShellView.axaml
Normal file
@ -0,0 +1,95 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
x:Class="Elfisa.Avalonia.Views.ShellView"
|
||||
x:DataType="vm:ShellViewModel">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- Верхняя панель навигации -->
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource Sidebar}" Height="82">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="18,0">
|
||||
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center" Margin="0,0,14,0">
|
||||
<TextBlock Text="ЭльФиСА" FontSize="18" FontWeight="Bold" Foreground="White"/>
|
||||
<TextBlock Text="Электронная Фармация" Foreground="{DynamicResource SidebarText}" FontSize="10"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListBox Grid.Column="1" Classes="topnav" VerticalAlignment="Center" HorizontalAlignment="Left"
|
||||
ItemsSource="{Binding NavItems}" SelectedItem="{Binding SelectedNav}">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:NavItem">
|
||||
<StackPanel Orientation="Vertical" Width="66" Spacing="3">
|
||||
<TextBlock Text="{Binding Icon}" FontFamily="Segoe MDL2 Assets" FontSize="18"
|
||||
Foreground="White" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Title}" Foreground="{DynamicResource SidebarText}"
|
||||
FontSize="11" HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center" Margin="8,0,0,0">
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding UserName}" Foreground="White" FontSize="12" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding RoleLabel}" Foreground="{DynamicResource SidebarText}"
|
||||
FontSize="10.5" HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
<Button Content="Выйти" Command="{Binding LogoutCommand}"
|
||||
Background="Transparent" Foreground="White" BorderBrush="#55FFFFFF"
|
||||
BorderThickness="1" CornerRadius="8" Padding="16,9" Cursor="Hand"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Строка вкладок-документов -->
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource Surface}"
|
||||
BorderBrush="{DynamicResource Border}" BorderThickness="0,0,0,1" MinHeight="44" Padding="10,0">
|
||||
<ListBox Classes="tabs" VerticalAlignment="Center" x:CompileBindings="False"
|
||||
ItemsSource="{Binding Tabs}" SelectedItem="{Binding ActiveTab}">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DocumentTab">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Title}" VerticalAlignment="Center" FontSize="13"/>
|
||||
<Button Content="✕" FontSize="11" Width="20" Height="20" Padding="0"
|
||||
Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
Foreground="{DynamicResource Muted}"
|
||||
Command="{Binding $parent[ListBox].DataContext.CloseTabCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- Нижняя строка состояния -->
|
||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Surface}"
|
||||
BorderBrush="{DynamicResource Border}" BorderThickness="0,1,0,0" Height="32" Padding="16,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Text="Пользователь:" Classes="muted" FontSize="12"/>
|
||||
<TextBlock Text="{Binding UserName}" FontSize="12" Foreground="{DynamicResource Text}"/>
|
||||
<TextBlock Text=" | Аптека:" Classes="muted" FontSize="12"/>
|
||||
<TextBlock Text="{Binding PharmacyAddress}" FontSize="12" Foreground="{DynamicResource Text}" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Настройки" Click="OnSettingsClick"
|
||||
Background="Transparent" Foreground="{DynamicResource Accent}" BorderThickness="0"
|
||||
Padding="10,4" FontSize="12.5" Cursor="Hand"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Контент активной вкладки -->
|
||||
<ContentControl Content="{Binding ActiveTab.Content}"/>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
21
src/Elfisa.Avalonia/Views/ShellView.axaml.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Elfisa.Avalonia.ViewModels;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class ShellView : UserControl
|
||||
{
|
||||
public ShellView() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private async void OnSettingsClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var win = new SettingsWindow { DataContext = new SettingsViewModel() };
|
||||
var owner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||
if (owner is not null) await win.ShowDialog(owner);
|
||||
else win.Show();
|
||||
}
|
||||
}
|
||||
18
src/Elfisa.Avalonia/app.manifest
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="Elfisa.Avalonia.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
195
src/Elfisa.Core/ApiClient.cs
Normal file
@ -0,0 +1,195 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Elfisa.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Клиент платформы. Портирован из WinForms-версии, оставлены методы,
|
||||
/// нужные новому Avalonia-клиенту: логин, отчёт покупателя, ИИ-отчёт.
|
||||
/// Остальные эндпоинты добавляются по мере портирования экранов.
|
||||
/// </summary>
|
||||
public sealed class ApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = null };
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _baseUrl;
|
||||
private string? _token;
|
||||
|
||||
public ApiClient(string? baseUrl = null)
|
||||
{
|
||||
_baseUrl = (baseUrl ?? AppConfig.ApiBaseUrl).TrimEnd('/');
|
||||
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
|
||||
}
|
||||
|
||||
public string BaseUrl => _baseUrl;
|
||||
public string? Token => _token;
|
||||
public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
|
||||
|
||||
public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token;
|
||||
|
||||
private string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
|
||||
|
||||
/// <summary>Логин. Возвращает (token, role) либо бросает с понятным сообщением.</summary>
|
||||
public async Task<LoginResponse> LoginAsync(string username, string password)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||
catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); }
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new ApiException("Неверный логин или пароль.");
|
||||
if (resp.StatusCode == (HttpStatusCode)429)
|
||||
throw new ApiException("Слишком много запросов. Подождите немного и повторите.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new ApiException(TryError(text) ?? $"Ошибка авторизации ({(int)resp.StatusCode}).");
|
||||
|
||||
var login = JsonSerializer.Deserialize<LoginResponse>(text, Json);
|
||||
if (login is null || string.IsNullOrWhiteSpace(login.Token))
|
||||
throw new ApiException("Сервер вернул пустой токен.");
|
||||
_token = login.Token;
|
||||
return login;
|
||||
}
|
||||
|
||||
/// <summary>Отчёт покупателя за период (скоуп по JWT на сервере — только свои заказы).</summary>
|
||||
public async Task<List<BuyerReportLineDto>> GetBuyerReportAsync(DateTime from, DateTime to)
|
||||
{
|
||||
EnsureAuth();
|
||||
var path = $"/api/buyer/report?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
|
||||
var text = await AuthorizedGetAsync(path, "формирование отчёта").ConfigureAwait(false);
|
||||
var resp = JsonSerializer.Deserialize<BuyerReportResponse>(text, Json);
|
||||
return resp?.Lines ?? new List<BuyerReportLineDto>();
|
||||
}
|
||||
|
||||
/// <summary>Свободный запрос к ИИ -> готовый Word/Excel (сервис на Mac mini через /ai).</summary>
|
||||
public async Task<AiReportResult> GenerateAiReportAsync(string prompt, string format = "docx")
|
||||
{
|
||||
EnsureAuth();
|
||||
if (string.IsNullOrWhiteSpace(prompt)) throw new ApiException("Пустой запрос для ИИ.");
|
||||
var aiUrl = new Uri(new Uri(AppConfig.AiBaseUrl.TrimEnd('/') + "/"), "generate").ToString();
|
||||
var body = JsonSerializer.Serialize(new AiReportRequest { Prompt = prompt, Token = _token!, Format = format }, Json);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, aiUrl)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||
catch (Exception ex) { throw new ApiException($"ИИ-сервис недоступен: {ex.Message}", ex); }
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
throw new ApiException($"ИИ-сервис вернул {(int)resp.StatusCode}: {TryError(err) ?? err}");
|
||||
}
|
||||
var bytes = await resp.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||
var name = resp.Content.Headers.ContentDisposition?.FileNameStar
|
||||
?? resp.Content.Headers.ContentDisposition?.FileName
|
||||
?? (format == "xlsx" ? "Отчёт.xlsx" : "Отчёт.docx");
|
||||
return new AiReportResult { Content = bytes, FileName = name.Trim('"') };
|
||||
}
|
||||
|
||||
/// <summary>Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов).</summary>
|
||||
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string? q = null, int limit = 200, int offset = 0, string? priceListId = null)
|
||||
{
|
||||
EnsureAuth();
|
||||
var path = $"/api/supplier-prices/summary?limit={limit}&offset={offset}";
|
||||
if (!string.IsNullOrWhiteSpace(q) && q.Trim().Length >= 2)
|
||||
path += "&q=" + Uri.EscapeDataString(q.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(priceListId))
|
||||
path += "&price_list_id=" + Uri.EscapeDataString(priceListId);
|
||||
var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
|
||||
}
|
||||
|
||||
/// <summary>Прайс-листы, подключённые текущему покупателю (для выбора «Мои прайсы»).</summary>
|
||||
public async Task<List<BuyerPriceListDto>> GetBuyerPriceListsAsync()
|
||||
{
|
||||
EnsureAuth();
|
||||
var text = await AuthorizedGetAsync("/api/buyer/price-lists", "загрузку прайсов").ConfigureAwait(false);
|
||||
try { return JsonSerializer.Deserialize<List<BuyerPriceListDto>>(text, Json) ?? new(); }
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
/// <summary>Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика.</summary>
|
||||
public async Task<BuyerOrderResponse> CreateOrderAsync(BuyerOrderCreateRequest request)
|
||||
{
|
||||
EnsureAuth();
|
||||
if (request.Items.Count == 0) throw new ApiException("Корзина пуста.");
|
||||
var body = JsonSerializer.Serialize(request, Json);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/api/buyer/orders"))
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||
catch (Exception ex) { throw new ApiException($"Не удалось отправить заказ: {ex.Message}", ex); }
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
|
||||
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка создания заказа ({(int)resp.StatusCode}).");
|
||||
return JsonSerializer.Deserialize<BuyerOrderResponse>(text, Json) ?? new BuyerOrderResponse();
|
||||
}
|
||||
|
||||
/// <summary>Резервирует следующий номер заказа EX-####### (как в WinForms до создания заказа).</summary>
|
||||
public async Task<string?> NextGlobalSignAsync()
|
||||
{
|
||||
EnsureAuth();
|
||||
var text = await AuthorizedGetAsync("/api/buyer/global-sign/next", "резервирование номера").ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return doc.RootElement.TryGetProperty("global_sign", out var v) ? v.GetString() : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Адреса-грузополучатели покупателя.</summary>
|
||||
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
|
||||
{
|
||||
EnsureAuth();
|
||||
var text = await AuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей").ConfigureAwait(false);
|
||||
try { return JsonSerializer.Deserialize<List<BuyerLocationDto>>(text, Json) ?? new(); }
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private async Task<string> AuthorizedGetAsync(string path, string op)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, Url(path));
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||
catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); }
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
|
||||
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка сервера ({(int)resp.StatusCode}) при {op}.");
|
||||
return text;
|
||||
}
|
||||
|
||||
private void EnsureAuth()
|
||||
{
|
||||
if (!IsAuthenticated) throw new ApiException("Требуется авторизация.");
|
||||
}
|
||||
|
||||
private static string? TryError(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
try { return JsonSerializer.Deserialize<ErrorResponse>(body, Json)?.Error; }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ApiException : Exception
|
||||
{
|
||||
public ApiException(string message, Exception? inner = null) : base(message, inner) { }
|
||||
}
|
||||
35
src/Elfisa.Core/AppConfig.cs
Normal file
@ -0,0 +1,35 @@
|
||||
namespace Elfisa.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Базовые адреса платформы. Совпадают с десктопом WinForms:
|
||||
/// API — https://24pharmdata.ru, ИИ-отчёты — https://24pharmdata.ru/ai.
|
||||
/// Можно переопределить переменными окружения.
|
||||
/// </summary>
|
||||
public static class AppConfig
|
||||
{
|
||||
public const string DefaultApiBaseUrl = "https://24pharmdata.ru";
|
||||
public const string DefaultAiBaseUrl = "https://24pharmdata.ru/ai";
|
||||
|
||||
private const string ApiEnv = "ELF_API_BASE_URL";
|
||||
private const string AiEnv = "ELF_AI_BASE_URL";
|
||||
|
||||
public static string ApiBaseUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var fromSettings = SettingsStore.Current.ApiBaseUrl;
|
||||
if (!string.IsNullOrWhiteSpace(fromSettings)) return fromSettings.Trim().TrimEnd('/');
|
||||
var env = Environment.GetEnvironmentVariable(ApiEnv);
|
||||
return string.IsNullOrWhiteSpace(env) ? DefaultApiBaseUrl : env.Trim().TrimEnd('/');
|
||||
}
|
||||
}
|
||||
|
||||
public static string AiBaseUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var env = Environment.GetEnvironmentVariable(AiEnv);
|
||||
return string.IsNullOrWhiteSpace(env) ? DefaultAiBaseUrl : env.Trim().TrimEnd('/');
|
||||
}
|
||||
}
|
||||
}
|
||||