Initial Enova365 sync service

This commit is contained in:
2026-07-15 08:27:32 +00:00
commit 605c6b7d2a
50 changed files with 2813 additions and 0 deletions

10
.gitignore vendored Normal file
View File

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

5
Directory.Build.props Normal file
View File

@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
</Project>

10
NuGet.Config Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<config>
<add key="globalPackagesFolder" value=".packages" />
</config>
</configuration>

68
README.md Normal file
View File

@@ -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', '<hasło operatora>', '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.

36
SmartB2B.EnovaSync.sln Normal file
View File

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

14
daemon/install.cmd Normal file
View File

@@ -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%

Binary file not shown.

View File

@@ -0,0 +1,13 @@
<service>
<id>smartb2bsync-enova</id>
<name>SmartB2B SYNC - enova365</name>
<description>Synchronizuje enova365 z portalem SmartB2B.</description>
<executable>%BASE%\..\SmartB2B.Enova.Service.exe</executable>
<workingdirectory>%BASE%\..</workingdirectory>
<stoptimeout>30sec</stoptimeout>
<log mode="roll-by-time">
<pattern>yyyyMMdd</pattern>
</log>
<onfailure action="restart" delay="10 sec" />
<delayedAutoStart />
</service>

7
daemon/uninstall.cmd Normal file
View File

@@ -0,0 +1,7 @@
@echo off
setlocal
cd /d "%~dp0"
smartb2bsync-enova.exe stop
smartb2bsync-enova.exe uninstall
exit /b %errorlevel%

59
publish.ps1 Normal file
View File

@@ -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"

BIN
release/Newtonsoft.Json.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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": ""
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

BIN
release/System.Reactive.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
release/WampSharp.dll Normal file

Binary file not shown.

Binary file not shown.

24
release/config/enova.json Normal file
View File

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

View File

@@ -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%

Binary file not shown.

View File

@@ -0,0 +1,13 @@
<service>
<id>smartb2bsync-enova</id>
<name>SmartB2B SYNC - enova365</name>
<description>Synchronizuje enova365 z portalem SmartB2B.</description>
<executable>%BASE%\..\SmartB2B.Enova.Service.exe</executable>
<workingdirectory>%BASE%\..</workingdirectory>
<stoptimeout>30sec</stoptimeout>
<log mode="roll-by-time">
<pattern>yyyyMMdd</pattern>
</log>
<onfailure action="restart" delay="10 sec" />
<delayedAutoStart />
</service>

View File

@@ -0,0 +1,7 @@
@echo off
setlocal
cd /d "%~dp0"
smartb2bsync-enova.exe stop
smartb2bsync-enova.exe uninstall
exit /b %errorlevel%

View File

