Initial Enova365 sync service
This commit is contained in:
256
src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs
Normal file
256
src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs
Normal 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";
|
||||
}
|
||||
17
src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj
Normal file
17
src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj
Normal 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>
|
||||
154
src/SmartB2B.Enova.Contracts/EnovaContracts.cs
Normal file
154
src/SmartB2B.Enova.Contracts/EnovaContracts.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
171
src/SmartB2B.Enova.Service/Configuration/ServiceSettings.cs
Normal file
171
src/SmartB2B.Enova.Service/Configuration/ServiceSettings.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
83
src/SmartB2B.Enova.Service/Diagnostics/DiagnosticsHandler.cs
Normal file
83
src/SmartB2B.Enova.Service/Diagnostics/DiagnosticsHandler.cs
Normal 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();
|
||||
}
|
||||
109
src/SmartB2B.Enova.Service/Orders/PlaceOrderHandler.cs
Normal file
109
src/SmartB2B.Enova.Service/Orders/PlaceOrderHandler.cs
Normal 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; }
|
||||
}
|
||||
120
src/SmartB2B.Enova.Service/Program.cs
Normal file
120
src/SmartB2B.Enova.Service/Program.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
130
src/SmartB2B.Enova.Service/Rpc/DelegateRpcOperation.cs
Normal file
130
src/SmartB2B.Enova.Service/Rpc/DelegateRpcOperation.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/SmartB2B.Enova.Service/Rpc/RpcInvocation.cs
Normal file
36
src/SmartB2B.Enova.Service/Rpc/RpcInvocation.cs
Normal 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);
|
||||
22
src/SmartB2B.Enova.Service/Rpc/WampErrorFactory.cs
Normal file
22
src/SmartB2B.Enova.Service/Rpc/WampErrorFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
99
src/SmartB2B.Enova.Service/Rpc/WampServiceClient.cs
Normal file
99
src/SmartB2B.Enova.Service/Rpc/WampServiceClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/SmartB2B.Enova.Service/Runtime/EnovaAdapterLoader.cs
Normal file
115
src/SmartB2B.Enova.Service/Runtime/EnovaAdapterLoader.cs
Normal 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)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/SmartB2B.Enova.Service/SmartB2B.Enova.Service.csproj
Normal file
29
src/SmartB2B.Enova.Service/SmartB2B.Enova.Service.csproj
Normal 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>
|
||||
157
src/SmartB2B.Enova.Service/Sql/SqlRawHandler.cs
Normal file
157
src/SmartB2B.Enova.Service/Sql/SqlRawHandler.cs
Normal 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; } = [];
|
||||
}
|
||||
24
src/SmartB2B.Enova.Service/config/enova.json
Normal file
24
src/SmartB2B.Enova.Service/config/enova.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user