commit 605c6b7d2a6b90af3724b2e2de9a99cdbb9db569 Author: Janusz Szczypka Date: Wed Jul 15 08:27:32 2026 +0000 Initial Enova365 sync service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c017382 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.dotnet/ +.packages/ +NuGet/ +**/bin/ +**/obj/ +*.user +*.suo + +# Lokalna konfiguracja wdrożeniowa; wzorcowa konfiguracja znajduje się w źródłach. +release/config/enova.local.json diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..bf38a86 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + false + + diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..e21afbe --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf3eb68 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# SmartB2B SYNC dla enova365 + +Dedykowana usługa Windows zastępująca proces Node na stanowiskach z enova365. Utrzymuje połączenie WAMP, dodaje zamówienia przez oficjalne API Enovy i wykonuje `eu.smartb2b.sql_raw` na osobnym połączeniu SQL. + +## Najważniejsze właściwości + +- Host nie zawiera bibliotek `Soneta.*`. Adapter ładuje je z katalogu `enova.installationPath` przy każdym starcie procesu. +- Zmiana wersji lub katalogu Enovy wymaga restartu usługi. Start kończy się błędem, jeżeli nowy zestaw DLL nie jest zgodny z adapterem. +- Operacje Enovy są wykonywane pojedynczo i w osobnych sesjach. Zapytania SQL korzystają z puli połączeń i mogą działać równolegle. +- ZO jest zapisywane transakcyjnie, domyślnie do bufora. Powtarzający się numer obcy nie jest blokowany. +- Pierwsza wersja obsługuje PLN, `price_netto` i rabat procentowy `0-100`. + +## Konfiguracja + +Plik `config/enova.json` zawiera portal WAMP, ścieżkę instalacji, alias bazy, operatora, definicję dokumentu, magazyn domyślny i tryb zapisu. + +Sekrety nie są przechowywane w JSON-ie. Konto usługi musi widzieć dwie zmienne środowiskowe maszyny: + +```powershell +[Environment]::SetEnvironmentVariable('SMARTB2B_ENOVA_PASSWORD', '', 'Machine') +[Environment]::SetEnvironmentVariable('SMARTB2B_SQL_CONNECTION_STRING', 'Server=...;Database=...;User ID=...;Password=...;TrustServerCertificate=True', 'Machine') +``` + +Po zmianie zmiennych trzeba zrestartować usługę. Connection string powinien używać konta SQL o możliwie najmniejszych uprawnieniach; `sql_raw` celowo dopuszcza również polecenia modyfikujące dane dla zgodności z obecną usługą. + +## Budowanie i testy + +Z katalogu głównego repozytorium, przy zainstalowanym SDK .NET 8: + +```powershell +dotnet restore .\SmartB2B.EnovaSync.sln --configfile .\NuGet.Config +dotnet build .\SmartB2B.EnovaSync.sln --no-restore +dotnet run --project .\tests\SmartB2B.Enova.Tests --no-build --no-restore +``` + +Ostrzeżenie `NU1603` dotyczące `Soneta.Generator` jest takie samo jak w działającym prototypie `test1` i nie blokuje kompilacji. + +Kontrola konfiguracji i dynamicznego ładowania, bez zapisu do bazy: + +```powershell +.\src\SmartB2B.Enova.Service\bin\Debug\net8.0\SmartB2B.Enova.Service.exe --check-config +``` + +## Publikacja i instalacja + +```powershell +.\publish.ps1 +``` + +Skrypt odtwarza katalog `release` z hostem, adapterem, konfiguracją i WinSW. Istniejąca konfiguracja `release/config/enova.json` jest zachowywana. Repozytorium zawiera również gotowy, skompilowany katalog `release`, więc na komputerze produkcyjnym nie trzeba instalować SDK. Przed instalacją: + +1. Skopiuj katalog `release` na komputer produkcyjny i uzupełnij `release/config/enova.json`. +2. Ustaw sekrety maszyny. +3. Zatrzymaj starą usługę Node dla tego samego portalu WAMP. +4. Skonfiguruj usługę do pracy pod dedykowanym kontem Windows, które potrafi uruchomić Enovę, widzi jej bazę i licencję. +5. Jako administrator uruchom `release/daemon/install.cmd`. + +Jeżeli usługa ma działać pod innym kontem niż `LocalSystem`, po instalacji ustaw je w `services.msc` albo poleceniem administracyjnym `sc.exe config smartb2bsync-enova obj= "DOMENA\\Użytkownik" password= "..."`, a następnie uruchom usługę ponownie. + +Wycofanie polega na uruchomieniu `daemon/uninstall.cmd` i ponownym uruchomieniu dotychczasowej usługi Node. Nie ma migracji schematu bazy. + +## Procedury WAMP + +- `eu.smartb2b.place_order` przyjmuje `currency_iso`, `companyErpId`, `warehouseErpId`, `purchase_order_number`, `notes` i `lines[]` z polami `symbol`, `quantity`, `price_netto`, `discount`. Wynik jest zwracany jako kwargs: `order_erp_id`, `order_erp_symbol`, `value_netto`, `value_brutto`, `stocks`. +- `eu.smartb2b.sql_raw` przyjmuje `query` i opcjonalne `params`; parametry odpowiadają kolejno `@1`, `@2`, itd. Wynik ma pola `recordsets`, `recordset`, `output`, `rowsAffected`. +- Diagnostyka: `eu.smartb2b.sync.get_version`, `get_info`, `get_log`, `get_error_log`. + +Kod produkcyjny, testy oraz gotowa wersja instalacyjna znajdują się w tym repozytorium. diff --git a/SmartB2B.EnovaSync.sln b/SmartB2B.EnovaSync.sln new file mode 100644 index 0000000..f56a7b0 --- /dev/null +++ b/SmartB2B.EnovaSync.sln @@ -0,0 +1,36 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35327.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartB2B.Enova.Contracts", "src\SmartB2B.Enova.Contracts\SmartB2B.Enova.Contracts.csproj", "{83EEA218-71A0-4E45-9572-B53767A3BC26}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartB2B.Enova.Adapter", "src\SmartB2B.Enova.Adapter\SmartB2B.Enova.Adapter.csproj", "{64C5439F-00AA-48F9-9A9B-DAE69DB0BC57}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartB2B.Enova.Service", "src\SmartB2B.Enova.Service\SmartB2B.Enova.Service.csproj", "{3473BD53-A2B9-46A8-AEE8-F087F373DF2B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartB2B.Enova.Tests", "tests\SmartB2B.Enova.Tests\SmartB2B.Enova.Tests.csproj", "{842D10FD-D5FC-4AF9-8D72-619BF6EE10C2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {83EEA218-71A0-4E45-9572-B53767A3BC26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83EEA218-71A0-4E45-9572-B53767A3BC26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83EEA218-71A0-4E45-9572-B53767A3BC26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83EEA218-71A0-4E45-9572-B53767A3BC26}.Release|Any CPU.Build.0 = Release|Any CPU + {64C5439F-00AA-48F9-9A9B-DAE69DB0BC57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64C5439F-00AA-48F9-9A9B-DAE69DB0BC57}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64C5439F-00AA-48F9-9A9B-DAE69DB0BC57}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64C5439F-00AA-48F9-9A9B-DAE69DB0BC57}.Release|Any CPU.Build.0 = Release|Any CPU + {3473BD53-A2B9-46A8-AEE8-F087F373DF2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3473BD53-A2B9-46A8-AEE8-F087F373DF2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3473BD53-A2B9-46A8-AEE8-F087F373DF2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3473BD53-A2B9-46A8-AEE8-F087F373DF2B}.Release|Any CPU.Build.0 = Release|Any CPU + {842D10FD-D5FC-4AF9-8D72-619BF6EE10C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {842D10FD-D5FC-4AF9-8D72-619BF6EE10C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {842D10FD-D5FC-4AF9-8D72-619BF6EE10C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {842D10FD-D5FC-4AF9-8D72-619BF6EE10C2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/daemon/install.cmd b/daemon/install.cmd new file mode 100644 index 0000000..3003f9c --- /dev/null +++ b/daemon/install.cmd @@ -0,0 +1,14 @@ +@echo off +setlocal +cd /d "%~dp0" + +if not exist "smartb2bsync-enova.exe" ( + echo Brak smartb2bsync-enova.exe. Najpierw uruchom publish.ps1. + exit /b 1 +) + +smartb2bsync-enova.exe install +if errorlevel 1 exit /b %errorlevel% + +smartb2bsync-enova.exe start +exit /b %errorlevel% diff --git a/daemon/smartb2bsync-enova.exe b/daemon/smartb2bsync-enova.exe new file mode 100644 index 0000000..a27a1bc Binary files /dev/null and b/daemon/smartb2bsync-enova.exe differ diff --git a/daemon/smartb2bsync-enova.xml b/daemon/smartb2bsync-enova.xml new file mode 100644 index 0000000..753f535 --- /dev/null +++ b/daemon/smartb2bsync-enova.xml @@ -0,0 +1,13 @@ + + smartb2bsync-enova + SmartB2B SYNC - enova365 + Synchronizuje enova365 z portalem SmartB2B. + %BASE%\..\SmartB2B.Enova.Service.exe + %BASE%\.. + 30sec + + yyyyMMdd + + + + diff --git a/daemon/uninstall.cmd b/daemon/uninstall.cmd new file mode 100644 index 0000000..f826240 --- /dev/null +++ b/daemon/uninstall.cmd @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d "%~dp0" + +smartb2bsync-enova.exe stop +smartb2bsync-enova.exe uninstall +exit /b %errorlevel% diff --git a/publish.ps1 b/publish.ps1 new file mode 100644 index 0000000..c98271f --- /dev/null +++ b/publish.ps1 @@ -0,0 +1,59 @@ +param( + [string]$OutputPath = (Join-Path $PSScriptRoot "release"), + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$env:DOTNET_CLI_HOME = $PSScriptRoot +$env:APPDATA = $PSScriptRoot +$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1" +$env:DOTNET_CLI_TELEMETRY_OPTOUT = "1" + +$bundledDotnet = Join-Path $PSScriptRoot "..\..\..\test1\.dotnet\dotnet.exe" +$dotnet = if (Test-Path -LiteralPath $bundledDotnet) { + (Resolve-Path $bundledDotnet).Path +} else { + (Get-Command dotnet -ErrorAction Stop).Source +} +$project = Join-Path $PSScriptRoot "src\SmartB2B.Enova.Service\SmartB2B.Enova.Service.csproj" +$daemonSource = Join-Path $PSScriptRoot "daemon" +$winSwSource = Resolve-Path (Join-Path $daemonSource "smartb2bsync-enova.exe") +$output = [System.IO.Path]::GetFullPath($OutputPath) +$daemonOutput = Join-Path $output "daemon" +$existingConfigurationPath = Join-Path $output "config\enova.json" +$existingConfiguration = if (Test-Path -LiteralPath $existingConfigurationPath) { + [System.IO.File]::ReadAllText($existingConfigurationPath) +} else { + $null +} + +if ($output -eq [System.IO.Path]::GetPathRoot($output) -or $output.Length -lt 10) { + throw "Niebezpieczna ścieżka wyjściowa: $output" +} + +if (Test-Path -LiteralPath $output) { + Remove-Item -LiteralPath $output -Recurse -Force +} + +New-Item -ItemType Directory -Force -Path $output | Out-Null +New-Item -ItemType Directory -Force -Path $daemonOutput | Out-Null + +& $dotnet publish $project -c $Configuration --no-restore -o $output +if ($LASTEXITCODE -ne 0) { + throw "Publikowanie usługi nie powiodło się." +} + +Copy-Item $winSwSource (Join-Path $daemonOutput "smartb2bsync-enova.exe") -Force +Copy-Item (Join-Path $daemonSource "smartb2bsync-enova.xml") $daemonOutput -Force +Copy-Item (Join-Path $daemonSource "install.cmd") $daemonOutput -Force +Copy-Item (Join-Path $daemonSource "uninstall.cmd") $daemonOutput -Force + +if ($null -ne $existingConfiguration) { + [System.IO.File]::WriteAllText( + (Join-Path $output "config\enova.json"), + $existingConfiguration, + [System.Text.UTF8Encoding]::new($false)) +} + +Write-Host "Gotowa usługa: $output" diff --git a/release/Newtonsoft.Json.dll b/release/Newtonsoft.Json.dll new file mode 100644 index 0000000..d035c38 Binary files /dev/null and b/release/Newtonsoft.Json.dll differ diff --git a/release/NewtonsoftMessagePack.dll b/release/NewtonsoftMessagePack.dll new file mode 100644 index 0000000..4a02789 Binary files /dev/null and b/release/NewtonsoftMessagePack.dll differ diff --git a/release/SmartB2B.Enova.Contracts.dll b/release/SmartB2B.Enova.Contracts.dll new file mode 100644 index 0000000..a9d8aec Binary files /dev/null and b/release/SmartB2B.Enova.Contracts.dll differ diff --git a/release/SmartB2B.Enova.Contracts.pdb b/release/SmartB2B.Enova.Contracts.pdb new file mode 100644 index 0000000..b0cc275 Binary files /dev/null and b/release/SmartB2B.Enova.Contracts.pdb differ diff --git a/release/SmartB2B.Enova.Service.deps.json b/release/SmartB2B.Enova.Service.deps.json new file mode 100644 index 0000000..db31af5 --- /dev/null +++ b/release/SmartB2B.Enova.Service.deps.json @@ -0,0 +1,636 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v8.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v8.0": { + "SmartB2B.Enova.Service/1.0.0": { + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "SmartB2B.Enova.Contracts": "1.0.0", + "WampSharp.Default.Client": "23.8.1" + }, + "runtime": { + "SmartB2B.Enova.Service.dll": {} + } + }, + "Microsoft.CSharp/4.3.0": { + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Dynamic.Runtime": "4.3.0", + "System.Globalization": "4.3.0", + "System.Linq": "4.3.0", + "System.Linq.Expressions": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Reflection.TypeExtensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "Microsoft.NETCore.Platforms/1.1.0": {}, + "Microsoft.NETCore.Targets/1.1.0": {}, + "Newtonsoft.Json/13.0.3": { + "runtime": { + "lib/net6.0/Newtonsoft.Json.dll": { + "assemblyVersion": "13.0.0.0", + "fileVersion": "13.0.3.27908" + } + } + }, + "NewtonsoftMessagePack/0.1.11": { + "dependencies": { + "Newtonsoft.Json": "13.0.3" + }, + "runtime": { + "lib/netstandard2.0/NewtonsoftMessagePack.dll": { + "assemblyVersion": "0.0.0.0", + "fileVersion": "0.0.0.0" + } + } + }, + "System.Collections/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Collections.Immutable/1.5.0": {}, + "System.Diagnostics.Debug/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Dynamic.Runtime/4.3.0": { + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Linq": "4.3.0", + "System.Linq.Expressions": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Reflection.TypeExtensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Globalization/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.IO/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Linq/4.3.0": { + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Linq.Expressions/4.3.0": { + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Linq": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Emit.Lightweight": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Reflection.TypeExtensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.ObjectModel/4.3.0": { + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Reactive/4.1.6": { + "dependencies": { + "System.Runtime.InteropServices.WindowsRuntime": "4.3.0", + "System.Threading.Tasks.Extensions": "4.5.2" + }, + "runtime": { + "lib/netstandard2.0/System.Reactive.dll": { + "assemblyVersion": "4.1.0.0", + "fileVersion": "4.1.6.362" + } + } + }, + "System.Reflection/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit/4.3.0": { + "dependencies": { + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit.ILGeneration/4.3.0": { + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit.Lightweight/4.3.0": { + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Extensions/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Primitives/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.TypeExtensions/4.3.0": { + "dependencies": { + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Resources.ResourceManager/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "System.Runtime.Extensions/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.Handles/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.InteropServices/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Runtime.InteropServices.WindowsRuntime/4.3.0": { + "dependencies": { + "System.Runtime": "4.3.0" + }, + "runtime": { + "lib/netstandard1.3/System.Runtime.InteropServices.WindowsRuntime.dll": { + "assemblyVersion": "4.0.2.0", + "fileVersion": "4.6.24705.1" + } + } + }, + "System.Text.Encoding/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Threading/4.3.0": { + "dependencies": { + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Tasks/4.3.0": { + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Threading.Tasks.Dataflow/4.9.0": {}, + "System.Threading.Tasks.Extensions/4.5.2": {}, + "WampSharp/23.8.1": { + "dependencies": { + "Microsoft.CSharp": "4.3.0", + "System.Collections.Immutable": "1.5.0", + "System.Reactive": "4.1.6", + "System.Threading.Tasks.Dataflow": "4.9.0" + }, + "runtime": { + "lib/netstandard2.1/WampSharp.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "WampSharp.Default.Client/23.8.1": { + "dependencies": { + "WampSharp.NewtonsoftJson": "23.8.1", + "WampSharp.NewtonsoftMessagePack": "23.8.1", + "WampSharp.WebSockets": "23.8.1" + }, + "runtime": { + "lib/netstandard2.1/WampSharp.Default.Client.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "WampSharp.NewtonsoftJson/23.8.1": { + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "WampSharp": "23.8.1" + }, + "runtime": { + "lib/netstandard2.1/WampSharp.NewtonsoftJson.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "WampSharp.NewtonsoftMessagePack/23.8.1": { + "dependencies": { + "NewtonsoftMessagePack": "0.1.11", + "WampSharp.NewtonsoftJson": "23.8.1" + }, + "runtime": { + "lib/netstandard2.1/WampSharp.NewtonsoftMessagePack.dll": { + "assemblyVersion": "0.0.0.0", + "fileVersion": "0.0.0.0" + } + } + }, + "WampSharp.WebSockets/23.8.1": { + "dependencies": { + "WampSharp": "23.8.1" + }, + "runtime": { + "lib/netstandard2.1/WampSharp.WebSockets.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "SmartB2B.Enova.Contracts/1.0.0": { + "runtime": { + "SmartB2B.Enova.Contracts.dll": { + "assemblyVersion": "1.0.0", + "fileVersion": "1.0.0.0" + } + } + } + } + }, + "libraries": { + "SmartB2B.Enova.Service/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Microsoft.CSharp/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-P+MBhIM0YX+JqROuf7i306ZLJEjQYA9uUyRDE+OqwUI5sh41e2ZbPQV3LfAPh+29cmceE1pUffXsGfR4eMY3KA==", + "path": "microsoft.csharp/4.3.0", + "hashPath": "microsoft.csharp.4.3.0.nupkg.sha512" + }, + "Microsoft.NETCore.Platforms/1.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==", + "path": "microsoft.netcore.platforms/1.1.0", + "hashPath": "microsoft.netcore.platforms.1.1.0.nupkg.sha512" + }, + "Microsoft.NETCore.Targets/1.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==", + "path": "microsoft.netcore.targets/1.1.0", + "hashPath": "microsoft.netcore.targets.1.1.0.nupkg.sha512" + }, + "Newtonsoft.Json/13.0.3": { + "type": "package", + "serviceable": true, + "sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==", + "path": "newtonsoft.json/13.0.3", + "hashPath": "newtonsoft.json.13.0.3.nupkg.sha512" + }, + "NewtonsoftMessagePack/0.1.11": { + "type": "package", + "serviceable": true, + "sha512": "sha512-CuwKoxWUFRQewUXS0FH2V3hZFrati69OtveyX3GwlDGp6VuvkzaSzSXw7Hi5OiFR3X4QYULlWkwV/jxYYEPacA==", + "path": "newtonsoftmessagepack/0.1.11", + "hashPath": "newtonsoftmessagepack.0.1.11.nupkg.sha512" + }, + "System.Collections/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", + "path": "system.collections/4.3.0", + "hashPath": "system.collections.4.3.0.nupkg.sha512" + }, + "System.Collections.Immutable/1.5.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-EXKiDFsChZW0RjrZ4FYHu9aW6+P4MCgEDCklsVseRfhoO0F+dXeMSsMRAlVXIo06kGJ/zv+2w1a2uc2+kxxSaQ==", + "path": "system.collections.immutable/1.5.0", + "hashPath": "system.collections.immutable.1.5.0.nupkg.sha512" + }, + "System.Diagnostics.Debug/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", + "path": "system.diagnostics.debug/4.3.0", + "hashPath": "system.diagnostics.debug.4.3.0.nupkg.sha512" + }, + "System.Dynamic.Runtime/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-SNVi1E/vfWUAs/WYKhE9+qlS6KqK0YVhnlT0HQtr8pMIA8YX3lwy3uPMownDwdYISBdmAF/2holEIldVp85Wag==", + "path": "system.dynamic.runtime/4.3.0", + "hashPath": "system.dynamic.runtime.4.3.0.nupkg.sha512" + }, + "System.Globalization/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "path": "system.globalization/4.3.0", + "hashPath": "system.globalization.4.3.0.nupkg.sha512" + }, + "System.IO/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", + "path": "system.io/4.3.0", + "hashPath": "system.io.4.3.0.nupkg.sha512" + }, + "System.Linq/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", + "path": "system.linq/4.3.0", + "hashPath": "system.linq.4.3.0.nupkg.sha512" + }, + "System.Linq.Expressions/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", + "path": "system.linq.expressions/4.3.0", + "hashPath": "system.linq.expressions.4.3.0.nupkg.sha512" + }, + "System.ObjectModel/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", + "path": "system.objectmodel/4.3.0", + "hashPath": "system.objectmodel.4.3.0.nupkg.sha512" + }, + "System.Reactive/4.1.6": { + "type": "package", + "serviceable": true, + "sha512": "sha512-mPLRPf2tql0Af4RcJRVPWiGxhJCbPQFNr74Qq0rDf+QXt8KV/SzYl9/NK604zf0s8Yu3hwelmCsDCjqp5SJoJA==", + "path": "system.reactive/4.1.6", + "hashPath": "system.reactive.4.1.6.nupkg.sha512" + }, + "System.Reflection/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", + "path": "system.reflection/4.3.0", + "hashPath": "system.reflection.4.3.0.nupkg.sha512" + }, + "System.Reflection.Emit/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", + "path": "system.reflection.emit/4.3.0", + "hashPath": "system.reflection.emit.4.3.0.nupkg.sha512" + }, + "System.Reflection.Emit.ILGeneration/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", + "path": "system.reflection.emit.ilgeneration/4.3.0", + "hashPath": "system.reflection.emit.ilgeneration.4.3.0.nupkg.sha512" + }, + "System.Reflection.Emit.Lightweight/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", + "path": "system.reflection.emit.lightweight/4.3.0", + "hashPath": "system.reflection.emit.lightweight.4.3.0.nupkg.sha512" + }, + "System.Reflection.Extensions/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", + "path": "system.reflection.extensions/4.3.0", + "hashPath": "system.reflection.extensions.4.3.0.nupkg.sha512" + }, + "System.Reflection.Primitives/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", + "path": "system.reflection.primitives/4.3.0", + "hashPath": "system.reflection.primitives.4.3.0.nupkg.sha512" + }, + "System.Reflection.TypeExtensions/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", + "path": "system.reflection.typeextensions/4.3.0", + "hashPath": "system.reflection.typeextensions.4.3.0.nupkg.sha512" + }, + "System.Resources.ResourceManager/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", + "path": "system.resources.resourcemanager/4.3.0", + "hashPath": "system.resources.resourcemanager.4.3.0.nupkg.sha512" + }, + "System.Runtime/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", + "path": "system.runtime/4.3.0", + "hashPath": "system.runtime.4.3.0.nupkg.sha512" + }, + "System.Runtime.Extensions/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", + "path": "system.runtime.extensions/4.3.0", + "hashPath": "system.runtime.extensions.4.3.0.nupkg.sha512" + }, + "System.Runtime.Handles/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", + "path": "system.runtime.handles/4.3.0", + "hashPath": "system.runtime.handles.4.3.0.nupkg.sha512" + }, + "System.Runtime.InteropServices/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", + "path": "system.runtime.interopservices/4.3.0", + "hashPath": "system.runtime.interopservices.4.3.0.nupkg.sha512" + }, + "System.Runtime.InteropServices.WindowsRuntime/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-J4GUi3xZQLUBasNwZnjrffN8i5wpHrBtZoLG+OhRyGo/+YunMRWWtwoMDlUAIdmX0uRfpHIBDSV6zyr3yf00TA==", + "path": "system.runtime.interopservices.windowsruntime/4.3.0", + "hashPath": "system.runtime.interopservices.windowsruntime.4.3.0.nupkg.sha512" + }, + "System.Text.Encoding/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", + "path": "system.text.encoding/4.3.0", + "hashPath": "system.text.encoding.4.3.0.nupkg.sha512" + }, + "System.Threading/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", + "path": "system.threading/4.3.0", + "hashPath": "system.threading.4.3.0.nupkg.sha512" + }, + "System.Threading.Tasks/4.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", + "path": "system.threading.tasks/4.3.0", + "hashPath": "system.threading.tasks.4.3.0.nupkg.sha512" + }, + "System.Threading.Tasks.Dataflow/4.9.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dTS+3D/GtG2/Pvc3E5YzVvAa7aQJgLDlZDIzukMOJjYudVOQOUXEU68y6Zi3Nn/jqIeB5kOCwrGbQFAKHVzXEQ==", + "path": "system.threading.tasks.dataflow/4.9.0", + "hashPath": "system.threading.tasks.dataflow.4.9.0.nupkg.sha512" + }, + "System.Threading.Tasks.Extensions/4.5.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-BG/TNxDFv0svAzx8OiMXDlsHfGw623BZ8tCXw4YLhDFDvDhNUEV58jKYMGRnkbJNm7c3JNNJDiN7JBMzxRBR2w==", + "path": "system.threading.tasks.extensions/4.5.2", + "hashPath": "system.threading.tasks.extensions.4.5.2.nupkg.sha512" + }, + "WampSharp/23.8.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-khUXgLMNv5KQ9Xzq5HkxGP9kb/5r19UMS7RW8kY+5ua3ev6ZtX1zGUaMUHwTiXVWkb+1wz4B35oHqx/F38sicQ==", + "path": "wampsharp/23.8.1", + "hashPath": "wampsharp.23.8.1.nupkg.sha512" + }, + "WampSharp.Default.Client/23.8.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-RZD8/SOQb/6KOhpxk2Z5IyVh06AJU/v431En2t14VCLD++RU7nSE4fJzNbQTlirbq/tYEVmTiYHILWX9HIEGUg==", + "path": "wampsharp.default.client/23.8.1", + "hashPath": "wampsharp.default.client.23.8.1.nupkg.sha512" + }, + "WampSharp.NewtonsoftJson/23.8.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-4JMyyf5ojLivoZusJ0eTL73p2tDrKR4pS8fy7iiZ4IxNV3D+ZzuVQc8uh6y9qtwXEx9IImrjvRejbphiBw3ERA==", + "path": "wampsharp.newtonsoftjson/23.8.1", + "hashPath": "wampsharp.newtonsoftjson.23.8.1.nupkg.sha512" + }, + "WampSharp.NewtonsoftMessagePack/23.8.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-wD/7AD6ZHyQOMcHuCn69pm4PMRHJL6LAvqAXZuL6/yxP4ASk8qaDf088sL1gjWmxiHSAHFnBsXDSZJzRncDfUA==", + "path": "wampsharp.newtonsoftmessagepack/23.8.1", + "hashPath": "wampsharp.newtonsoftmessagepack.23.8.1.nupkg.sha512" + }, + "WampSharp.WebSockets/23.8.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-sPdCJhkNS7AzQB62/DeG2EjQGud2VmJ5Hd4l1OOn2NmEqHx5bTyUcUUXaiRc4F4JtgRwiBU1Le3kd5SjpUfZsg==", + "path": "wampsharp.websockets/23.8.1", + "hashPath": "wampsharp.websockets.23.8.1.nupkg.sha512" + }, + "SmartB2B.Enova.Contracts/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/release/SmartB2B.Enova.Service.dll b/release/SmartB2B.Enova.Service.dll new file mode 100644 index 0000000..8fad37b Binary files /dev/null and b/release/SmartB2B.Enova.Service.dll differ diff --git a/release/SmartB2B.Enova.Service.exe b/release/SmartB2B.Enova.Service.exe new file mode 100644 index 0000000..f100565 Binary files /dev/null and b/release/SmartB2B.Enova.Service.exe differ diff --git a/release/SmartB2B.Enova.Service.pdb b/release/SmartB2B.Enova.Service.pdb new file mode 100644 index 0000000..b5e340f Binary files /dev/null and b/release/SmartB2B.Enova.Service.pdb differ diff --git a/release/SmartB2B.Enova.Service.runtimeconfig.json b/release/SmartB2B.Enova.Service.runtimeconfig.json new file mode 100644 index 0000000..d784515 --- /dev/null +++ b/release/SmartB2B.Enova.Service.runtimeconfig.json @@ -0,0 +1,13 @@ +{ + "runtimeOptions": { + "tfm": "net8.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "8.0.0" + }, + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false + } + } +} \ No newline at end of file diff --git a/release/System.Reactive.dll b/release/System.Reactive.dll new file mode 100644 index 0000000..d2fef33 Binary files /dev/null and b/release/System.Reactive.dll differ diff --git a/release/System.Runtime.InteropServices.WindowsRuntime.dll b/release/System.Runtime.InteropServices.WindowsRuntime.dll new file mode 100644 index 0000000..cc35b34 Binary files /dev/null and b/release/System.Runtime.InteropServices.WindowsRuntime.dll differ diff --git a/release/WampSharp.Default.Client.dll b/release/WampSharp.Default.Client.dll new file mode 100644 index 0000000..342f23b Binary files /dev/null and b/release/WampSharp.Default.Client.dll differ diff --git a/release/WampSharp.NewtonsoftJson.dll b/release/WampSharp.NewtonsoftJson.dll new file mode 100644 index 0000000..06cef8f Binary files /dev/null and b/release/WampSharp.NewtonsoftJson.dll differ diff --git a/release/WampSharp.NewtonsoftMessagePack.dll b/release/WampSharp.NewtonsoftMessagePack.dll new file mode 100644 index 0000000..62f93cb Binary files /dev/null and b/release/WampSharp.NewtonsoftMessagePack.dll differ diff --git a/release/WampSharp.WebSockets.dll b/release/WampSharp.WebSockets.dll new file mode 100644 index 0000000..3e7c902 Binary files /dev/null and b/release/WampSharp.WebSockets.dll differ diff --git a/release/WampSharp.dll b/release/WampSharp.dll new file mode 100644 index 0000000..b6fe2b4 Binary files /dev/null and b/release/WampSharp.dll differ diff --git a/release/adapters/SmartB2B.Enova.Adapter.dll b/release/adapters/SmartB2B.Enova.Adapter.dll new file mode 100644 index 0000000..e941e30 Binary files /dev/null and b/release/adapters/SmartB2B.Enova.Adapter.dll differ diff --git a/release/config/enova.json b/release/config/enova.json new file mode 100644 index 0000000..c21bbcc --- /dev/null +++ b/release/config/enova.json @@ -0,0 +1,24 @@ +{ + "portal": "demo", + "wamp": { + "serverUrl": "ws://wamp.smartb2b.eu:8080/", + "reconnectDelaySeconds": 10 + }, + "enova": { + "installationPath": "C:\\Program Files (x86)\\Soneta\\enova365 2604.4.4", + "database": "Firma demo", + "operator": "Administrator", + "passwordEnvironmentVariable": "SMARTB2B_ENOVA_PASSWORD", + "documentDefinition": "ZO", + "defaultWarehouseCode": null, + "saveMode": "Buffer" + }, + "sql": { + "connectionStringEnvironmentVariable": "SMARTB2B_SQL_CONNECTION_STRING", + "commandTimeoutSeconds": 30 + }, + "diagnostics": { + "logDirectory": "daemon", + "logPrefix": "smartb2bsync-enova" + } +} diff --git a/release/daemon/install.cmd b/release/daemon/install.cmd new file mode 100644 index 0000000..3003f9c --- /dev/null +++ b/release/daemon/install.cmd @@ -0,0 +1,14 @@ +@echo off +setlocal +cd /d "%~dp0" + +if not exist "smartb2bsync-enova.exe" ( + echo Brak smartb2bsync-enova.exe. Najpierw uruchom publish.ps1. + exit /b 1 +) + +smartb2bsync-enova.exe install +if errorlevel 1 exit /b %errorlevel% + +smartb2bsync-enova.exe start +exit /b %errorlevel% diff --git a/release/daemon/smartb2bsync-enova.exe b/release/daemon/smartb2bsync-enova.exe new file mode 100644 index 0000000..a27a1bc Binary files /dev/null and b/release/daemon/smartb2bsync-enova.exe differ diff --git a/release/daemon/smartb2bsync-enova.xml b/release/daemon/smartb2bsync-enova.xml new file mode 100644 index 0000000..753f535 --- /dev/null +++ b/release/daemon/smartb2bsync-enova.xml @@ -0,0 +1,13 @@ + + smartb2bsync-enova + SmartB2B SYNC - enova365 + Synchronizuje enova365 z portalem SmartB2B. + %BASE%\..\SmartB2B.Enova.Service.exe + %BASE%\.. + 30sec + + yyyyMMdd + + + + diff --git a/release/daemon/uninstall.cmd b/release/daemon/uninstall.cmd new file mode 100644 index 0000000..f826240 --- /dev/null +++ b/release/daemon/uninstall.cmd @@ -0,0 +1,7 @@ +@echo off +setlocal +cd /d "%~dp0" + +smartb2bsync-enova.exe stop +smartb2bsync-enova.exe uninstall +exit /b %errorlevel% diff --git a/src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs b/src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs new file mode 100644 index 0000000..a20e78c --- /dev/null +++ b/src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs @@ -0,0 +1,256 @@ +using System.Reflection; +using SmartB2B.Enova.Contracts; +using Soneta.Business; +using Soneta.Business.App; +using Soneta.CRM; +using Soneta.Handel; +using Soneta.Magazyny; +using Soneta.Start; +using Soneta.Tools; +using Soneta.Towary; +using Soneta.Types; + +namespace SmartB2B.Enova.Adapter; + +public sealed class EnovaOrderAdapter : IEnovaOrderAdapter +{ + private const string CurrencySymbol = "PLN"; + private static readonly object RuntimeLock = new(); + private static bool _runtimeLoaded; + private EnovaAdapterConfiguration? _configuration; + + public EnovaAdapterInfo Initialize(EnovaAdapterConfiguration configuration) + { + ValidateConfiguration(configuration); + EnsureRuntimeLoaded(); + + _ = BusApplication.Instance[configuration.Connection.DatabaseName] + ?? throw new EnovaOperationException( + "eu.smartb2b.erp.database_not_found", + $"Nie znaleziono zarejestrowanej bazy enova365 '{configuration.Connection.DatabaseName}'.", + new Dictionary { ["database"] = configuration.Connection.DatabaseName }); + + _configuration = configuration; + + return new EnovaAdapterInfo( + GetAssemblyVersion(typeof(EnovaOrderAdapter).Assembly), + GetAssemblyVersion(typeof(Session).Assembly), + GetAssemblyVersion(typeof(HandelModule).Assembly), + configuration.Connection.DatabaseName); + } + + public OrderResult CreateOrder(OrderRequest request) + { + var configuration = _configuration + ?? throw new InvalidOperationException("Adapter Enovy nie został zainicjalizowany."); + + var errors = OrderValidator.Validate(request); + if (errors.Count > 0) + { + throw new EnovaOperationException( + "eu.smartb2b.erp.invalid_order_lines", + string.Join(" | ", errors), + new Dictionary { ["errors"] = errors }); + } + + try + { + var sessionState = SessionState.Create(); + using var attachedSessionState = sessionState.Attach(); + + var database = BusApplication.Instance[configuration.Connection.DatabaseName] + ?? throw new EnovaOperationException( + "eu.smartb2b.erp.database_not_found", + $"Nie znaleziono zarejestrowanej bazy enova365 '{configuration.Connection.DatabaseName}'."); + + using var login = database.Login( + winAuth: false, + user: configuration.Connection.OperatorName, + password: configuration.Connection.Password); + using var session = login.CreateSession( + readOnly: false, + config: false, + name: "SmartB2B Enova Sync"); + + return CreateOrderInSession(session, request); + } + catch (EnovaOperationException) + { + throw; + } + catch (Exception exception) + { + throw new EnovaOperationException( + "eu.smartb2b.erp.error", + $"Nie udało się utworzyć zamówienia w bazie '{configuration.Connection.DatabaseName}': {exception.Message}", + new Dictionary { ["message"] = exception.Message }, + exception); + } + } + + private static OrderResult CreateOrderInSession(Session session, OrderRequest request) + { + var handel = HandelModule.GetInstance(session); + var crm = CRMModule.GetInstance(session); + var towary = TowaryModule.GetInstance(session); + var warehouses = MagazynyModule.GetInstance(session).Magazyny; + + var definition = handel.DefDokHandlowych.WgSymbolu[request.DocumentDefinition] + ?? throw new EnovaOperationException( + "eu.smartb2b.erp.document_definition_not_found", + $"Nie znaleziono definicji dokumentu '{request.DocumentDefinition}'.", + new Dictionary { ["documentDefinition"] = request.DocumentDefinition }); + var customer = crm.Kontrahenci.WgKodu[request.CustomerCode] + ?? throw new EnovaOperationException( + "eu.smartb2b.company_not_found", + $"Nie znaleziono kontrahenta o kodzie '{request.CustomerCode}'.", + new Dictionary { ["companyErpId"] = request.CustomerCode }); + var warehouse = string.IsNullOrWhiteSpace(request.WarehouseCode) + ? warehouses.StandardowyMagazyn + : warehouses.GetGrantedView() + .Cast() + .FirstOrDefault(candidate => string.Equals( + candidate.Symbol, + request.WarehouseCode.Trim(), + StringComparison.OrdinalIgnoreCase)); + + if (warehouse is null) + { + throw new EnovaOperationException( + "eu.smartb2b.erp.warehouse_not_found", + string.IsNullOrWhiteSpace(request.WarehouseCode) + ? "Nie znaleziono standardowego magazynu firmy." + : $"Nie znaleziono magazynu o symbolu '{request.WarehouseCode.Trim()}'.", + new Dictionary { ["warehouseErpId"] = request.WarehouseCode }); + } + + DokumentHandlowy document; + + using (var transaction = session.Logout(editMode: true)) + { + document = session.AddRow(new DokumentHandlowy()); + document.Definicja = definition; + document.Kontrahent = customer; + document.Magazyn = warehouse; + + if (!string.IsNullOrWhiteSpace(request.CustomerReference)) + { + document.Obcy.Numer = request.CustomerReference.Trim(); + } + + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + document.Opis.Clear(); + document.Opis.Add(request.Notes.Trim()); + } + + foreach (var line in request.Items) + { + var product = towary.Towary.WgKodu[line.ProductCode] + ?? throw new EnovaOperationException( + "eu.smartb2b.product_not_found", + $"Nie znaleziono towaru o kodzie '{line.ProductCode}'.", + new Dictionary { ["symbol"] = line.ProductCode }); + + var position = session.AddRow(new PozycjaDokHandlowego(document)); + position.Towar = product; + position.Ilosc = new Quantity((double)line.Quantity); + position.Cena = new DoubleCy(line.UnitPrice, CurrencySymbol); + position.UstawRabat(new Percent(line.Discount / 100m), ręcznie: true); + } + + document.Stan = request.SaveMode switch + { + OrderSaveMode.Buffer => StanDokumentuHandlowego.Bufor, + OrderSaveMode.Approved => StanDokumentuHandlowego.Zatwierdzony, + _ => throw new EnovaOperationException( + "eu.smartb2b.erp.invalid_order", + $"Nieobsługiwany tryb zapisu: {request.SaveMode}.") + }; + + transaction.Commit(); + } + + session.Save(); + + return new OrderResult( + document.ID, + document.Numer.Pelny.ToString(), + request.SaveMode, + customer.Kod, + warehouse.Symbol, + document.Obcy.Numer, + request.Items.Count, + document.Suma.Netto, + document.Suma.VAT, + document.Suma.Brutto, + request.Items.Any(item => item.Discount > 0)); + } + + private static void EnsureRuntimeLoaded() + { + if (_runtimeLoaded) + { + return; + } + + lock (RuntimeLock) + { + if (_runtimeLoaded) + { + return; + } + + var loader = new Loader + { + WithUI = false, + WithNet = false, + WithExtra = false, + WithExtensions = false, + CheckWinForms = false + }; + + loader.UseCurrentDllsAsInstallation(standard: true); + loader.Load(); + + var errors = Loader.GetListOfErrors(); + if (errors.Count > 0) + { + throw new EnovaOperationException( + "eu.smartb2b.erp.runtime_error", + $"Loader enova365 zgłosił błędy: {string.Join(" | ", errors)}"); + } + + _runtimeLoaded = true; + } + } + + private static void ValidateConfiguration(EnovaAdapterConfiguration configuration) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(configuration.Connection.DatabaseName)) + { + errors.Add("Nazwa bazy enova365 jest wymagana."); + } + + if (string.IsNullOrWhiteSpace(configuration.Connection.OperatorName)) + { + errors.Add("Nazwa operatora enova365 jest wymagana."); + } + + if (string.IsNullOrWhiteSpace(configuration.DocumentDefinition)) + { + errors.Add("Definicja dokumentu jest wymagana."); + } + + if (errors.Count > 0) + { + throw new EnovaOperationException( + "eu.smartb2b.erp.configuration_error", + string.Join(" | ", errors)); + } + } + + private static string GetAssemblyVersion(Assembly assembly) => + assembly.GetName().Version?.ToString() ?? "unknown"; +} diff --git a/src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj b/src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj new file mode 100644 index 0000000..6d725db --- /dev/null +++ b/src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj @@ -0,0 +1,17 @@ + + + net8.0 + enable + enable + true + + + + + + + + + + + diff --git a/src/SmartB2B.Enova.Contracts/EnovaContracts.cs b/src/SmartB2B.Enova.Contracts/EnovaContracts.cs new file mode 100644 index 0000000..ffcbefa --- /dev/null +++ b/src/SmartB2B.Enova.Contracts/EnovaContracts.cs @@ -0,0 +1,154 @@ +namespace SmartB2B.Enova.Contracts; + +public enum OrderSaveMode +{ + Buffer, + Approved +} + +public sealed record EnovaConnectionOptions( + string DatabaseName, + string OperatorName, + string Password); + +public sealed record EnovaAdapterConfiguration( + EnovaConnectionOptions Connection, + string DocumentDefinition, + string? DefaultWarehouseCode, + OrderSaveMode SaveMode); + +public sealed class OrderRequest +{ + public string CustomerCode { get; init; } = string.Empty; + + public string? CustomerReference { get; init; } + + public string? Notes { get; init; } + + public string DocumentDefinition { get; init; } = "ZO"; + + public string? WarehouseCode { get; init; } + + public OrderSaveMode SaveMode { get; init; } = OrderSaveMode.Buffer; + + public IReadOnlyList Items { get; init; } = []; +} + +public sealed class OrderLineRequest +{ + public string ProductCode { get; init; } = string.Empty; + + public decimal Quantity { get; init; } + + public decimal UnitPrice { get; init; } + + public decimal Discount { get; init; } +} + +public sealed record OrderResult( + int Id, + string Number, + OrderSaveMode SaveMode, + string CustomerCode, + string WarehouseCode, + string? CustomerReference, + int LineCount, + decimal Net, + decimal Vat, + decimal Gross, + bool HasDiscount); + +public sealed record EnovaAdapterInfo( + string AdapterVersion, + string SonetaBusinessVersion, + string SonetaHandelVersion, + string DatabaseName); + +public interface IEnovaOrderAdapter +{ + EnovaAdapterInfo Initialize(EnovaAdapterConfiguration configuration); + + OrderResult CreateOrder(OrderRequest request); +} + +public sealed class EnovaOperationException : Exception +{ + public EnovaOperationException( + string errorUri, + string message, + IReadOnlyDictionary? details = null, + Exception? innerException = null) + : base(message, innerException) + { + ErrorUri = errorUri; + Details = details ?? new Dictionary(); + } + + public string ErrorUri { get; } + + public IReadOnlyDictionary Details { get; } +} + +public static class OrderValidator +{ + public static IReadOnlyList Validate(OrderRequest? request) + { + var errors = new List(); + + if (request is null) + { + errors.Add("Brak danych zamówienia."); + return errors; + } + + if (string.IsNullOrWhiteSpace(request.CustomerCode)) + { + errors.Add("Pole companyErpId jest wymagane."); + } + + if (string.IsNullOrWhiteSpace(request.DocumentDefinition)) + { + errors.Add("Definicja dokumentu jest wymagana."); + } + + if (request.Items is null || request.Items.Count == 0) + { + errors.Add("Zamówienie musi zawierać co najmniej jedną pozycję."); + return errors; + } + + for (var index = 0; index < request.Items.Count; index++) + { + var line = request.Items[index]; + var prefix = $"lines[{index}]"; + + if (line is null) + { + errors.Add($"{prefix}: pozycja nie może być pusta."); + continue; + } + + if (string.IsNullOrWhiteSpace(line.ProductCode)) + { + errors.Add($"{prefix}.symbol jest wymagane."); + } + + if (line.Quantity <= 0) + { + errors.Add($"{prefix}.quantity musi być większe od zera."); + } + + if (line.UnitPrice < 0) + { + errors.Add($"{prefix}.price_netto nie może być ujemne."); + } + + if (line.Discount is < 0 or > 100) + { + errors.Add($"{prefix}.discount musi mieścić się w zakresie 0-100."); + } + } + + return errors; + } +} diff --git a/src/SmartB2B.Enova.Contracts/SmartB2B.Enova.Contracts.csproj b/src/SmartB2B.Enova.Contracts/SmartB2B.Enova.Contracts.csproj new file mode 100644 index 0000000..e8cd599 --- /dev/null +++ b/src/SmartB2B.Enova.Contracts/SmartB2B.Enova.Contracts.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/src/SmartB2B.Enova.Service/Configuration/ServiceSettings.cs b/src/SmartB2B.Enova.Service/Configuration/ServiceSettings.cs new file mode 100644 index 0000000..5b52f24 --- /dev/null +++ b/src/SmartB2B.Enova.Service/Configuration/ServiceSettings.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SmartB2B.Enova.Contracts; + +namespace SmartB2B.Enova.Service.Configuration; + +public sealed class ServiceSettings +{ + public string Portal { get; init; } = string.Empty; + + public WampSettings Wamp { get; init; } = new(); + + public EnovaSettings Enova { get; init; } = new(); + + public SqlSettings Sql { get; init; } = new(); + + public DiagnosticsSettings Diagnostics { get; init; } = new(); + + public static ServiceSettings Load(string path) + { + if (!File.Exists(path)) + { + throw new ConfigurationException($"Nie znaleziono pliku konfiguracji: {path}"); + } + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter() } + }; + + ServiceSettings settings; + try + { + settings = JsonSerializer.Deserialize(File.ReadAllText(path), options) + ?? throw new ConfigurationException("Plik konfiguracji jest pusty."); + } + catch (JsonException exception) + { + throw new ConfigurationException($"Niepoprawny JSON konfiguracji: {exception.Message}", exception); + } + + settings.Validate(); + return settings; + } + + public RuntimeSettings ResolveRuntimeSettings() + { + var enovaPassword = Environment.GetEnvironmentVariable(Enova.PasswordEnvironmentVariable); + if (enovaPassword is null) + { + throw new ConfigurationException( + $"Brak zmiennej środowiskowej {Enova.PasswordEnvironmentVariable} z hasłem operatora Enovy."); + } + + var connectionString = Environment.GetEnvironmentVariable(Sql.ConnectionStringEnvironmentVariable); + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ConfigurationException( + $"Brak zmiennej środowiskowej {Sql.ConnectionStringEnvironmentVariable} z connection stringiem SQL."); + } + + var installationPath = Path.GetFullPath( + Environment.ExpandEnvironmentVariables(Enova.InstallationPath)); + + return new RuntimeSettings( + installationPath, + new EnovaAdapterConfiguration( + new EnovaConnectionOptions(Enova.Database, Enova.Operator, enovaPassword), + Enova.DocumentDefinition, + Enova.DefaultWarehouseCode, + Enova.SaveMode), + connectionString, + Sql.CommandTimeoutSeconds, + ResolvePath(Diagnostics.LogDirectory), + Diagnostics.LogPrefix); + } + + private void Validate() + { + var errors = new List(); + Require(Portal, "portal", errors); + Require(Wamp.ServerUrl, "wamp.serverUrl", errors); + if (Wamp.ReconnectDelaySeconds <= 0) + { + errors.Add("wamp.reconnectDelaySeconds musi być większe od zera."); + } + Require(Enova.InstallationPath, "enova.installationPath", errors); + Require(Enova.Database, "enova.database", errors); + Require(Enova.Operator, "enova.operator", errors); + Require(Enova.PasswordEnvironmentVariable, "enova.passwordEnvironmentVariable", errors); + Require(Enova.DocumentDefinition, "enova.documentDefinition", errors); + Require(Sql.ConnectionStringEnvironmentVariable, "sql.connectionStringEnvironmentVariable", errors); + if (Sql.CommandTimeoutSeconds <= 0) + { + errors.Add("sql.commandTimeoutSeconds musi być większe od zera."); + } + + if (errors.Count > 0) + { + throw new ConfigurationException(string.Join(Environment.NewLine, errors)); + } + } + + private static void Require(string? value, string name, ICollection errors) + { + if (string.IsNullOrWhiteSpace(value)) + { + errors.Add($"Parametr '{name}' jest wymagany."); + } + } + + private static string ResolvePath(string path) => + Path.IsPathRooted(path) ? Path.GetFullPath(path) : Path.GetFullPath(path, AppContext.BaseDirectory); +} + +public sealed class WampSettings +{ + public string ServerUrl { get; init; } = string.Empty; + + public int ReconnectDelaySeconds { get; init; } = 10; +} + +public sealed class EnovaSettings +{ + public string InstallationPath { get; init; } = string.Empty; + + public string Database { get; init; } = string.Empty; + + public string Operator { get; init; } = "Administrator"; + + public string PasswordEnvironmentVariable { get; init; } = "SMARTB2B_ENOVA_PASSWORD"; + + public string DocumentDefinition { get; init; } = "ZO"; + + public string? DefaultWarehouseCode { get; init; } + + public OrderSaveMode SaveMode { get; init; } = OrderSaveMode.Buffer; +} + +public sealed class SqlSettings +{ + public string ConnectionStringEnvironmentVariable { get; init; } = "SMARTB2B_SQL_CONNECTION_STRING"; + + public int CommandTimeoutSeconds { get; init; } = 30; +} + +public sealed class DiagnosticsSettings +{ + public string LogDirectory { get; init; } = "daemon"; + + public string LogPrefix { get; init; } = "smartb2bsync-enova"; +} + +public sealed record RuntimeSettings( + string EnovaInstallationPath, + EnovaAdapterConfiguration EnovaConfiguration, + string SqlConnectionString, + int SqlCommandTimeoutSeconds, + string LogDirectory, + string LogPrefix); + +public sealed class ConfigurationException : Exception +{ + public ConfigurationException(string message, Exception? innerException = null) + : base(message, innerException) + { + } +} diff --git a/src/SmartB2B.Enova.Service/Diagnostics/DiagnosticsHandler.cs b/src/SmartB2B.Enova.Service/Diagnostics/DiagnosticsHandler.cs new file mode 100644 index 0000000..937d47c --- /dev/null +++ b/src/SmartB2B.Enova.Service/Diagnostics/DiagnosticsHandler.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using SmartB2B.Enova.Contracts; +using SmartB2B.Enova.Service.Rpc; + +namespace SmartB2B.Enova.Service.Diagnostics; + +public sealed partial class DiagnosticsHandler +{ + private readonly EnovaAdapterInfo _adapterInfo; + private readonly string _enovaPath; + private readonly string _logDirectory; + private readonly string _logPrefix; + + public DiagnosticsHandler( + EnovaAdapterInfo adapterInfo, + string enovaPath, + string logDirectory, + string logPrefix) + { + _adapterInfo = adapterInfo; + _enovaPath = enovaPath; + _logDirectory = logDirectory; + _logPrefix = logPrefix; + } + + public Task GetVersionAsync(RpcInvocation _, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown"; + return Task.FromResult(version); + } + + public Task GetInfoAsync(RpcInvocation _, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var serviceVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown"; + var lines = new[] + { + $"SmartB2B Enova Sync version: {serviceVersion}", + $".NET version: {Environment.Version}", + $"Adapter version: {_adapterInfo.AdapterVersion}", + $"Soneta.Business version: {_adapterInfo.SonetaBusinessVersion}", + $"Soneta.Handel version: {_adapterInfo.SonetaHandelVersion}", + $"Enova path: {_enovaPath}", + $"Database: {_adapterInfo.DatabaseName}" + }; + return Task.FromResult(string.Join(Environment.NewLine, lines)); + } + + public Task GetLogAsync(RpcInvocation invocation, CancellationToken cancellationToken) => + ReadLogAsync(invocation, "out", cancellationToken); + + public Task GetErrorLogAsync(RpcInvocation invocation, CancellationToken cancellationToken) => + ReadLogAsync(invocation, "err", cancellationToken); + + private async Task ReadLogAsync( + RpcInvocation invocation, + string stream, + CancellationToken cancellationToken) + { + var date = invocation.Get("date") ?? DateTime.UtcNow.ToString("yyyy-MM-dd"); + if (!DatePattern().IsMatch(date)) + { + throw WampErrorFactory.Create( + "eu.smartb2b.sync.invalid_date", + "Data musi mieć format YYYY-MM-DD.", + new Dictionary { ["date"] = date }); + } + + var compactDate = date.Replace("-", string.Empty, StringComparison.Ordinal); + var path = Path.Combine(_logDirectory, $"{_logPrefix}_{compactDate}.{stream}.log"); + if (!File.Exists(path)) + { + return null; + } + + return await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + } + + [GeneratedRegex("^\\d{4}-\\d{2}-\\d{2}$", RegexOptions.CultureInvariant)] + private static partial Regex DatePattern(); +} diff --git a/src/SmartB2B.Enova.Service/Orders/PlaceOrderHandler.cs b/src/SmartB2B.Enova.Service/Orders/PlaceOrderHandler.cs new file mode 100644 index 0000000..1acb221 --- /dev/null +++ b/src/SmartB2B.Enova.Service/Orders/PlaceOrderHandler.cs @@ -0,0 +1,109 @@ +using Newtonsoft.Json; +using SmartB2B.Enova.Contracts; +using SmartB2B.Enova.Service.Rpc; + +namespace SmartB2B.Enova.Service.Orders; + +public sealed class PlaceOrderHandler +{ + private readonly IEnovaOrderAdapter _adapter; + private readonly EnovaAdapterConfiguration _configuration; + private readonly SemaphoreSlim _orderLock = new(1, 1); + + public PlaceOrderHandler(IEnovaOrderAdapter adapter, EnovaAdapterConfiguration configuration) + { + _adapter = adapter; + _configuration = configuration; + } + + public async Task HandleAsync(RpcInvocation invocation, CancellationToken cancellationToken) + { + if (!invocation.Contains("currency_iso")) + { + throw WampErrorFactory.Create("eu.smartb2b.missing_currency", "Pole currency_iso jest wymagane."); + } + + var currency = invocation.Get("currency_iso")?.Trim(); + if (!string.Equals(currency, "PLN", StringComparison.OrdinalIgnoreCase)) + { + throw WampErrorFactory.Create( + "eu.smartb2b.erp.currency_not_supported", + $"Waluta '{currency}' nie jest obsługiwana. Wymagana waluta: PLN.", + new Dictionary { ["currency_iso"] = currency }); + } + + var inputLines = invocation.Get("lines", []) ?? []; + var request = new OrderRequest + { + CustomerCode = invocation.Get("companyErpId") ?? string.Empty, + CustomerReference = invocation.Get("purchase_order_number"), + Notes = invocation.Get("notes"), + DocumentDefinition = _configuration.DocumentDefinition, + WarehouseCode = invocation.Get("warehouseErpId") ?? _configuration.DefaultWarehouseCode, + SaveMode = _configuration.SaveMode, + Items = inputLines.Select(line => new OrderLineRequest + { + ProductCode = line.Symbol ?? string.Empty, + Quantity = line.Quantity, + UnitPrice = line.PriceNetto, + Discount = line.Discount + }).ToArray() + }; + + var errors = OrderValidator.Validate(request); + if (errors.Count > 0) + { + throw WampErrorFactory.Create( + "eu.smartb2b.erp.invalid_order_lines", + string.Join(" | ", errors), + new Dictionary { ["errors"] = errors }); + } + + await _orderLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + Console.WriteLine( + $"Tworzenie ZO: kontrahent={request.CustomerCode}, pozycje={request.Items.Count}, referencja={request.CustomerReference ?? "(brak)"}."); + + OrderResult result; + try + { + result = await Task.Run(() => _adapter.CreateOrder(request), cancellationToken) + .ConfigureAwait(false); + } + catch (EnovaOperationException exception) + { + throw WampErrorFactory.FromEnova(exception); + } + + Console.WriteLine($"Utworzono ZO {result.Number} (ID: {result.Id})."); + return new KeywordResult(new Dictionary + { + ["order_erp_id"] = result.Id, + ["order_erp_symbol"] = result.Number, + ["value_netto"] = result.Net, + ["value_brutto"] = result.Gross, + ["stocks"] = Array.Empty() + }); + } + finally + { + _orderLock.Release(); + } + } +} + +public sealed class PlaceOrderLine +{ + [JsonProperty("symbol")] + public string? Symbol { get; init; } + + [JsonProperty("quantity")] + public decimal Quantity { get; init; } + + [JsonProperty("price_netto")] + public decimal PriceNetto { get; init; } + + [JsonProperty("discount")] + public decimal Discount { get; init; } +} diff --git a/src/SmartB2B.Enova.Service/Program.cs b/src/SmartB2B.Enova.Service/Program.cs new file mode 100644 index 0000000..df511b8 --- /dev/null +++ b/src/SmartB2B.Enova.Service/Program.cs @@ -0,0 +1,120 @@ +using SmartB2B.Enova.Service.Configuration; +using SmartB2B.Enova.Service.Diagnostics; +using SmartB2B.Enova.Service.Orders; +using SmartB2B.Enova.Service.Rpc; +using SmartB2B.Enova.Service.Runtime; +using SmartB2B.Enova.Service.Sql; +using WampSharp.V2.Rpc; + +namespace SmartB2B.Enova.Service; + +internal static class Program +{ + public static async Task Main(string[] args) + { + var cancellation = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + eventArgs.Cancel = true; + cancellation.Cancel(); + }; + AppDomain.CurrentDomain.ProcessExit += (_, _) => cancellation.Cancel(); + + try + { + var commandLine = CommandLineOptions.Parse(args); + var settings = ServiceSettings.Load(commandLine.ConfigurationPath); + var runtime = settings.ResolveRuntimeSettings(); + + using var adapterLoader = new EnovaAdapterLoader(runtime.EnovaInstallationPath); + var adapter = adapterLoader.Load(); + var adapterInfo = adapter.Initialize(runtime.EnovaConfiguration); + + _ = new System.Data.Common.DbConnectionStringBuilder + { + ConnectionString = runtime.SqlConnectionString + }; + var sql = new SqlRawHandler( + runtime.SqlConnectionString, + runtime.SqlCommandTimeoutSeconds); + + Console.WriteLine( + $"Załadowano Enovę {adapterInfo.SonetaBusinessVersion} z '{runtime.EnovaInstallationPath}'."); + Console.WriteLine($"Baza: {adapterInfo.DatabaseName}; portal WAMP: {settings.Portal}."); + + if (commandLine.CheckConfigurationOnly) + { + Console.WriteLine("Konfiguracja i adapter Enovy są poprawne."); + return 0; + } + + var orders = new PlaceOrderHandler(adapter, runtime.EnovaConfiguration); + var diagnostics = new DiagnosticsHandler( + adapterInfo, + runtime.EnovaInstallationPath, + runtime.LogDirectory, + runtime.LogPrefix); + + IReadOnlyList operations = + [ + new DelegateRpcOperation("eu.smartb2b.place_order", orders.HandleAsync), + new DelegateRpcOperation("eu.smartb2b.sql_raw", sql.HandleAsync), + new DelegateRpcOperation("eu.smartb2b.sync.get_version", diagnostics.GetVersionAsync), + new DelegateRpcOperation("eu.smartb2b.sync.get_info", diagnostics.GetInfoAsync), + new DelegateRpcOperation("eu.smartb2b.sync.get_log", diagnostics.GetLogAsync), + new DelegateRpcOperation("eu.smartb2b.sync.get_error_log", diagnostics.GetErrorLogAsync) + ]; + + var wampClient = new WampServiceClient( + settings.Wamp.ServerUrl, + settings.Portal, + TimeSpan.FromSeconds(settings.Wamp.ReconnectDelaySeconds), + operations); + + await wampClient.RunAsync(cancellation.Token).ConfigureAwait(false); + Console.WriteLine("SmartB2B Enova Sync zatrzymany."); + return 0; + } + catch (ConfigurationException exception) + { + Console.Error.WriteLine($"Błąd konfiguracji: {exception.Message}"); + return 2; + } + catch (Exception exception) + { + Console.Error.WriteLine($"Błąd krytyczny: {exception}"); + return 1; + } + } +} + +internal sealed record CommandLineOptions(string ConfigurationPath, bool CheckConfigurationOnly) +{ + public static CommandLineOptions Parse(string[] args) + { + var path = Path.Combine(AppContext.BaseDirectory, "config", "enova.json"); + var checkOnly = false; + + for (var index = 0; index < args.Length; index++) + { + switch (args[index]) + { + case "--config": + if (++index >= args.Length || string.IsNullOrWhiteSpace(args[index])) + { + throw new ConfigurationException("Argument --config wymaga ścieżki."); + } + + path = Path.GetFullPath(args[index]); + break; + case "--check-config": + checkOnly = true; + break; + default: + throw new ConfigurationException($"Nieznany argument: {args[index]}"); + } + } + + return new CommandLineOptions(path, checkOnly); + } +} diff --git a/src/SmartB2B.Enova.Service/Rpc/DelegateRpcOperation.cs b/src/SmartB2B.Enova.Service/Rpc/DelegateRpcOperation.cs new file mode 100644 index 0000000..6de62e3 --- /dev/null +++ b/src/SmartB2B.Enova.Service/Rpc/DelegateRpcOperation.cs @@ -0,0 +1,130 @@ +using WampSharp.Core.Serialization; +using WampSharp.V2.Core.Contracts; +using WampSharp.V2.Rpc; + +namespace SmartB2B.Enova.Service.Rpc; + +public sealed class DelegateRpcOperation : IWampRpcOperation +{ + private readonly Func> _handler; + + public DelegateRpcOperation( + string procedure, + Func> handler) + { + Procedure = procedure; + _handler = handler; + } + + public string Procedure { get; } + + public IWampCancellableInvocation Invoke( + IWampRawRpcOperationRouterCallback caller, + IWampFormatter formatter, + InvocationDetails details) => + Invoke(caller, formatter, details, [], new Dictionary()); + + public IWampCancellableInvocation Invoke( + IWampRawRpcOperationRouterCallback caller, + IWampFormatter formatter, + InvocationDetails details, + TMessage[] arguments) => + Invoke(caller, formatter, details, arguments, new Dictionary()); + + public IWampCancellableInvocation Invoke( + IWampRawRpcOperationRouterCallback caller, + IWampFormatter formatter, + InvocationDetails details, + TMessage[] arguments, + IDictionary argumentsKeywords) + { + var cancellation = new CancellationTokenSource(); + _ = ExecuteAsync( + caller, + formatter, + arguments ?? [], + argumentsKeywords ?? new Dictionary(), + cancellation.Token); + return new CancellableInvocation(cancellation); + } + + private async Task ExecuteAsync( + IWampRawRpcOperationRouterCallback caller, + IWampFormatter formatter, + TMessage[] arguments, + IDictionary argumentsKeywords, + CancellationToken cancellationToken) + { + try + { + var positional = arguments.Select(formatter.Deserialize).ToArray(); + var keywords = argumentsKeywords.ToDictionary( + pair => pair.Key, + pair => (object?)formatter.Deserialize(pair.Value), + StringComparer.OrdinalIgnoreCase); + + var result = await _handler(new RpcInvocation(positional, keywords), cancellationToken) + .ConfigureAwait(false); + var yieldOptions = new YieldOptions(); + + if (result is KeywordResult keywordResult) + { + var serializedKeywords = keywordResult.Values.ToDictionary( + pair => pair.Key, + pair => formatter.Serialize(pair.Value), + StringComparer.Ordinal); + caller.Result(formatter, yieldOptions, [], serializedKeywords); + } + else if (result is null) + { + caller.Result(formatter, yieldOptions); + } + else + { + caller.Result(formatter, yieldOptions, [formatter.Serialize(result)]); + } + } + catch (WampException exception) + { + var details = formatter.Serialize(exception.Details); + var errorArguments = exception.Arguments.Select(formatter.Serialize).ToArray(); + var keywordArguments = formatter.Serialize(exception.ArgumentsKeywords); + caller.Error(formatter, details, exception.ErrorUri, errorArguments, keywordArguments); + } + catch (OperationCanceledException) + { + var details = formatter.Serialize(new Dictionary()); + caller.Error(formatter, details, "wamp.error.canceled"); + } + catch (Exception exception) + { + Console.Error.WriteLine($"Nieobsłużony błąd procedury {Procedure}: {exception}"); + var details = formatter.Serialize(new Dictionary()); + var keywordArguments = formatter.Serialize(new Dictionary + { + ["message"] = exception.Message + }); + caller.Error( + formatter, + details, + "eu.smartb2b.error", + Array.Empty(), + keywordArguments); + } + } + + private sealed class CancellableInvocation : IWampCancellableInvocation + { + private readonly CancellationTokenSource _cancellation; + + public CancellableInvocation(CancellationTokenSource cancellation) + { + _cancellation = cancellation; + } + + public void Cancel(InterruptDetails details) + { + _cancellation.Cancel(); + } + } +} diff --git a/src/SmartB2B.Enova.Service/Rpc/RpcInvocation.cs b/src/SmartB2B.Enova.Service/Rpc/RpcInvocation.cs new file mode 100644 index 0000000..5ef8046 --- /dev/null +++ b/src/SmartB2B.Enova.Service/Rpc/RpcInvocation.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json.Linq; + +namespace SmartB2B.Enova.Service.Rpc; + +public sealed class RpcInvocation +{ + private readonly IReadOnlyDictionary _keywordArguments; + + public RpcInvocation(object?[] positionalArguments, IReadOnlyDictionary keywordArguments) + { + PositionalArguments = positionalArguments; + _keywordArguments = keywordArguments; + } + + public object?[] PositionalArguments { get; } + + public bool Contains(string name) => _keywordArguments.ContainsKey(name); + + public T? Get(string name, T? defaultValue = default) + { + if (!_keywordArguments.TryGetValue(name, out var value) || value is null) + { + return defaultValue; + } + + if (value is T typed) + { + return typed; + } + + var token = value as JToken ?? JToken.FromObject(value); + return token.ToObject(); + } +} + +public sealed record KeywordResult(IReadOnlyDictionary Values); diff --git a/src/SmartB2B.Enova.Service/Rpc/WampErrorFactory.cs b/src/SmartB2B.Enova.Service/Rpc/WampErrorFactory.cs new file mode 100644 index 0000000..23d0bee --- /dev/null +++ b/src/SmartB2B.Enova.Service/Rpc/WampErrorFactory.cs @@ -0,0 +1,22 @@ +using SmartB2B.Enova.Contracts; +using WampSharp.V2.Core.Contracts; + +namespace SmartB2B.Enova.Service.Rpc; + +public static class WampErrorFactory +{ + public static WampException FromEnova(EnovaOperationException exception) => + Create(exception.ErrorUri, exception.Message, exception.Details); + + public static WampException Create( + string errorUri, + string message, + IReadOnlyDictionary? details = null) + { + var keywordArguments = (details ?? new Dictionary()) + .ToDictionary(pair => pair.Key, pair => pair.Value!, StringComparer.Ordinal); + keywordArguments["message"] = message; + + return new WampException(errorUri, [], keywordArguments); + } +} diff --git a/src/SmartB2B.Enova.Service/Rpc/WampServiceClient.cs b/src/SmartB2B.Enova.Service/Rpc/WampServiceClient.cs new file mode 100644 index 0000000..72f5597 --- /dev/null +++ b/src/SmartB2B.Enova.Service/Rpc/WampServiceClient.cs @@ -0,0 +1,99 @@ +using WampSharp.V2; +using WampSharp.V2.Client; +using WampSharp.V2.Core.Contracts; +using WampSharp.V2.Rpc; + +namespace SmartB2B.Enova.Service.Rpc; + +public sealed class WampServiceClient +{ + private readonly string _serverUrl; + private readonly string _realmName; + private readonly TimeSpan _reconnectDelay; + private readonly IReadOnlyList _operations; + + public WampServiceClient( + string serverUrl, + string realmName, + TimeSpan reconnectDelay, + IReadOnlyList operations) + { + _serverUrl = serverUrl; + _realmName = realmName; + _reconnectDelay = reconnectDelay; + _operations = operations; + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + IWampChannel? channel = null; + var registrations = new List(); + try + { + var channelFactory = new DefaultWampChannelFactory(); + channel = channelFactory.CreateJsonChannel(_serverUrl, _realmName); + var disconnected = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + channel.RealmProxy.Monitor.ConnectionBroken += (_, _) => disconnected.TrySetResult(); + channel.RealmProxy.Monitor.ConnectionError += (_, _) => disconnected.TrySetResult(); + + Console.WriteLine($"Łączenie z WAMP: realm={_realmName}, url={_serverUrl}."); + await channel.Open().WaitAsync(cancellationToken).ConfigureAwait(false); + + foreach (var operation in _operations) + { + var registration = await channel.RealmProxy.RpcCatalog.Register( + operation, + new RegisterOptions { Invoke = "last" }) + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + registrations.Add(registration); + } + + Console.WriteLine( + $"Połączono z WAMP; zarejestrowano {_operations.Count} procedur."); + await disconnected.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + Console.Error.WriteLine("Połączenie WAMP zostało przerwane."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + Console.Error.WriteLine($"Błąd połączenia WAMP: {exception.Message}"); + } + finally + { + foreach (var registration in registrations) + { + try + { + await registration.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Sesja może już nie istnieć po zerwaniu połączenia. + } + } + + try + { + channel?.Close(); + } + catch + { + // Zamknięcie uszkodzonego kanału nie może zatrzymać pętli ponownego łączenia. + } + } + + if (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(_reconnectDelay, cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/src/SmartB2B.Enova.Service/Runtime/EnovaAdapterLoader.cs b/src/SmartB2B.Enova.Service/Runtime/EnovaAdapterLoader.cs new file mode 100644 index 0000000..4e48e6b --- /dev/null +++ b/src/SmartB2B.Enova.Service/Runtime/EnovaAdapterLoader.cs @@ -0,0 +1,115 @@ +using System.Reflection; +using System.Runtime.Loader; +using SmartB2B.Enova.Contracts; + +namespace SmartB2B.Enova.Service.Runtime; + +public sealed class EnovaAdapterLoader : IDisposable +{ + private static readonly string[] RequiredAssemblies = + [ + "Soneta.Start.dll", + "Soneta.Business.dll", + "Soneta.Handel.dll", + "Soneta.CRM.dll" + ]; + + private readonly string _enovaPath; + private readonly string _adapterPath; + private readonly object _syncRoot = new(); + private readonly Dictionary _resolved = new(StringComparer.OrdinalIgnoreCase); + + public EnovaAdapterLoader(string enovaPath, string? adapterPath = null) + { + _enovaPath = Path.GetFullPath(enovaPath); + _adapterPath = Path.GetFullPath(adapterPath ?? Path.Combine( + AppContext.BaseDirectory, + "adapters", + "SmartB2B.Enova.Adapter.dll")); + + ValidateFiles(); + AssemblyLoadContext.Default.Resolving += ResolveAssembly; + + var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + if (!currentPath.Split(Path.PathSeparator).Contains(_enovaPath, StringComparer.OrdinalIgnoreCase)) + { + Environment.SetEnvironmentVariable("PATH", _enovaPath + Path.PathSeparator + currentPath); + } + } + + public IEnovaOrderAdapter Load() + { + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(_adapterPath); + var adapterType = assembly.GetExportedTypes().SingleOrDefault(type => + !type.IsAbstract && typeof(IEnovaOrderAdapter).IsAssignableFrom(type)); + + if (adapterType is null) + { + throw new InvalidOperationException( + $"W module '{_adapterPath}' nie znaleziono implementacji {nameof(IEnovaOrderAdapter)}."); + } + + return (IEnovaOrderAdapter)(Activator.CreateInstance(adapterType) + ?? throw new InvalidOperationException($"Nie można utworzyć adaptera '{adapterType.FullName}'.")); + } + + public void Dispose() + { + AssemblyLoadContext.Default.Resolving -= ResolveAssembly; + } + + private Assembly? ResolveAssembly(AssemblyLoadContext context, AssemblyName assemblyName) + { + var simpleName = assemblyName.Name; + if (string.IsNullOrWhiteSpace(simpleName)) + { + return null; + } + + lock (_syncRoot) + { + if (_resolved.TryGetValue(simpleName, out var cached)) + { + return cached; + } + + var candidates = new[] + { + Path.Combine(Path.GetDirectoryName(_adapterPath)!, simpleName + ".dll"), + Path.Combine(_enovaPath, simpleName + ".dll") + }; + + var path = candidates.FirstOrDefault(File.Exists); + if (path is null) + { + return null; + } + + var loaded = context.LoadFromAssemblyPath(path); + _resolved[simpleName] = loaded; + return loaded; + } + } + + private void ValidateFiles() + { + if (!Directory.Exists(_enovaPath)) + { + throw new DirectoryNotFoundException($"Nie znaleziono katalogu instalacji Enovy: {_enovaPath}"); + } + + if (!File.Exists(_adapterPath)) + { + throw new FileNotFoundException("Nie znaleziono modułu adaptera Enovy.", _adapterPath); + } + + var missing = RequiredAssemblies + .Where(file => !File.Exists(Path.Combine(_enovaPath, file))) + .ToArray(); + if (missing.Length > 0) + { + throw new FileNotFoundException( + $"W katalogu Enovy brakuje wymaganych bibliotek: {string.Join(", ", missing)}"); + } + } +} diff --git a/src/SmartB2B.Enova.Service/SmartB2B.Enova.Service.csproj b/src/SmartB2B.Enova.Service/SmartB2B.Enova.Service.csproj new file mode 100644 index 0000000..b78b08e --- /dev/null +++ b/src/SmartB2B.Enova.Service/SmartB2B.Enova.Service.csproj @@ -0,0 +1,29 @@ + + + Exe + net8.0 + enable + enable + SmartB2B.Enova.Service + 1.0.0 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SmartB2B.Enova.Service/Sql/SqlRawHandler.cs b/src/SmartB2B.Enova.Service/Sql/SqlRawHandler.cs new file mode 100644 index 0000000..020e2e1 --- /dev/null +++ b/src/SmartB2B.Enova.Service/Sql/SqlRawHandler.cs @@ -0,0 +1,157 @@ +using System.Data.Common; +using Newtonsoft.Json; +using SmartB2B.Enova.Service.Rpc; + +namespace SmartB2B.Enova.Service.Sql; + +public sealed class SqlRawHandler +{ + private readonly string _connectionString; + private readonly int _commandTimeoutSeconds; + private readonly Type _connectionType; + + public SqlRawHandler(string connectionString, int commandTimeoutSeconds) + { + _ = new DbConnectionStringBuilder { ConnectionString = connectionString }; + _connectionString = connectionString; + _commandTimeoutSeconds = commandTimeoutSeconds; + _connectionType = ResolveConnectionType(); + } + + public async Task HandleAsync(RpcInvocation invocation, CancellationToken cancellationToken) + { + var query = invocation.Get("query"); + if (string.IsNullOrWhiteSpace(query)) + { + throw WampErrorFactory.Create("eu.smartb2b.sql_error", "Pole query jest wymagane."); + } + + var parameters = invocation.Get("params", []) ?? []; + + try + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + await using var command = connection.CreateCommand(); + command.CommandText = query; + command.CommandTimeout = _commandTimeoutSeconds; + + for (var index = 0; index < parameters.Length; index++) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = $"@{index + 1}"; + parameter.Value = NormalizeParameter(parameters[index]); + command.Parameters.Add(parameter); + } + + var rowsAffected = new List(); + + var recordsets = new List>>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + do + { + if (reader.FieldCount == 0) + { + continue; + } + + var rows = new List>(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var row = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var ordinal = 0; ordinal < reader.FieldCount; ordinal++) + { + var name = reader.GetName(ordinal); + if (row.ContainsKey(name)) + { + name = $"{name}_{ordinal + 1}"; + } + + row[name] = await reader.IsDBNullAsync(ordinal, cancellationToken).ConfigureAwait(false) + ? null + : NormalizeResultValue(reader.GetValue(ordinal)); + } + + rows.Add(row); + } + + recordsets.Add(rows); + } + while (await reader.NextResultAsync(cancellationToken).ConfigureAwait(false)); + + if (reader.RecordsAffected >= 0) + { + rowsAffected.Add(reader.RecordsAffected); + } + + return new SqlRawResult + { + Recordsets = recordsets, + Recordset = recordsets.FirstOrDefault(), + Output = new Dictionary(), + RowsAffected = rowsAffected + }; + } + catch (DbException exception) + { + var code = exception.GetType().GetProperty("Number")?.GetValue(exception) + ?? exception.ErrorCode; + Console.Error.WriteLine($"Błąd SQL {code}: {exception.Message}"); + throw WampErrorFactory.Create( + "eu.smartb2b.sql_error", + exception.Message, + new Dictionary { ["code"] = code }); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw WampErrorFactory.Create("eu.smartb2b.sql_error", "Przekroczono czas wykonania zapytania SQL."); + } + } + + private DbConnection CreateConnection() + { + return (DbConnection)(Activator.CreateInstance(_connectionType, _connectionString) + ?? throw new InvalidOperationException("Nie można utworzyć połączenia SQL.")); + } + + private static Type ResolveConnectionType() => + Type.GetType( + "System.Data.SqlClient.SqlConnection, System.Data.SqlClient", + throwOnError: true) + ?? throw new InvalidOperationException("Nie znaleziono dostawcy System.Data.SqlClient."); + + private static object NormalizeParameter(object? value) + { + if (value is Newtonsoft.Json.Linq.JValue jsonValue) + { + value = jsonValue.Value; + } + + return value ?? DBNull.Value; + } + + private static object NormalizeResultValue(object value) => + value is byte[] bytes + ? new Dictionary + { + ["type"] = "Buffer", + ["data"] = bytes.Select(item => (int)item).ToArray() + } + : value; +} + +public sealed class SqlRawResult +{ + [JsonProperty("recordsets")] + public IReadOnlyList>> Recordsets { get; init; } = []; + + [JsonProperty("recordset", NullValueHandling = NullValueHandling.Ignore)] + public IReadOnlyList>? Recordset { get; init; } + + [JsonProperty("output")] + public IReadOnlyDictionary Output { get; init; } = new Dictionary(); + + [JsonProperty("rowsAffected")] + public IReadOnlyList RowsAffected { get; init; } = []; +} diff --git a/src/SmartB2B.Enova.Service/config/enova.json b/src/SmartB2B.Enova.Service/config/enova.json new file mode 100644 index 0000000..c21bbcc --- /dev/null +++ b/src/SmartB2B.Enova.Service/config/enova.json @@ -0,0 +1,24 @@ +{ + "portal": "demo", + "wamp": { + "serverUrl": "ws://wamp.smartb2b.eu:8080/", + "reconnectDelaySeconds": 10 + }, + "enova": { + "installationPath": "C:\\Program Files (x86)\\Soneta\\enova365 2604.4.4", + "database": "Firma demo", + "operator": "Administrator", + "passwordEnvironmentVariable": "SMARTB2B_ENOVA_PASSWORD", + "documentDefinition": "ZO", + "defaultWarehouseCode": null, + "saveMode": "Buffer" + }, + "sql": { + "connectionStringEnvironmentVariable": "SMARTB2B_SQL_CONNECTION_STRING", + "commandTimeoutSeconds": 30 + }, + "diagnostics": { + "logDirectory": "daemon", + "logPrefix": "smartb2bsync-enova" + } +} diff --git a/tests/SmartB2B.Enova.Tests/Program.cs b/tests/SmartB2B.Enova.Tests/Program.cs new file mode 100644 index 0000000..3c6342b --- /dev/null +++ b/tests/SmartB2B.Enova.Tests/Program.cs @@ -0,0 +1,343 @@ +using SmartB2B.Enova.Contracts; +using SmartB2B.Enova.Service.Orders; +using SmartB2B.Enova.Service.Rpc; +using SmartB2B.Enova.Service.Runtime; +using WampSharp.Core.Serialization; +using WampSharp.V2.Core; +using WampSharp.V2.Core.Contracts; +using WampSharp.V2.Rpc; + +var tests = new (string Name, Func Run)[] +{ + ("Poprawne zamówienie z rabatem ułamkowym", ValidFractionalDiscount), + ("Rabat 100 procent jest dozwolony", FullDiscount), + ("Rabat ujemny jest odrzucany", NegativeDiscount), + ("Rabat powyżej 100 jest odrzucany", DiscountAboveOneHundred), + ("Ilość zerowa jest odrzucana", ZeroQuantity), + ("Cena ujemna jest odrzucana", NegativePrice), + ("Brak towaru jest odrzucany", MissingProduct), + ("Brak pozycji jest odrzucany", MissingItems), + ("Powtarzająca się referencja nie jest walidowana", ReferenceMayRepeat), + ("Kontrakt place_order jest mapowany na Enovę", PlaceOrderMapping), + ("place_order zwraca nazwany wynik", PlaceOrderKeywordResult), + ("Warstwa WAMP wysyła wynik jako kwargs", WampKeywordWireResult), + ("Brak waluty zwraca stabilny błąd", MissingCurrency), + ("Waluta inna niż PLN jest odrzucana", UnsupportedCurrency), + ("Brak katalogu Enovy jest wykrywany", MissingEnovaDirectory) +}; + +var failures = 0; +foreach (var test in tests) +{ + try + { + await test.Run(); + Console.WriteLine($"PASS: {test.Name}"); + } + catch (Exception exception) + { + failures++; + Console.Error.WriteLine($"FAIL: {test.Name}: {exception.Message}"); + } +} + +Console.WriteLine($"Wynik: {tests.Length - failures}/{tests.Length} testów zaliczonych."); +return failures == 0 ? 0 : 1; + +static Task ValidFractionalDiscount() +{ + AssertNoErrors(CreateValidRequest(12.5m)); + return Task.CompletedTask; +} + +static Task FullDiscount() +{ + AssertNoErrors(CreateValidRequest(100m)); + return Task.CompletedTask; +} + +static Task NegativeDiscount() +{ + AssertHasError(CreateValidRequest(-0.01m), "discount"); + return Task.CompletedTask; +} + +static Task DiscountAboveOneHundred() +{ + AssertHasError(CreateValidRequest(100.01m), "discount"); + return Task.CompletedTask; +} + +static Task ZeroQuantity() +{ + AssertHasError(CreateValidRequest(quantity: 0m), "quantity"); + return Task.CompletedTask; +} + +static Task NegativePrice() +{ + AssertHasError(CreateValidRequest(unitPrice: -0.01m), "price_netto"); + return Task.CompletedTask; +} + +static Task MissingProduct() +{ + AssertHasError(CreateValidRequest(productCode: ""), "symbol"); + return Task.CompletedTask; +} + +static Task MissingItems() +{ + var request = new OrderRequest { CustomerCode = "Abc", Items = [] }; + AssertHasError(request, "co najmniej jedną"); + return Task.CompletedTask; +} + +static Task ReferenceMayRepeat() +{ + AssertNoErrors(CreateValidRequest()); + AssertNoErrors(CreateValidRequest()); + return Task.CompletedTask; +} + +static async Task PlaceOrderMapping() +{ + var adapter = new FakeAdapter(); + var handler = new PlaceOrderHandler(adapter, CreateConfiguration()); + var invocation = CreateInvocation(); + + await handler.HandleAsync(invocation, CancellationToken.None); + + var request = adapter.LastRequest ?? throw new InvalidOperationException("Adapter nie otrzymał zamówienia."); + AssertEqual("ABC", request.CustomerCode, "companyErpId"); + AssertEqual("MAG", request.WarehouseCode, "warehouseErpId"); + AssertEqual("PO/1", request.CustomerReference, "purchase_order_number"); + AssertEqual(2, request.Items.Count, "liczba pozycji"); + AssertEqual(12.5m, request.Items[0].Discount, "rabat"); + AssertEqual(120m, request.Items[0].UnitPrice, "cena netto"); +} + +static async Task PlaceOrderKeywordResult() +{ + var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration()); + var result = await handler.HandleAsync(CreateInvocation(), CancellationToken.None); + var keywords = result as KeywordResult + ?? throw new InvalidOperationException("Wynik nie jest wynikiem nazwanym WAMP."); + + AssertEqual("ZO/1", keywords.Values["order_erp_symbol"], "order_erp_symbol"); + AssertEqual(100m, keywords.Values["value_netto"], "value_netto"); + AssertEqual(123m, keywords.Values["value_brutto"], "value_brutto"); +} + +static async Task WampKeywordWireResult() +{ + var operation = new DelegateRpcOperation( + "test.keyword", + (_, _) => Task.FromResult(new KeywordResult( + new Dictionary { ["answer"] = 42 }))); + var callback = new FakeRouterCallback(); + + _ = operation.Invoke( + callback, + WampObjectFormatter.Value, + new InvocationDetails(), + Array.Empty(), + new Dictionary()); + + var result = await callback.Completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + AssertEqual(0, result.Arguments.Length, "liczba argumentów pozycyjnych WAMP"); + AssertEqual(42, result.KeywordArguments["answer"], "argument nazwany WAMP"); +} + +static async Task MissingCurrency() +{ + var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration()); + var invocation = new RpcInvocation([], new Dictionary()); + await AssertWampError(handler.HandleAsync(invocation, CancellationToken.None), "eu.smartb2b.missing_currency"); +} + +static async Task UnsupportedCurrency() +{ + var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration()); + var invocation = new RpcInvocation([], new Dictionary { ["currency_iso"] = "EUR" }); + await AssertWampError( + handler.HandleAsync(invocation, CancellationToken.None), + "eu.smartb2b.erp.currency_not_supported"); +} + +static Task MissingEnovaDirectory() +{ + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + try + { + _ = new EnovaAdapterLoader(path, Path.Combine(path, "adapter.dll")); + throw new InvalidOperationException("Oczekiwano DirectoryNotFoundException."); + } + catch (DirectoryNotFoundException) + { + return Task.CompletedTask; + } +} + +static RpcInvocation CreateInvocation() => new( + [], + new Dictionary + { + ["currency_iso"] = "PLN", + ["companyErpId"] = "ABC", + ["warehouseErpId"] = "MAG", + ["purchase_order_number"] = "PO/1", + ["notes"] = "Test", + ["lines"] = new[] + { + new PlaceOrderLine { Symbol = "T1", Quantity = 2m, PriceNetto = 120m, Discount = 12.5m }, + new PlaceOrderLine { Symbol = "T2", Quantity = 1m, PriceNetto = 50m, Discount = 0m } + } + }); + +static EnovaAdapterConfiguration CreateConfiguration() => new( + new EnovaConnectionOptions("Firma demo", "Administrator", ""), + "ZO", + null, + OrderSaveMode.Buffer); + +static OrderRequest CreateValidRequest( + decimal discount = 0m, + decimal quantity = 1m, + decimal unitPrice = 100m, + string productCode = "BIKINI") => + new() + { + CustomerCode = "Abc", + CustomerReference = "KLIENT/TEST/1", + Notes = "Test", + DocumentDefinition = "ZO", + SaveMode = OrderSaveMode.Buffer, + Items = + [ + new OrderLineRequest + { + ProductCode = productCode, + Quantity = quantity, + UnitPrice = unitPrice, + Discount = discount + } + ] + }; + +static void AssertNoErrors(OrderRequest request) +{ + var errors = OrderValidator.Validate(request); + if (errors.Count > 0) + { + throw new InvalidOperationException(string.Join(" | ", errors)); + } +} + +static void AssertHasError(OrderRequest request, string expectedText) +{ + var errors = OrderValidator.Validate(request); + if (!errors.Any(error => error.Contains(expectedText, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + $"Oczekiwano błędu zawierającego '{expectedText}', otrzymano: {string.Join(" | ", errors)}"); + } +} + +static async Task AssertWampError(Task task, string expectedUri) +{ + try + { + await task; + throw new InvalidOperationException($"Oczekiwano błędu {expectedUri}."); + } + catch (WampException exception) when (exception.ErrorUri == expectedUri) + { + // Oczekiwany błąd. + } +} + +static void AssertEqual(object? expected, object? actual, string name) +{ + if (!Equals(expected, actual)) + { + throw new InvalidOperationException($"{name}: oczekiwano '{expected}', otrzymano '{actual}'."); + } +} + +file sealed class FakeAdapter : IEnovaOrderAdapter +{ + public OrderRequest? LastRequest { get; private set; } + + public EnovaAdapterInfo Initialize(EnovaAdapterConfiguration configuration) => + new("1.0.0", "2604.4.4.0", "2604.4.4.0", configuration.Connection.DatabaseName); + + public OrderResult CreateOrder(OrderRequest request) + { + LastRequest = request; + return new OrderResult( + 1, + "ZO/1", + request.SaveMode, + request.CustomerCode, + request.WarehouseCode ?? "FIRMA", + request.CustomerReference, + request.Items.Count, + 100m, + 23m, + 123m, + request.Items.Any(line => line.Discount > 0)); + } +} + +file sealed class FakeRouterCallback : IWampRawRpcOperationRouterCallback +{ + public long RequestId => 1; + + public TaskCompletionSource Completion { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Result(IWampFormatter formatter, YieldOptions details) => + Completion.TrySetResult(new CapturedResult([], new Dictionary())); + + public void Result( + IWampFormatter formatter, + YieldOptions details, + TMessage[] arguments) => + Completion.TrySetResult(new CapturedResult( + arguments.Select(formatter.Deserialize).ToArray(), + new Dictionary())); + + public void Result( + IWampFormatter formatter, + YieldOptions details, + TMessage[] arguments, + IDictionary argumentsKeywords) => + Completion.TrySetResult(new CapturedResult( + arguments.Select(formatter.Deserialize).ToArray(), + argumentsKeywords.ToDictionary( + pair => pair.Key, + pair => (object?)formatter.Deserialize(pair.Value)))); + + public void Error(IWampFormatter formatter, TMessage details, string error) => + Completion.TrySetException(new InvalidOperationException(error)); + + public void Error( + IWampFormatter formatter, + TMessage details, + string error, + TMessage[] arguments) => + Completion.TrySetException(new InvalidOperationException(error)); + + public void Error( + IWampFormatter formatter, + TMessage details, + string error, + TMessage[] arguments, + TMessage argumentsKeywords) => + Completion.TrySetException(new InvalidOperationException(error)); +} + +file sealed record CapturedResult( + object?[] Arguments, + IReadOnlyDictionary KeywordArguments); diff --git a/tests/SmartB2B.Enova.Tests/SmartB2B.Enova.Tests.csproj b/tests/SmartB2B.Enova.Tests/SmartB2B.Enova.Tests.csproj new file mode 100644 index 0000000..8a53f9f --- /dev/null +++ b/tests/SmartB2B.Enova.Tests/SmartB2B.Enova.Tests.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0 + enable + enable + + + + + +