@@ -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<string, object?> { ["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<string, object?> { ["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<string, object?> { ["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<string, object?> { ["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<string, object?> { ["companyErpId"] = request.CustomerCode });
var warehouse = string.IsNullOrWhiteSpace(request.WarehouseCode)
? warehouses.StandardowyMagazyn
: warehouses.GetGrantedView()
.Cast<Magazyn>()
.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<string, object?> { ["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<string, object?> { ["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<string>();
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";
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../SmartB2B.Enova.Contracts/SmartB2B.Enova.Contracts.csproj" />
<PackageReference Include="Soneta.Handel" Version="2604.4.4" />
<PackageReference Include="Soneta.Business.Licence" Version="2604.4.4" />
<PackageReference Include="Soneta.Licence.Local.Proxy" Version="1.0.2" />
<PackageReference Include="Soneta.Products.Modules" Version="2604.4.4" />
<PackageReference Include="Soneta.Start" Version="2604.4.4" />
<PackageReference Include="Soneta.Standard" Version="2604.4.4" />
</ItemGroup>
</Project>

View File

@@ -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<OrderLineRequest> 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<string, object?>? details = null,
Exception? innerException = null)
: base(message, innerException)
{
ErrorUri = errorUri;
Details = details ?? new Dictionary<string, object?>();
}
public string ErrorUri { get; }
public IReadOnlyDictionary<string, object?> Details { get; }
}
public static class OrderValidator
{
public static IReadOnlyList<string> Validate(OrderRequest? request)
{
var errors = new List<string>();
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;
}
}

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

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

View File

@@ -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<object?> GetVersionAsync(RpcInvocation _, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown";
return Task.FromResult<object?>(version);
}
public Task<object?> 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<object?>(string.Join(Environment.NewLine, lines));
}
public Task<object?> GetLogAsync(RpcInvocation invocation, CancellationToken cancellationToken) =>
ReadLogAsync(invocation, "out", cancellationToken);
public Task<object?> GetErrorLogAsync(RpcInvocation invocation, CancellationToken cancellationToken) =>
ReadLogAsync(invocation, "err", cancellationToken);
private async Task<object?> ReadLogAsync(
RpcInvocation invocation,
string stream,
CancellationToken cancellationToken)
{
var date = invocation.Get<string>("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<string, object?> { ["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();
}

View File

@@ -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<object?> 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<string>("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<string, object?> { ["currency_iso"] = currency });
}
var inputLines = invocation.Get<PlaceOrderLine[]>("lines", []) ?? [];
var request = new OrderRequest
{
CustomerCode = invocation.Get<string>("companyErpId") ?? string.Empty,
CustomerReference = invocation.Get<string>("purchase_order_number"),
Notes = invocation.Get<string>("notes"),
DocumentDefinition = _configuration.DocumentDefinition,
WarehouseCode = invocation.Get<string>("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<string, object?> { ["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<string, object?>
{
["order_erp_id"] = result.Id,
["order_erp_symbol"] = result.Number,
["value_netto"] = result.Net,
["value_brutto"] = result.Gross,
["stocks"] = Array.Empty<object>()
});
}
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; }
}

View File

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

View File

@@ -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<RpcInvocation, CancellationToken, Task<object?>> _handler;
public DelegateRpcOperation(
string procedure,
Func<RpcInvocation, CancellationToken, Task<object?>> handler)
{
Procedure = procedure;
_handler = handler;
}
public string Procedure { get; }
public IWampCancellableInvocation Invoke<TMessage>(
IWampRawRpcOperationRouterCallback caller,
IWampFormatter<TMessage> formatter,
InvocationDetails details) =>
Invoke(caller, formatter, details, [], new Dictionary<string, TMessage>());
public IWampCancellableInvocation Invoke<TMessage>(
IWampRawRpcOperationRouterCallback caller,
IWampFormatter<TMessage> formatter,
InvocationDetails details,
TMessage[] arguments) =>
Invoke(caller, formatter, details, arguments, new Dictionary<string, TMessage>());
public IWampCancellableInvocation Invoke<TMessage>(
IWampRawRpcOperationRouterCallback caller,
IWampFormatter<TMessage> formatter,
InvocationDetails details,
TMessage[] arguments,
IDictionary<string, TMessage> argumentsKeywords)
{
var cancellation = new CancellationTokenSource();
_ = ExecuteAsync(
caller,
formatter,
arguments ?? [],
argumentsKeywords ?? new Dictionary<string, TMessage>(),
cancellation.Token);
return new CancellableInvocation(cancellation);
}
private async Task ExecuteAsync<TMessage>(
IWampRawRpcOperationRouterCallback caller,
IWampFormatter<TMessage> formatter,
TMessage[] arguments,
IDictionary<string, TMessage> argumentsKeywords,
CancellationToken cancellationToken)
{
try
{
var positional = arguments.Select(formatter.Deserialize<object>).ToArray();
var keywords = argumentsKeywords.ToDictionary(
pair => pair.Key,
pair => (object?)formatter.Deserialize<object>(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<string, object>());
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<string, object>());
var keywordArguments = formatter.Serialize(new Dictionary<string, object?>
{
["message"] = exception.Message
});
caller.Error(
formatter,
details,
"eu.smartb2b.error",
Array.Empty<TMessage>(),
keywordArguments);
}
}
private sealed class CancellableInvocation : IWampCancellableInvocation
{
private readonly CancellationTokenSource _cancellation;
public CancellableInvocation(CancellationTokenSource cancellation)
{
_cancellation = cancellation;
}
public void Cancel(InterruptDetails details)
{
_cancellation.Cancel();
}
}
}

View File

@@ -0,0 +1,36 @@
using Newtonsoft.Json.Linq;
namespace SmartB2B.Enova.Service.Rpc;
public sealed class RpcInvocation
{
private readonly IReadOnlyDictionary<string, object?> _keywordArguments;
public RpcInvocation(object?[] positionalArguments, IReadOnlyDictionary<string, object?> keywordArguments)
{
PositionalArguments = positionalArguments;
_keywordArguments = keywordArguments;
}
public object?[] PositionalArguments { get; }
public bool Contains(string name) => _keywordArguments.ContainsKey(name);
public T? Get<T>(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<T>();
}
}
public sealed record KeywordResult(IReadOnlyDictionary<string, object?> Values);

View File

@@ -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<string, object?>? details = null)
{
var keywordArguments = (details ?? new Dictionary<string, object?>())
.ToDictionary(pair => pair.Key, pair => pair.Value!, StringComparer.Ordinal);
keywordArguments["message"] = message;
return new WampException(errorUri, [], keywordArguments);
}
}

View File

@@ -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<IWampRpcOperation> _operations;
public WampServiceClient(
string serverUrl,
string realmName,
TimeSpan reconnectDelay,
IReadOnlyList<IWampRpcOperation> 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<IAsyncDisposable>();
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);
}
}
}
}

View File

