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,343 @@
using SmartB2B.Enova.Contracts;
using SmartB2B.Enova.Service.Orders;
using SmartB2B.Enova.Service.Rpc;
using SmartB2B.Enova.Service.Runtime;
using WampSharp.Core.Serialization;
using WampSharp.V2.Core;
using WampSharp.V2.Core.Contracts;
using WampSharp.V2.Rpc;
var tests = new (string Name, Func<Task> Run)[]
{
("Poprawne zamówienie z rabatem ułamkowym", ValidFractionalDiscount),
("Rabat 100 procent jest dozwolony", FullDiscount),
("Rabat ujemny jest odrzucany", NegativeDiscount),
("Rabat powyżej 100 jest odrzucany", DiscountAboveOneHundred),
("Ilość zerowa jest odrzucana", ZeroQuantity),
("Cena ujemna jest odrzucana", NegativePrice),
("Brak towaru jest odrzucany", MissingProduct),
("Brak pozycji jest odrzucany", MissingItems),
("Powtarzająca się referencja nie jest walidowana", ReferenceMayRepeat),
("Kontrakt place_order jest mapowany na Enovę", PlaceOrderMapping),
("place_order zwraca nazwany wynik", PlaceOrderKeywordResult),
("Warstwa WAMP wysyła wynik jako kwargs", WampKeywordWireResult),
("Brak waluty zwraca stabilny błąd", MissingCurrency),
("Waluta inna niż PLN jest odrzucana", UnsupportedCurrency),
("Brak katalogu Enovy jest wykrywany", MissingEnovaDirectory)
};
var failures = 0;
foreach (var test in tests)
{
try
{
await test.Run();
Console.WriteLine($"PASS: {test.Name}");
}
catch (Exception exception)
{
failures++;
Console.Error.WriteLine($"FAIL: {test.Name}: {exception.Message}");
}
}
Console.WriteLine($"Wynik: {tests.Length - failures}/{tests.Length} testów zaliczonych.");
return failures == 0 ? 0 : 1;
static Task ValidFractionalDiscount()
{
AssertNoErrors(CreateValidRequest(12.5m));
return Task.CompletedTask;
}
static Task FullDiscount()
{
AssertNoErrors(CreateValidRequest(100m));
return Task.CompletedTask;
}
static Task NegativeDiscount()
{
AssertHasError(CreateValidRequest(-0.01m), "discount");
return Task.CompletedTask;
}
static Task DiscountAboveOneHundred()
{
AssertHasError(CreateValidRequest(100.01m), "discount");
return Task.CompletedTask;
}
static Task ZeroQuantity()
{
AssertHasError(CreateValidRequest(quantity: 0m), "quantity");
return Task.CompletedTask;
}
static Task NegativePrice()
{
AssertHasError(CreateValidRequest(unitPrice: -0.01m), "price_netto");
return Task.CompletedTask;
}
static Task MissingProduct()
{
AssertHasError(CreateValidRequest(productCode: ""), "symbol");
return Task.CompletedTask;
}
static Task MissingItems()
{
var request = new OrderRequest { CustomerCode = "Abc", Items = [] };
AssertHasError(request, "co najmniej jedną");
return Task.CompletedTask;
}
static Task ReferenceMayRepeat()
{
AssertNoErrors(CreateValidRequest());
AssertNoErrors(CreateValidRequest());
return Task.CompletedTask;
}
static async Task PlaceOrderMapping()
{
var adapter = new FakeAdapter();
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
var invocation = CreateInvocation();
await handler.HandleAsync(invocation, CancellationToken.None);
var request = adapter.LastRequest ?? throw new InvalidOperationException("Adapter nie otrzymał zamówienia.");
AssertEqual("ABC", request.CustomerCode, "companyErpId");
AssertEqual("MAG", request.WarehouseCode, "warehouseErpId");
AssertEqual("PO/1", request.CustomerReference, "purchase_order_number");
AssertEqual(2, request.Items.Count, "liczba pozycji");
AssertEqual(12.5m, request.Items[0].Discount, "rabat");
AssertEqual(120m, request.Items[0].UnitPrice, "cena netto");
}
static async Task PlaceOrderKeywordResult()
{
var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration());
var result = await handler.HandleAsync(CreateInvocation(), CancellationToken.None);
var keywords = result as KeywordResult
?? throw new InvalidOperationException("Wynik nie jest wynikiem nazwanym WAMP.");
AssertEqual("ZO/1", keywords.Values["order_erp_symbol"], "order_erp_symbol");
AssertEqual(100m, keywords.Values["value_netto"], "value_netto");
AssertEqual(123m, keywords.Values["value_brutto"], "value_brutto");
}
static async Task WampKeywordWireResult()
{
var operation = new DelegateRpcOperation(
"test.keyword",
(_, _) => Task.FromResult<object?>(new KeywordResult(
new Dictionary<string, object?> { ["answer"] = 42 })));
var callback = new FakeRouterCallback();
_ = operation.Invoke(
callback,
WampObjectFormatter.Value,
new InvocationDetails(),
Array.Empty<object>(),
new Dictionary<string, object>());
var result = await callback.Completion.Task.WaitAsync(TimeSpan.FromSeconds(5));
AssertEqual(0, result.Arguments.Length, "liczba argumentów pozycyjnych WAMP");
AssertEqual(42, result.KeywordArguments["answer"], "argument nazwany WAMP");
}
static async Task MissingCurrency()
{
var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration());
var invocation = new RpcInvocation([], new Dictionary<string, object?>());
await AssertWampError(handler.HandleAsync(invocation, CancellationToken.None), "eu.smartb2b.missing_currency");
}
static async Task UnsupportedCurrency()
{
var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration());
var invocation = new RpcInvocation([], new Dictionary<string, object?> { ["currency_iso"] = "EUR" });
await AssertWampError(
handler.HandleAsync(invocation, CancellationToken.None),
"eu.smartb2b.erp.currency_not_supported");
}
static Task MissingEnovaDirectory()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
_ = new EnovaAdapterLoader(path, Path.Combine(path, "adapter.dll"));
throw new InvalidOperationException("Oczekiwano DirectoryNotFoundException.");
}
catch (DirectoryNotFoundException)
{
return Task.CompletedTask;
}
}
static RpcInvocation CreateInvocation() => new(
[],
new Dictionary<string, object?>
{
["currency_iso"] = "PLN",
["companyErpId"] = "ABC",
["warehouseErpId"] = "MAG",
["purchase_order_number"] = "PO/1",
["notes"] = "Test",
["lines"] = new[]
{
new PlaceOrderLine { Symbol = "T1", Quantity = 2m, PriceNetto = 120m, Discount = 12.5m },
new PlaceOrderLine { Symbol = "T2", Quantity = 1m, PriceNetto = 50m, Discount = 0m }
}
});
static EnovaAdapterConfiguration CreateConfiguration() => new(
new EnovaConnectionOptions("Firma demo", "Administrator", ""),
"ZO",
null,
OrderSaveMode.Buffer);
static OrderRequest CreateValidRequest(
decimal discount = 0m,
decimal quantity = 1m,
decimal unitPrice = 100m,
string productCode = "BIKINI") =>
new()
{
CustomerCode = "Abc",
CustomerReference = "KLIENT/TEST/1",
Notes = "Test",
DocumentDefinition = "ZO",
SaveMode = OrderSaveMode.Buffer,
Items =
[
new OrderLineRequest
{
ProductCode = productCode,
Quantity = quantity,
UnitPrice = unitPrice,
Discount = discount
}
]
};
static void AssertNoErrors(OrderRequest request)
{
var errors = OrderValidator.Validate(request);
if (errors.Count > 0)
{
throw new InvalidOperationException(string.Join(" | ", errors));
}
}
static void AssertHasError(OrderRequest request, string expectedText)
{
var errors = OrderValidator.Validate(request);
if (!errors.Any(error => error.Contains(expectedText, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"Oczekiwano błędu zawierającego '{expectedText}', otrzymano: {string.Join(" | ", errors)}");
}
}
static async Task AssertWampError(Task<object?> task, string expectedUri)
{
try
{
await task;
throw new InvalidOperationException($"Oczekiwano błędu {expectedUri}.");
}
catch (WampException exception) when (exception.ErrorUri == expectedUri)
{
// Oczekiwany błąd.
}
}
static void AssertEqual(object? expected, object? actual, string name)
{
if (!Equals(expected, actual))
{
throw new InvalidOperationException($"{name}: oczekiwano '{expected}', otrzymano '{actual}'.");
}
}
file sealed class FakeAdapter : IEnovaOrderAdapter
{
public OrderRequest? LastRequest { get; private set; }
public EnovaAdapterInfo Initialize(EnovaAdapterConfiguration configuration) =>
new("1.0.0", "2604.4.4.0", "2604.4.4.0", configuration.Connection.DatabaseName);
public OrderResult CreateOrder(OrderRequest request)
{
LastRequest = request;
return new OrderResult(
1,
"ZO/1",
request.SaveMode,
request.CustomerCode,
request.WarehouseCode ?? "FIRMA",
request.CustomerReference,
request.Items.Count,
100m,
23m,
123m,
request.Items.Any(line => line.Discount > 0));
}
}
file sealed class FakeRouterCallback : IWampRawRpcOperationRouterCallback
{
public long RequestId => 1;
public TaskCompletionSource<CapturedResult> Completion { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public void Result<TMessage>(IWampFormatter<TMessage> formatter, YieldOptions details) =>
Completion.TrySetResult(new CapturedResult([], new Dictionary<string, object?>()));
public void Result<TMessage>(
IWampFormatter<TMessage> formatter,
YieldOptions details,
TMessage[] arguments) =>
Completion.TrySetResult(new CapturedResult(
arguments.Select(formatter.Deserialize<object>).ToArray(),
new Dictionary<string, object?>()));
public void Result<TMessage>(
IWampFormatter<TMessage> formatter,
YieldOptions details,
TMessage[] arguments,
IDictionary<string, TMessage> argumentsKeywords) =>
Completion.TrySetResult(new CapturedResult(
arguments.Select(formatter.Deserialize<object>).ToArray(),
argumentsKeywords.ToDictionary(
pair => pair.Key,
pair => (object?)formatter.Deserialize<object>(pair.Value))));
public void Error<TMessage>(IWampFormatter<TMessage> formatter, TMessage details, string error) =>
Completion.TrySetException(new InvalidOperationException(error));
public void Error<TMessage>(
IWampFormatter<TMessage> formatter,
TMessage details,
string error,
TMessage[] arguments) =>
Completion.TrySetException(new InvalidOperationException(error));
public void Error<TMessage>(
IWampFormatter<TMessage> formatter,
TMessage details,
string error,
TMessage[] arguments,
TMessage argumentsKeywords) =>
Completion.TrySetException(new InvalidOperationException(error));
}
file sealed record CapturedResult(
object?[] Arguments,
IReadOnlyDictionary<string, object?> KeywordArguments);

View File

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