Add shared ERP sync core and Optima adapter

This commit is contained in:
2026-09-08 02:07:42 +00:00
parent a040a91d1f
commit 6c4e50f7a6
366 changed files with 4707 additions and 478 deletions

View File

@@ -1,13 +1,14 @@
using System.Data.Common;
using System.Reflection;
using System.Runtime.Loader;
using SmartB2B.Enova.Contracts;
using SmartB2B.Sync.Contracts;
using SmartB2B.Sync.Service;
using SmartB2B.Enova.Service;
using SmartB2B.Enova.Service.Configuration;
using SmartB2B.Enova.Service.Orders;
using SmartB2B.Enova.Service.Rpc;
using SmartB2B.Sync.Service.Orders;
using SmartB2B.Sync.Service.Rpc;
using SmartB2B.Enova.Service.Runtime;
using SmartB2B.Enova.Service.Sql;
using SmartB2B.Sync.Service.Sql;
using WampSharp.Core.Serialization;
using WampSharp.V2.Core;
using WampSharp.V2.Core.Contracts;
@@ -84,19 +85,25 @@ static Task SqlProviderIsSupported()
static Task DatabaseRegistrationUsesConnectionString()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
string? installationPath = null;
var installationCandidates = new List<string>();
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, "enova365 2604.4.4");
if (File.Exists(Path.Combine(candidate, "Soneta.Business.dll")))
{
installationPath = candidate;
break;
}
installationCandidates.Add(Path.Combine(directory.FullName, "enova365 2604.4.4"));
directory = directory.Parent;
}
installationCandidates.Add(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
"Soneta",
"enova365 2604.4.4"));
installationCandidates.Add(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
"Soneta",
"enova365 2604.4.4"));
var installationPath = installationCandidates.FirstOrDefault(
candidate => File.Exists(Path.Combine(candidate, "Soneta.Business.dll")));
if (installationPath is null)
{
throw new InvalidOperationException("Nie znaleziono lokalnej instalacji enova365 do testu adaptera.");
@@ -188,7 +195,7 @@ static Task MissingProduct()
static Task MissingItems()
{
var request = new OrderRequest { CustomerCode = "Abc", Items = [] };
var request = new OrderRequest { CurrencyIso = "PLN", CustomerCode = "Abc", Items = [] };
AssertHasError(request, "co najmniej jedną");
return Task.CompletedTask;
}
@@ -214,7 +221,7 @@ static async Task PlaceOrderMapping()
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");
AssertEqual(120m, request.Items[0].UnitPriceNet, "cena netto");
}
static async Task PlaceOrderKeywordResult()
@@ -259,7 +266,7 @@ static async Task MissingCurrency()
static async Task UnsupportedCurrency()
{
var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration());
var invocation = new RpcInvocation([], new Dictionary<string, object?> { ["currency_iso"] = "EUR" });
var invocation = CreateInvocation("EUR");
await AssertWampError(
handler.HandleAsync(invocation, CancellationToken.None),
"eu.smartb2b.erp.currency_not_supported");
@@ -376,14 +383,14 @@ static Task PartialCredentialsDisablePlaceOrder()
static Task DebugSwitchEnablesVerboseLogging()
{
var options = CommandLineOptions.Parse(["/debug"]);
var options = ServiceCommandLine.Parse(["/debug"], "enova.json");
AssertEqual(true, options.VerboseLogging, "tryb szczegółowy");
return Task.CompletedTask;
}
static Task VerboseSwitchCanBeDisabled()
{
var options = CommandLineOptions.Parse(["/verbose", "/noverbose"]);
var options = ServiceCommandLine.Parse(["/verbose", "/noverbose"], "enova.json");
AssertEqual(false, options.VerboseLogging, "wyłączony tryb szczegółowy");
return Task.CompletedTask;
}
@@ -424,11 +431,11 @@ static RuntimeSettings LoadRuntimeSettings(string credentialsJson, bool verboseL
}
}
static RpcInvocation CreateInvocation() => new(
static RpcInvocation CreateInvocation(string currency = "PLN") => new(
[],
new Dictionary<string, object?>
{
["currency_iso"] = "PLN",
["currency_iso"] = currency,
["companyErpId"] = "ABC",
["warehouseErpId"] = "MAG",
["purchase_order_number"] = "PO/1",
@@ -440,8 +447,8 @@ static RpcInvocation CreateInvocation() => new(
}
});
static EnovaAdapterConfiguration CreateConfiguration() => new(
new EnovaConnectionOptions(
static ErpAdapterConfiguration CreateConfiguration() => new(
new ErpConnectionOptions(
"Firma demo",
"Administrator",
"",
@@ -457,6 +464,7 @@ static OrderRequest CreateValidRequest(
string productCode = "BIKINI") =>
new()
{
CurrencyIso = "PLN",
CustomerCode = "Abc",
CustomerReference = "KLIENT/TEST/1",
Notes = "Test",
@@ -468,7 +476,7 @@ static OrderRequest CreateValidRequest(
{
ProductCode = productCode,
Quantity = quantity,
UnitPrice = unitPrice,
UnitPriceNet = unitPrice,
Discount = discount
}
]
@@ -514,15 +522,24 @@ static void AssertEqual(object? expected, object? actual, string name)
}
}
file sealed class FakeAdapter : IEnovaOrderAdapter
file sealed class FakeAdapter : IErpOrderAdapter
{
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 ErpAdapterInfo Initialize(ErpAdapterConfiguration configuration) =>
new("1.0.0", "enova365", "2604.4.4.0", configuration.Connection.DatabaseName, ["PLN"]);
public void CheckConnection() { }
public void Dispose() { }
public OrderResult CreateOrder(OrderRequest request)
{
if (!string.Equals(request.CurrencyIso, "PLN", StringComparison.OrdinalIgnoreCase))
{
throw new ErpOperationException("eu.smartb2b.erp.currency_not_supported", "Waluta nie jest obsługiwana.");
}
LastRequest = request;
return new OrderResult(
1,
@@ -539,13 +556,17 @@ file sealed class FakeAdapter : IEnovaOrderAdapter
}
}
file sealed class FailingAdapter : IEnovaOrderAdapter
file sealed class FailingAdapter : IErpOrderAdapter
{
public EnovaAdapterInfo Initialize(EnovaAdapterConfiguration configuration) =>
new("1.0.0", "2604.4.4.0", "2604.4.4.0", configuration.Connection.DatabaseName);
public ErpAdapterInfo Initialize(ErpAdapterConfiguration configuration) =>
new("1.0.0", "enova365", "2604.4.4.0", configuration.Connection.DatabaseName, ["PLN"]);
public void CheckConnection() { }
public void Dispose() { }
public OrderResult CreateOrder(OrderRequest request) =>
throw new EnovaOperationException(
throw new ErpOperationException(
"eu.smartb2b.company_not_found",
$"Nie znaleziono kontrahenta o kodzie '{request.CustomerCode}'.");
}

View File

@@ -7,7 +7,8 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj" />
<ProjectReference Include="../../src/SmartB2B.Enova.Contracts/SmartB2B.Enova.Contracts.csproj" />
<ProjectReference Include="../../src/SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" />
<ProjectReference Include="../../src/SmartB2B.Sync.Service/SmartB2B.Sync.Service.csproj" />
<ProjectReference Include="../../src/SmartB2B.Enova.Service/SmartB2B.Enova.Service.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,209 @@
using System.Text.Json;
using SmartB2B.Optima.Adapter;
using SmartB2B.Optima.Service.Configuration;
using SmartB2B.Sync.Contracts;
using SmartB2B.Sync.Service.Rpc;
using SmartB2B.Sync.Service.Sql;
using WampSharp.V2.Core.Contracts;
var options = ParseArguments(args);
if (!options.TryGetValue("config", out var configPath) || string.IsNullOrWhiteSpace(configPath))
{
Console.Error.WriteLine(
"Użycie: --config <optima.json> [--negative-tests] [--write-order [--approved]] " +
"[--customer-id <ID> --product-code <KOD>]");
return 2;
}
var settings = OptimaServiceSettings.Load(Path.GetFullPath(configPath));
var runtime = settings.ResolveRuntimeSettings(verboseLogging: true);
using var adapter = new OptimaOrderAdapter(runtime.InstallationPath);
var info = adapter.Initialize(runtime.AdapterConfiguration);
adapter.CheckConnection();
Console.WriteLine(
$"PASS check-config: {info.ErpName} {info.ErpVersion}, baza {info.DatabaseName}, " +
$"architektura {info.Details?["Process bitness"]}.");
var sql = new SqlRawHandler(runtime.SqlConnectionString, runtime.SqlCommandTimeoutSeconds);
var sqlResult = (SqlRawResult?)await sql.HandleAsync(
new RpcInvocation(
[],
new Dictionary<string, object?>
{
["query"] = "SELECT @1 AS parameter_value; SELECT CAST(0x0102 AS varbinary(2)) AS binary_value;",
["params"] = new object?[] { 123 }
}),
CancellationToken.None);
if (sqlResult?.Recordsets.Count != 2 ||
sqlResult.Recordsets[0].Count != 1 ||
sqlResult.Recordsets[1].Count != 1 ||
Convert.ToInt32(sqlResult.Recordsets[0][0]["parameter_value"]) != 123 ||
sqlResult.Recordsets[1][0]["binary_value"] is not IReadOnlyDictionary<string, object> binary ||
!string.Equals(Convert.ToString(binary["type"]), "Buffer", StringComparison.Ordinal) ||
binary["data"] is not int[] { Length: 2 } bytes ||
bytes[0] != 1 ||
bytes[1] != 2)
{
throw new InvalidOperationException("sql_raw nie zwrócił oczekiwanych parametrów, recordsetów lub danych binarnych.");
}
await ExpectSqlErrorAsync(sql);
Console.WriteLine("PASS sql_raw: parametry, dwa recordsety, wartość binarna i stabilny błąd SQL.");
var needsTestData = options.ContainsKey("negative-tests") || options.ContainsKey("write-order");
options.TryGetValue("customer-id", out var customerId);
options.TryGetValue("product-code", out var productCode);
if (needsTestData && (string.IsNullOrWhiteSpace(customerId) || string.IsNullOrWhiteSpace(productCode)))
{
Console.Error.WriteLine("Przy --negative-tests lub --write-order wymagane są --customer-id i --product-code.");
return 2;
}
if (options.ContainsKey("negative-tests"))
{
RunNegativeTests(adapter, runtime.AdapterConfiguration, customerId!, productCode!);
Console.WriteLine("PASS błędy ERP: kontrahent, magazyn, waluta i towar.");
}
if (!options.ContainsKey("write-order"))
{
Console.WriteLine("Test zakończony bez zapisu. Dodaj --write-order, aby utworzyć RO w buforze.");
return 0;
}
var saveMode = options.ContainsKey("approved") ? OrderSaveMode.Approved : OrderSaveMode.Buffer;
if (saveMode == OrderSaveMode.Buffer && runtime.AdapterConfiguration.SaveMode != OrderSaveMode.Buffer)
{
Console.Error.WriteLine("Runner integracyjny zezwala na zapis wyłącznie przy optima.saveMode=Buffer.");
return 2;
}
var reference = $"SMARTB2B-IT-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
var request = CreateRequest(runtime.AdapterConfiguration, customerId!, productCode!, "PLN", reference, saveMode);
var result = adapter.CreateOrder(request);
Console.WriteLine("PASS place_order:");
Console.WriteLine(JsonSerializer.Serialize(new
{
order_erp_id = result.Id,
order_erp_symbol = result.Number,
value_netto = result.Net,
value_brutto = result.Gross,
reference,
save_mode = result.SaveMode.ToString()
}, new JsonSerializerOptions { WriteIndented = true }));
return 0;
static void RunNegativeTests(
OptimaOrderAdapter adapter,
ErpAdapterConfiguration configuration,
string customerId,
string productCode)
{
ExpectError(
() => adapter.CreateOrder(CreateRequest(configuration, "2147483647", productCode, "PLN", "SMARTB2B-ERR-KNT")),
"eu.smartb2b.company_not_found");
ExpectError(
() => adapter.CreateOrder(CreateRequest(
configuration with { DefaultWarehouseCode = "2147483647" },
customerId,
productCode,
"PLN",
"SMARTB2B-ERR-MAG")),
"eu.smartb2b.erp.warehouse_not_found");
ExpectError(
() => adapter.CreateOrder(CreateRequest(configuration, customerId, productCode, "ZZZ", "SMARTB2B-ERR-WAL")),
"eu.smartb2b.erp.currency_not_found");
ExpectError(
() => adapter.CreateOrder(CreateRequest(configuration, customerId, "SMARTB2B_MISSING", "PLN", "SMARTB2B-ERR-TWR")),
"eu.smartb2b.product_not_found");
}
static OrderRequest CreateRequest(
ErpAdapterConfiguration configuration,
string customerId,
string productCode,
string currency,
string reference,
OrderSaveMode saveMode = OrderSaveMode.Buffer) => new()
{
CurrencyIso = currency,
CustomerCode = customerId,
CustomerReference = reference,
Notes = "Automatyczny test integracyjny SmartB2B",
DocumentDefinition = configuration.DocumentDefinition,
WarehouseCode = configuration.DefaultWarehouseCode,
SaveMode = saveMode,
Items =
[
new OrderLineRequest
{
ProductCode = productCode,
Quantity = 2m,
UnitPriceNet = 10m,
UnitPriceGross = 10m,
Discount = 5m
}
]
};
static void ExpectError(Action action, string expectedUri)
{
try
{
action();
throw new InvalidOperationException($"Oczekiwano błędu {expectedUri}.");
}
catch (ErpOperationException exception) when (exception.ErrorUri == expectedUri)
{
}
}
static async Task ExpectSqlErrorAsync(SqlRawHandler sql)
{
try
{
_ = await sql.HandleAsync(
new RpcInvocation(
[],
new Dictionary<string, object?>
{
["query"] = "SELECT * FROM CDN.__SMARTB2B_MISSING_TABLE__"
}),
CancellationToken.None);
throw new InvalidOperationException("Oczekiwano błędu eu.smartb2b.sql_error.");
}
catch (WampException exception) when (exception.ErrorUri == "eu.smartb2b.sql_error")
{
}
}
static Dictionary<string, string?> ParseArguments(string[] arguments)
{
var result = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < arguments.Length; index++)
{
var argument = arguments[index];
if (!argument.StartsWith("--", StringComparison.Ordinal))
{
throw new ArgumentException($"Nieznany argument: {argument}");
}
var name = argument[2..];
if (string.Equals(name, "write-order", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "approved", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "negative-tests", StringComparison.OrdinalIgnoreCase))
{
result[name] = null;
continue;
}
if (++index >= arguments.Length)
{
throw new ArgumentException($"Brak wartości argumentu {argument}.");
}
result[name] = arguments[index];
}
return result;
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/SmartB2B.Optima.Adapter/SmartB2B.Optima.Adapter.csproj" />
<ProjectReference Include="../../src/SmartB2B.Optima.Service/SmartB2B.Optima.Service.csproj" />
<ProjectReference Include="../../src/SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,319 @@
using System.Collections.Concurrent;
using SmartB2B.Optima.Adapter;
using SmartB2B.Sync.Contracts;
using SmartB2B.Sync.Service.Orders;
using SmartB2B.Sync.Service.Rpc;
var tests = new (string Name, Func<Task> Run)[]
{
("Cena netto pozostaje zgodna wstecznie", NetPriceIsBackwardCompatible),
("Cena brutto jest obsługiwana addytywnie", GrossPriceIsAccepted),
("Brak obu cen jest odrzucany", MissingPricesAreRejected),
("Ujemna cena brutto jest odrzucana", NegativeGrossPriceIsRejected),
("Tryb netto wybiera price_netto", NetDocumentSelectsNetPrice),
("Tryb brutto wybiera price_brutto", GrossDocumentSelectsGrossPrice),
("Brak ceny wymaganej przez definicję jest stabilnym błędem", RequiredPriceIsEnforced),
("Handler przekazuje pełny kontrakt do Optimy", HandlerMapsOptimaContract),
("Wynik Optimy zachowuje kwargs WAMP", HandlerReturnsKeywordResult),
("Operacje zamówień są serializowane", ConcurrentOrdersAreSerialized),
("Fasada COM działa na jednym wątku STA", FacadeRunsOnSingleStaThread),
("Adapter serializuje bezpośrednie wywołania COM", AdapterSerializesComCalls)
};
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 NetPriceIsBackwardCompatible()
{
AssertNoErrors(CreateRequest(net: 10m, gross: null));
return Task.CompletedTask;
}
static Task GrossPriceIsAccepted()
{
AssertNoErrors(CreateRequest(net: null, gross: 12.30m));
return Task.CompletedTask;
}
static Task MissingPricesAreRejected()
{
AssertHasError(CreateRequest(net: null, gross: null), "price_netto lub price_brutto");
return Task.CompletedTask;
}
static Task NegativeGrossPriceIsRejected()
{
AssertHasError(CreateRequest(net: null, gross: -0.01m), "price_brutto");
return Task.CompletedTask;
}
static Task NetDocumentSelectsNetPrice()
{
AssertEqual(10m, OptimaOrderAdapter.SelectUnitPrice(1, CreateLine(10m, 12.30m), 0), "cena netto");
return Task.CompletedTask;
}
static Task GrossDocumentSelectsGrossPrice()
{
AssertEqual(12.30m, OptimaOrderAdapter.SelectUnitPrice(2, CreateLine(10m, 12.30m), 0), "cena brutto");
return Task.CompletedTask;
}
static Task RequiredPriceIsEnforced()
{
try
{
_ = OptimaOrderAdapter.SelectUnitPrice(2, CreateLine(10m, null), 3);
throw new InvalidOperationException("Oczekiwano błędu wymaganej ceny brutto.");
}
catch (ErpOperationException exception) when (
exception.ErrorUri == "eu.smartb2b.erp.invalid_order_lines" &&
exception.Message.Contains("lines[3].price_brutto", StringComparison.Ordinal))
{
return Task.CompletedTask;
}
}
static async Task HandlerMapsOptimaContract()
{
using var adapter = new CapturingAdapter();
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
await handler.HandleAsync(CreateInvocation(), CancellationToken.None);
var request = adapter.LastRequest ?? throw new InvalidOperationException("Adapter nie otrzymał zamówienia.");
AssertEqual("EUR", request.CurrencyIso, "waluta");
AssertEqual("123", request.CustomerCode, "kontrahent");
AssertEqual("7", request.WarehouseCode, "magazyn");
AssertEqual(100m, request.Items[0].UnitPriceNet, "netto");
AssertEqual(123m, request.Items[0].UnitPriceGross, "brutto");
}
static async Task HandlerReturnsKeywordResult()
{
using var adapter = new CapturingAdapter();
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
var result = await handler.HandleAsync(CreateInvocation(), CancellationToken.None) as KeywordResult
?? throw new InvalidOperationException("Brak wyniku kwargs.");
AssertEqual(42, result.Values["order_erp_id"], "ID");
AssertEqual("RO/1/2026", result.Values["order_erp_symbol"], "numer");
AssertEqual(100m, result.Values["value_netto"], "netto");
AssertEqual(123m, result.Values["value_brutto"], "brutto");
}
static async Task ConcurrentOrdersAreSerialized()
{
using var adapter = new CapturingAdapter(delayMilliseconds: 100);
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
await Task.WhenAll(
handler.HandleAsync(CreateInvocation(), CancellationToken.None),
handler.HandleAsync(CreateInvocation(), CancellationToken.None));
AssertEqual(1, adapter.MaximumConcurrency, "maksymalna równoległość adaptera");
}
static Task FacadeRunsOnSingleStaThread()
{
var facade = new CapturingComFacade();
using var adapter = new OptimaOrderAdapter("C:\\Optima-Test", facade);
var info = adapter.Initialize(CreateConfiguration());
adapter.CheckConnection();
_ = adapter.CreateOrder(CreateRequest(10m, 12.30m));
AssertEqual(1, facade.ThreadIds.Distinct().Count(), "liczba wątków COM");
AssertEqual(ApartmentState.STA, facade.ApartmentStates.Distinct().Single(), "apartment COM");
AssertEqual("Buffer", info.Details?["Save mode"], "tryb zapisu w diagnostyce");
return Task.CompletedTask;
}
static async Task AdapterSerializesComCalls()
{
var facade = new CapturingComFacade(delayMilliseconds: 100);
using var adapter = new OptimaOrderAdapter("C:\\Optima-Test", facade);
adapter.Initialize(CreateConfiguration());
var request = CreateRequest(10m, 12.30m);
await Task.WhenAll(
Task.Run(() => adapter.CreateOrder(request)),
Task.Run(() => adapter.CreateOrder(request)));
AssertEqual(1, facade.MaximumConcurrency, "maksymalna równoległość fasady COM");
}
static RpcInvocation CreateInvocation() => new(
[],
new Dictionary<string, object?>
{
["currency_iso"] = "eur",
["companyErpId"] = "123",
["warehouseErpId"] = "7",
["purchase_order_number"] = "PO/OPTIMA/1",
["notes"] = "Test",
["lines"] = new[]
{
new PlaceOrderLine
{
Symbol = "TOWAR",
Quantity = 2m,
PriceNetto = 100m,
PriceBrutto = 123m,
Discount = 12.5m
}
}
});
static ErpAdapterConfiguration CreateConfiguration() => new(
new ErpConnectionOptions("Firma demo", "ADMIN", "", "Server=.;Database=demo"),
"RO",
"1",
OrderSaveMode.Buffer);
static OrderRequest CreateRequest(decimal? net, decimal? gross) => new()
{
CurrencyIso = "PLN",
CustomerCode = "123",
DocumentDefinition = "RO",
WarehouseCode = "1",
Items = [CreateLine(net, gross)]
};
static OrderLineRequest CreateLine(decimal? net, decimal? gross) => new()
{
ProductCode = "TOWAR",
Quantity = 1m,
UnitPriceNet = net,
UnitPriceGross = gross,
Discount = 0m
};
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 text)
{
var errors = OrderValidator.Validate(request);
if (!errors.Any(error => error.Contains(text, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"Oczekiwano '{text}', otrzymano: {string.Join(" | ", errors)}");
}
}
static void AssertEqual(object? expected, object? actual, string name)
{
if (!Equals(expected, actual))
{
throw new InvalidOperationException($"{name}: oczekiwano '{expected}', otrzymano '{actual}'.");
}
}
file sealed class CapturingAdapter : IErpOrderAdapter
{
private readonly int _delayMilliseconds;
private int _concurrency;
private int _maximumConcurrency;
public CapturingAdapter(int delayMilliseconds = 0) => _delayMilliseconds = delayMilliseconds;
public OrderRequest? LastRequest { get; private set; }
public int MaximumConcurrency => _maximumConcurrency;
public ErpAdapterInfo Initialize(ErpAdapterConfiguration configuration) =>
new("1.0", "Comarch ERP Optima", "2026.5.1", configuration.Connection.DatabaseName, []);
public void CheckConnection() { }
public OrderResult CreateOrder(OrderRequest request)
{
var active = Interlocked.Increment(ref _concurrency);
InterlockedExtensions.Max(ref _maximumConcurrency, active);
try
{
LastRequest = request;
if (_delayMilliseconds > 0) Thread.Sleep(_delayMilliseconds);
return new OrderResult(
42, "RO/1/2026", request.SaveMode, request.CustomerCode,
request.WarehouseCode ?? "1", request.CustomerReference, request.Items.Count,
100m, 23m, 123m, request.Items.Any(line => line.Discount > 0));
}
finally
{
Interlocked.Decrement(ref _concurrency);
}
}
public void Dispose() { }
}
file static class InterlockedExtensions
{
public static void Max(ref int target, int value)
{
while (true)
{
var current = Volatile.Read(ref target);
if (current >= value || Interlocked.CompareExchange(ref target, value, current) == current) return;
}
}
}
file sealed class CapturingComFacade : IOptimaComFacade
{
private readonly int _delayMilliseconds;
private int _concurrency;
private int _maximumConcurrency;
public CapturingComFacade(int delayMilliseconds = 0) => _delayMilliseconds = delayMilliseconds;
public ConcurrentBag<int> ThreadIds { get; } = [];
public ConcurrentBag<ApartmentState> ApartmentStates { get; } = [];
public int MaximumConcurrency => _maximumConcurrency;
public OptimaRuntimeInfo InspectRuntime(string installationPath) =>
new("2026.5.1.6382", "CDNBase.Application", "x64");
public void CheckConnection(ErpAdapterConfiguration configuration) => Capture(() => { });
public OrderResult CreateOrder(ErpAdapterConfiguration configuration, OrderRequest request) => Capture(() =>
new OrderResult(
84, "RO/2/2026", request.SaveMode, request.CustomerCode,
request.WarehouseCode ?? "1", request.CustomerReference, request.Items.Count,
10m, 2.30m, 12.30m, false));
private T Capture<T>(Func<T> action)
{
var active = Interlocked.Increment(ref _concurrency);
InterlockedExtensions.Max(ref _maximumConcurrency, active);
try
{
ThreadIds.Add(Environment.CurrentManagedThreadId);
ApartmentStates.Add(Thread.CurrentThread.GetApartmentState());
if (_delayMilliseconds > 0) Thread.Sleep(_delayMilliseconds);
return action();
}
finally
{
Interlocked.Decrement(ref _concurrency);
}
}
private void Capture(Action action) => Capture(() =>
{
action();
return true;
});
}

View File

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