@@ -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<string, Assembly> _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)}");
}
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SmartB2B.Enova.Service</AssemblyName>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../SmartB2B.Enova.Contracts/SmartB2B.Enova.Contracts.csproj" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="WampSharp.Default.Client" Version="23.8.1" />
</ItemGroup>
<ItemGroup>
<None Include="config/enova.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="BuildEnovaAdapter" BeforeTargets="Build">
<MSBuild Projects="../SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj" Targets="Build" Properties="Configuration=$(Configuration)" />
</Target>
<Target Name="CopyEnovaAdapter" AfterTargets="Build" DependsOnTargets="BuildEnovaAdapter">
<MakeDir Directories="$(OutDir)adapters" />
<Copy SourceFiles="../SmartB2B.Enova.Adapter/bin/$(Configuration)/net8.0/SmartB2B.Enova.Adapter.dll" DestinationFolder="$(OutDir)adapters" />
</Target>
<Target Name="CopyEnovaAdapterToPublish" AfterTargets="Publish" DependsOnTargets="BuildEnovaAdapter">
<MakeDir Directories="$(PublishDir)adapters" />
<Copy SourceFiles="../SmartB2B.Enova.Adapter/bin/$(Configuration)/net8.0/SmartB2B.Enova.Adapter.dll" DestinationFolder="$(PublishDir)adapters" />
</Target>
</Project>

View File

@@ -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<object?> HandleAsync(RpcInvocation invocation, CancellationToken cancellationToken)
{
var query = invocation.Get<string>("query");
if (string.IsNullOrWhiteSpace(query))
{
throw WampErrorFactory.Create("eu.smartb2b.sql_error", "Pole query jest wymagane.");
}
var parameters = invocation.Get<object?[]>("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<int>();
var recordsets = new List<IReadOnlyList<IReadOnlyDictionary<string, object?>>>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
do
{
if (reader.FieldCount == 0)
{
continue;
}
var rows = new List<IReadOnlyDictionary<string, object?>>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var row = new Dictionary<string, object?>(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<string, object?>(),
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<string, object?> { ["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<string, object>
{
["type"] = "Buffer",
["data"] = bytes.Select(item => (int)item).ToArray()
}
: value;
}
public sealed class SqlRawResult
{
[JsonProperty("recordsets")]
public IReadOnlyList<IReadOnlyList<IReadOnlyDictionary<string, object?>>> Recordsets { get; init; } = [];
[JsonProperty("recordset", NullValueHandling = NullValueHandling.Ignore)]
public IReadOnlyList<IReadOnlyDictionary<string, object?>>? Recordset { get; init; }
[JsonProperty("output")]
public IReadOnlyDictionary<string, object?> Output { get; init; } = new Dictionary<string, object?>();
[JsonProperty("rowsAffected")]
public IReadOnlyList<int> RowsAffected { get; init; } = [];
}

View File

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

View File

@@ -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<Task> 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<object?>(new KeywordResult(
new Dictionary<string, object?> { ["answer"] = 42 })));
var callback = new FakeRouterCallback();
_ = operation.Invoke(
callback,
WampObjectFormatter.Value,
new InvocationDetails(),
Array.Empty<object>(),
new Dictionary<string, object>());
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<string, object?>());
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<string, object?> { ["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<string, object?>
{
["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<object?> 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<CapturedResult> Completion { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public void Result<TMessage>(IWampFormatter<TMessage> formatter, YieldOptions details) =>
Completion.TrySetResult(new CapturedResult([], new Dictionary<string, object?>()));
public void Result<TMessage>(
IWampFormatter<TMessage> formatter,
YieldOptions details,
TMessage[] arguments) =>
Completion.TrySetResult(new CapturedResult(
arguments.Select(formatter.Deserialize<object>).ToArray(),
new Dictionary<string, object?>()));
public void Result<TMessage>(
IWampFormatter<TMessage> formatter,
YieldOptions details,
TMessage[] arguments,
IDictionary<string, TMessage> argumentsKeywords) =>
Completion.TrySetResult(new CapturedResult(
arguments.Select(formatter.Deserialize<object>).ToArray(),
argumentsKeywords.ToDictionary(
pair => pair.Key,
pair => (object?)formatter.Deserialize<object>(pair.Value))));
public void Error<TMessage>(IWampFormatter<TMessage> formatter, TMessage details, string error) =>
Completion.TrySetException(new InvalidOperationException(error));
public void Error<TMessage>(
IWampFormatter<TMessage> formatter,
TMessage details,
string error,
TMessage[] arguments) =>
Completion.TrySetException(new InvalidOperationException(error));
public void Error<TMessage>(
IWampFormatter<TMessage> formatter,
TMessage details,
string error,
TMessage[] arguments,
TMessage argumentsKeywords) =>
Completion.TrySetException(new InvalidOperationException(error));
}
file sealed record CapturedResult(
object?[] Arguments,
IReadOnlyDictionary<string, object?> KeywordArguments);

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/SmartB2B.Enova.Contracts/SmartB2B.Enova.Contracts.csproj" />
<ProjectReference Include="../../src/SmartB2B.Enova.Service/SmartB2B.Enova.Service.csproj" />
</ItemGroup>
</Project>