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

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