diff --git a/OPTIMA-INTEGRATION-TESTS.md b/OPTIMA-INTEGRATION-TESTS.md index 5053a4f..4f73f03 100644 --- a/OPTIMA-INTEGRATION-TESTS.md +++ b/OPTIMA-INTEGRATION-TESTS.md @@ -55,4 +55,29 @@ dotnet run --project .\tests\SmartB2B.Optima.IntegrationTests -- --config .\src\ Test odczytuje zapisane wartości z `CDN.DokAtrybuty` i sprawdza, że próba użycia nieznanego kodu nie utworzyła RO. +## Waluty i zamówienia zagraniczne + +Runner przyjmuje `--currency` (domyślnie PLN), `--international` i `--eu`. +Przykład zamówienia unijnego w EUR w przygotowanej firmie testowej: + +```powershell +dotnet run --project .\tests\SmartB2B.Optima.IntegrationTests -- --config .\src\SmartB2B.Optima.Service\config\optima.json --write-order --customer-id 2 --product-code SMARTB2B_TEST_TWR --currency EUR --international --eu +``` + +Powtórz dla eksportu spoza UE (`--currency USD --international`), klienta krajowego +(`--currency PLN --eu`) oraz zagranicznego zamówienia w PLN +(`--currency PLN --international --eu`). Użyj kontrahentów i walut przygotowanych +w firmie testowej. `--eu` bez `--international` oznacza klienta krajowego. + +Po każdym zapisie runner odczytuje nagłówek i pozycje z bazy. Weryfikuje walutę, +rodzaj transakcji (0 / 3 / 1), zgodność sum w walucie z wynikiem adaptera, a dla +zamówień zagranicznych także stawki 0% na pozycjach i zerową kwotę VAT. + +W Enovie analogiczne wywołanie WAMP powinno zapisać ZO z cenami i płatnością +w `currency_iso`, rodzajem VAT na danych dokumentu `Krajowy` / `Unijny` / +`Eksportowy`, oraz zwrócić sumy w walucie zamówienia. Sprawdź ZO w EUR dla +kontrahenta unijnego będącego podatnikiem VAT, eksport w USD, ZO krajowe w PLN +i unijne ZO w PLN. Definicja ZO i ustawienia podatkowe kontrahenta muszą pozwalać +ERP dobrać właściwe stawki; dla WDT pozycje powinny mieć 0%. + Przed testami trybu `Approved` wykonaj kopię bezpieczeństwa bazy lub użyj firmy przeznaczonej wyłącznie do testów. diff --git a/README.md b/README.md index 56ebd28..a66da7c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ procedury WAMP. - Zmiana wersji lub katalogu Enovy wymaga restartu usługi. Start kończy się błędem, jeżeli nowy zestaw DLL nie jest zgodny z adapterem. - Operacje Enovy są wykonywane pojedynczo i w osobnych sesjach. Zapytania SQL korzystają z puli połączeń i mogą działać równolegle. - ZO jest zapisywane transakcyjnie, domyślnie do bufora. Powtarzający się numer obcy nie jest blokowany. -- Pierwsza wersja obsługuje PLN, `price_netto` i rabat procentowy `0-100`. +- Adaptery obsługują waluty dostępne w ERP według `currency_iso`, `price_netto` i rabat procentowy `0-100`. Optima obsługuje również `price_brutto` zgodnie z definicją dokumentu. - Sentry raportuje błędy krytyczne procesu oraz nieoczekiwane błędy procedur Enovy i SQL, oznaczone portalem i nazwą procedury WAMP. ## Konfiguracja @@ -114,6 +114,8 @@ Wycofanie polega na uruchomieniu `release-enova/daemon/uninstall.cmd` i ponownym - `eu.smartb2b.place_order` przyjmuje `currency_iso`, `companyErpId`, `warehouseErpId`, `purchase_order_number`, `notes` i `lines[]` z polami `symbol`, `quantity`, opcjonalnymi `price_netto` i `price_brutto` oraz `discount`. Dla Optimy opcjonalne `extraFields` jest mapą kodów atrybutów dokumentu (`DefAtrybuty.DeA_Kod`) na wartości tekstowe, np. `{"ŹRÓDŁO":"B2B"}`. Nieznany kod lub wartość odrzucona przez Optimę przerywa zapis zamówienia. Enova wymaga `price_netto`; Optima wybiera cenę zgodnie z `TypNB` definicji dokumentu. Wynik jest zwracany jako kwargs: `order_erp_id`, `order_erp_symbol`, `value_netto`, `value_brutto`, `stocks`. - Każde wywołanie `place_order` jest logowane przed walidacją do standardowego logu usługi jako jedna linia JSON z pełnymi `args` i `kwargs`, w tym `extraFields`. Działa to także bez `/verbose`; log zawiera niezmienione wartości przesłane przez portal i można go odczytać przez `eu.smartb2b.sync.get_log`. +- Flagi logiczne `companyIsInternational` i `companyIsInEuropeanUnion` są zgodne z `WampOrder.php`. Dla klienta krajowego (`companyIsInternational=false`, także gdy należy do UE) Enova ustawia na danych dokumentu `RodzajPodmiotu.Krajowy`, a Optima `Export=0`. Dla zagranicznego klienta z UE jest to `Unijny` / `Export=3`, a dla klienta spoza UE `Eksportowy` / `Export=1`. Klasyfikacja jest ustawiana przed pozycjami; stawki VAT dobiera ERP zgodnie z definicją dokumentu i ustawieniami podatkowymi kontrahenta. Brak flag oznacza `false` dla zgodności ze starszymi wywołaniami. +- Waluta dokumentu i cen pochodzi z `currency_iso`, niezależnie od kraju klienta. Enova ustawia walutę płatności przez `BruttoCy`, walutę cen przez `DoubleCy`; Optima przez `WalutaSymbol`. Nieznana waluta zwraca `eu.smartb2b.erp.currency_not_found`. Wynik `value_netto` i `value_brutto` w obu adapterach jest wyrażony w walucie zamówienia. - `eu.smartb2b.sql_raw` przyjmuje `query` i opcjonalne `params`; parametry odpowiadają kolejno `@1`, `@2`, itd. Wynik ma pola `recordsets`, `recordset`, `output`, `rowsAffected`. - Diagnostyka: `eu.smartb2b.sync.get_version`, `get_info`, `get_log`, `get_error_log`. diff --git a/release-enova/SmartB2B.Sync.Contracts.dll b/release-enova/SmartB2B.Sync.Contracts.dll index e8cb390..fc3d17b 100644 Binary files a/release-enova/SmartB2B.Sync.Contracts.dll and b/release-enova/SmartB2B.Sync.Contracts.dll differ diff --git a/release-enova/SmartB2B.Sync.Contracts.pdb b/release-enova/SmartB2B.Sync.Contracts.pdb index 16a3f29..db2ac30 100644 Binary files a/release-enova/SmartB2B.Sync.Contracts.pdb and b/release-enova/SmartB2B.Sync.Contracts.pdb differ diff --git a/release-enova/SmartB2B.Sync.Service.dll b/release-enova/SmartB2B.Sync.Service.dll index d928549..0ca7540 100644 Binary files a/release-enova/SmartB2B.Sync.Service.dll and b/release-enova/SmartB2B.Sync.Service.dll differ diff --git a/release-enova/SmartB2B.Sync.Service.pdb b/release-enova/SmartB2B.Sync.Service.pdb index 1afdf80..1bbd903 100644 Binary files a/release-enova/SmartB2B.Sync.Service.pdb and b/release-enova/SmartB2B.Sync.Service.pdb differ diff --git a/release-enova/adapters/SmartB2B.Enova.Adapter.dll b/release-enova/adapters/SmartB2B.Enova.Adapter.dll index 391f81e..1c361eb 100644 Binary files a/release-enova/adapters/SmartB2B.Enova.Adapter.dll and b/release-enova/adapters/SmartB2B.Enova.Adapter.dll differ diff --git a/release-optima/SmartB2B.Optima.Adapter.dll b/release-optima/SmartB2B.Optima.Adapter.dll index 3d9897e..53fe561 100644 Binary files a/release-optima/SmartB2B.Optima.Adapter.dll and b/release-optima/SmartB2B.Optima.Adapter.dll differ diff --git a/release-optima/SmartB2B.Optima.Adapter.pdb b/release-optima/SmartB2B.Optima.Adapter.pdb index 881437c..0dc22e1 100644 Binary files a/release-optima/SmartB2B.Optima.Adapter.pdb and b/release-optima/SmartB2B.Optima.Adapter.pdb differ diff --git a/release-optima/SmartB2B.Sync.Contracts.dll b/release-optima/SmartB2B.Sync.Contracts.dll index 4788d29..fc3d17b 100644 Binary files a/release-optima/SmartB2B.Sync.Contracts.dll and b/release-optima/SmartB2B.Sync.Contracts.dll differ diff --git a/release-optima/SmartB2B.Sync.Contracts.pdb b/release-optima/SmartB2B.Sync.Contracts.pdb index d82a385..db2ac30 100644 Binary files a/release-optima/SmartB2B.Sync.Contracts.pdb and b/release-optima/SmartB2B.Sync.Contracts.pdb differ diff --git a/release-optima/SmartB2B.Sync.Service.dll b/release-optima/SmartB2B.Sync.Service.dll index e40ba0f..0ca7540 100644 Binary files a/release-optima/SmartB2B.Sync.Service.dll and b/release-optima/SmartB2B.Sync.Service.dll differ diff --git a/release-optima/SmartB2B.Sync.Service.pdb b/release-optima/SmartB2B.Sync.Service.pdb index 6a56e96..1bbd903 100644 Binary files a/release-optima/SmartB2B.Sync.Service.pdb and b/release-optima/SmartB2B.Sync.Service.pdb differ diff --git a/src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs b/src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs index 1b2c0d9..cfe2a0f 100644 --- a/src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs +++ b/src/SmartB2B.Enova.Adapter/EnovaOrderAdapter.cs @@ -4,18 +4,19 @@ using SmartB2B.Sync.Contracts; using Soneta.Business; using Soneta.Business.App; using Soneta.CRM; +using Soneta.Core; using Soneta.Handel; using Soneta.Magazyny; using Soneta.Start; using Soneta.Tools; using Soneta.Towary; using Soneta.Types; +using Soneta.Waluty; namespace SmartB2B.Enova.Adapter; public sealed class EnovaOrderAdapter : IErpOrderAdapter { - private const string CurrencySymbol = "PLN"; private static readonly object RuntimeLock = new(); private static bool _runtimeLoaded; private ErpAdapterConfiguration? _configuration; @@ -34,7 +35,7 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter "enova365", GetAssemblyVersion(typeof(Session).Assembly), configuration.Connection.DatabaseName, - ["PLN", "price_netto", "Buffer", "Approved"], + ["multi_currency", "international_orders", "price_netto", "Buffer", "Approved"], new Dictionary { ["Soneta.Business version"] = GetAssemblyVersion(typeof(Session).Assembly), @@ -80,14 +81,6 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter var database = _database ?? throw new InvalidOperationException("Połączenie z bazą Enovy nie zostało zainicjalizowane."); - if (!string.Equals(request.CurrencyIso, CurrencySymbol, StringComparison.OrdinalIgnoreCase)) - { - throw new ErpOperationException( - "eu.smartb2b.erp.currency_not_supported", - $"Waluta '{request.CurrencyIso}' nie jest obsługiwana. Wymagana waluta: {CurrencySymbol}.", - new Dictionary { ["currency_iso"] = request.CurrencyIso }); - } - var errors = OrderValidator.Validate(request); if (errors.Count > 0) { @@ -143,6 +136,11 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter var crm = CRMModule.GetInstance(session); var towary = TowaryModule.GetInstance(session); var warehouses = MagazynyModule.GetInstance(session).Magazyny; + _ = WalutyModule.GetInstance(session).Waluty.WgSymbolu[request.CurrencyIso] + ?? throw new ErpOperationException( + "eu.smartb2b.erp.currency_not_found", + $"Nie znaleziono waluty '{request.CurrencyIso}'.", + new Dictionary { ["currency_iso"] = request.CurrencyIso }); var definition = handel.DefDokHandlowych.WgSymbolu[request.DocumentDefinition] ?? throw new ErpOperationException( @@ -185,6 +183,9 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter document.Definicja = definition; document.Kontrahent = customer; document.Magazyn = warehouse; + // Zmieniamy dane na dokumencie, bez modyfikowania kartoteki kontrahenta. + document.DaneKontrahenta.RodzajPodmiotu = GetCustomerType(request); + document.BruttoCy = new Currency(0m, request.CurrencyIso); Verbose(verboseLogging, "Enova: dodano nagłówek dokumentu."); if (!string.IsNullOrWhiteSpace(request.CustomerReference)) @@ -217,7 +218,7 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter line.UnitPriceNet ?? throw new ErpOperationException( "eu.smartb2b.erp.invalid_order_lines", $"lines[{index}].price_netto jest wymagane przez enova365."), - CurrencySymbol); + request.CurrencyIso); position.UstawRabat(new Percent(line.Discount / 100m), ręcznie: true); Verbose(verboseLogging, $"Enova: dodano pozycję {index + 1}."); } @@ -242,6 +243,8 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter verboseLogging, $"Enova: session.Save() zakończone; numer={document.Numer.Pelny}, ID={document.ID}."); + var net = document.Suma.NettoCy.Value; + var gross = document.BruttoCy.Value; return new OrderResult( document.ID, document.Numer.Pelny.ToString(), @@ -250,12 +253,17 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter warehouse.Symbol, document.Obcy.Numer, request.Items.Count, - document.Suma.Netto, - document.Suma.VAT, - document.Suma.Brutto, + net, + gross - net, + gross, request.Items.Any(item => item.Discount > 0)); } + internal static RodzajPodmiotu GetCustomerType(OrderRequest request) => + request.CompanyIsInternational + ? (request.CompanyIsInEuropeanUnion ? RodzajPodmiotu.Unijny : RodzajPodmiotu.Eksportowy) + : RodzajPodmiotu.Krajowy; + private static void EnsureRuntimeLoaded() { if (_runtimeLoaded) diff --git a/src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj b/src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj index 089c735..1bcdd91 100644 --- a/src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj +++ b/src/SmartB2B.Enova.Adapter/SmartB2B.Enova.Adapter.csproj @@ -6,6 +6,7 @@ true + diff --git a/src/SmartB2B.Optima.Adapter/LateBoundOptimaComFacade.cs b/src/SmartB2B.Optima.Adapter/LateBoundOptimaComFacade.cs index dedef04..0069d44 100644 --- a/src/SmartB2B.Optima.Adapter/LateBoundOptimaComFacade.cs +++ b/src/SmartB2B.Optima.Adapter/LateBoundOptimaComFacade.cs @@ -85,7 +85,7 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade } } - private static OrderResult CreateOrderInSession(object session, OrderRequest request, bool verboseLogging) + internal static OrderResult CreateOrderInSession(object session, OrderRequest request, bool verboseLogging) { object? documents = null; object? document = null; @@ -150,7 +150,10 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade doc.NumerObcy = request.CustomerReference?.Trim() ?? string.Empty; doc.Uwagi = request.Notes?.Trim() ?? string.Empty; doc.WalutaSymbol = request.CurrencyIso; - doc.Export = 0; + // Ustaw rodzaj transakcji przed dodaniem towarów, aby Optima dobrała stawki VAT. + doc.Export = request.CompanyIsInternational + ? (request.CompanyIsInEuropeanUnion ? 3 : 1) + : 0; AddDocumentAttributes(comSession, doc, request.ExtraFields); elements = doc.Elementy; diff --git a/src/SmartB2B.Optima.Adapter/OptimaOrderAdapter.cs b/src/SmartB2B.Optima.Adapter/OptimaOrderAdapter.cs index c8e1050..9f9dd44 100644 --- a/src/SmartB2B.Optima.Adapter/OptimaOrderAdapter.cs +++ b/src/SmartB2B.Optima.Adapter/OptimaOrderAdapter.cs @@ -41,7 +41,7 @@ public sealed class OptimaOrderAdapter : IErpOrderAdapter "Comarch ERP Optima", runtime.ErpVersion, configuration.Connection.DatabaseName, - ["price_netto", "price_brutto", "multi_currency", "extraFields", "Buffer", "Approved", "COM STA"], + ["price_netto", "price_brutto", "multi_currency", "international_orders", "extraFields", "Buffer", "Approved", "COM STA"], new Dictionary { ["COM ProgID"] = runtime.ComProgId, diff --git a/src/SmartB2B.Sync.Contracts/SyncContracts.cs b/src/SmartB2B.Sync.Contracts/SyncContracts.cs index de69c66..817886d 100644 --- a/src/SmartB2B.Sync.Contracts/SyncContracts.cs +++ b/src/SmartB2B.Sync.Contracts/SyncContracts.cs @@ -25,6 +25,10 @@ public sealed class OrderRequest public string CustomerCode { get; init; } = string.Empty; + public bool CompanyIsInternational { get; init; } + + public bool CompanyIsInEuropeanUnion { get; init; } + public string? CustomerReference { get; init; } public string? Notes { get; init; } diff --git a/src/SmartB2B.Sync.Service/Orders/PlaceOrderHandler.cs b/src/SmartB2B.Sync.Service/Orders/PlaceOrderHandler.cs index bc10404..bf20ad6 100644 --- a/src/SmartB2B.Sync.Service/Orders/PlaceOrderHandler.cs +++ b/src/SmartB2B.Sync.Service/Orders/PlaceOrderHandler.cs @@ -36,6 +36,8 @@ public sealed class PlaceOrderHandler { CurrencyIso = invocation.Get("currency_iso")?.Trim().ToUpperInvariant() ?? string.Empty, CustomerCode = invocation.Get("companyErpId") ?? string.Empty, + CompanyIsInternational = invocation.Get("companyIsInternational"), + CompanyIsInEuropeanUnion = invocation.Get("companyIsInEuropeanUnion"), CustomerReference = invocation.Get("purchase_order_number"), Notes = invocation.Get("notes"), DocumentDefinition = _configuration.DocumentDefinition, diff --git a/tests/SmartB2B.Enova.Tests/Program.cs b/tests/SmartB2B.Enova.Tests/Program.cs index af22ce2..99f6768 100644 --- a/tests/SmartB2B.Enova.Tests/Program.cs +++ b/tests/SmartB2B.Enova.Tests/Program.cs @@ -3,6 +3,8 @@ using System.Reflection; using System.Runtime.Loader; using SmartB2B.Sync.Contracts; using SmartB2B.Sync.Service; +using SmartB2B.Enova.Adapter; +using Soneta.Core; using SmartB2B.Enova.Service; using SmartB2B.Enova.Service.Configuration; using SmartB2B.Sync.Service.Orders; @@ -29,7 +31,8 @@ var tests = new (string Name, Func Run)[] ("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), + ("Waluty i flagi kontrahenta z WampOrder są obsługiwane", ForeignCurrencyMapping), + ("Rodzaj VAT Enovy wynika z obu flag kontrahenta", EnovaCustomerTypeMapping), ("Błąd adaptera jest wypisywany na stderr", AdapterErrorIsWrittenToStderr), ("Brak katalogu Enovy jest wykrywany", MissingEnovaDirectory), ("Sekrety są odczytywane z pliku konfiguracji", SecretsComeFromConfigurationFile), @@ -43,8 +46,15 @@ var tests = new (string Name, Func Run)[] }; var failures = 0; +var skipped = 0; foreach (var test in tests) { + if (!OperatingSystem.IsWindows() && test.Run == (Func)DatabaseRegistrationUsesConnectionString) + { + skipped++; + Console.WriteLine($"SKIP: {test.Name}: test wymaga instalacji Enovy na Windows."); + continue; + } try { await test.Run(); @@ -57,7 +67,7 @@ foreach (var test in tests) } } -Console.WriteLine($"Wynik: {tests.Length - failures}/{tests.Length} testów zaliczonych."); +Console.WriteLine($"Wynik: {tests.Length - skipped - failures}/{tests.Length - skipped} testów zaliczonych, pominięto: {skipped}."); return failures == 0 ? 0 : 1; static Task SqlProviderIsSupported() @@ -263,13 +273,42 @@ static async Task MissingCurrency() await AssertWampError(handler.HandleAsync(invocation, CancellationToken.None), "eu.smartb2b.missing_currency"); } -static async Task UnsupportedCurrency() +static async Task ForeignCurrencyMapping() { - var handler = new PlaceOrderHandler(new FakeAdapter(), CreateConfiguration()); - var invocation = CreateInvocation("EUR"); - await AssertWampError( - handler.HandleAsync(invocation, CancellationToken.None), - "eu.smartb2b.erp.currency_not_supported"); + foreach (var currency in new[] { "EUR", "USD", "CZK", "PLN" }) + foreach (var (international, eu) in new[] { (false, true), (true, true), (true, false) }) + { + var adapter = new FakeAdapter(); + var arguments = new Dictionary(CreateInvocation($" {currency.ToLowerInvariant()} ").KeywordArguments) + { + ["companyIsInternational"] = international, + ["companyIsInEuropeanUnion"] = eu + }; + await new PlaceOrderHandler(adapter, CreateConfiguration()) + .HandleAsync(new RpcInvocation([], arguments), CancellationToken.None); + AssertEqual(currency, adapter.LastRequest?.CurrencyIso, "currency_iso"); + AssertEqual(international, adapter.LastRequest?.CompanyIsInternational, "companyIsInternational"); + AssertEqual(eu, adapter.LastRequest?.CompanyIsInEuropeanUnion, "companyIsInEuropeanUnion"); + } +} + +static Task EnovaCustomerTypeMapping() +{ + foreach (var (international, eu, expected) in new[] + { + (false, true, RodzajPodmiotu.Krajowy), + (false, false, RodzajPodmiotu.Krajowy), + (true, true, RodzajPodmiotu.Unijny), + (true, false, RodzajPodmiotu.Eksportowy) + }) + { + AssertEqual(expected, EnovaOrderAdapter.GetCustomerType(new OrderRequest + { + CompanyIsInternational = international, + CompanyIsInEuropeanUnion = eu + }), "rodzaj podmiotu na dokumencie"); + } + return Task.CompletedTask; } static async Task AdapterErrorIsWrittenToStderr() @@ -535,11 +574,6 @@ file sealed class FakeAdapter : IErpOrderAdapter 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, diff --git a/tests/SmartB2B.Optima.IntegrationTests/Program.cs b/tests/SmartB2B.Optima.IntegrationTests/Program.cs index b3dd056..a7c22f1 100644 --- a/tests/SmartB2B.Optima.IntegrationTests/Program.cs +++ b/tests/SmartB2B.Optima.IntegrationTests/Program.cs @@ -12,6 +12,7 @@ if (!options.TryGetValue("config", out var configPath) || string.IsNullOrWhiteSp Console.Error.WriteLine( "Użycie: --config [--negative-tests] [--write-order [--approved]] " + "[--customer-id --product-code ] " + + "[--currency --international --eu] " + "[--attribute-code-1 --attribute-value-1 --attribute-code-2 --attribute-value-2 ]"); return 2; } @@ -93,9 +94,12 @@ if (extraFields.Count > 0) Console.WriteLine("PASS extraFields: nieznany kod nie utworzył zamówienia."); } -var request = CreateRequest(runtime.AdapterConfiguration, customerId!, productCode!, "PLN", reference, saveMode, extraFields); +var currency = options.GetValueOrDefault("currency", "PLN")?.Trim().ToUpperInvariant() ?? "PLN"; +var request = CreateRequest(runtime.AdapterConfiguration, customerId!, productCode!, currency, reference, saveMode, extraFields, + options.ContainsKey("international"), options.ContainsKey("eu")); var result = adapter.CreateOrder(request); +await AssertSavedOrderAsync(sql, request, result); if (extraFields.Count > 0) { await AssertSavedAttributesAsync(sql, result.Id, extraFields); @@ -145,10 +149,14 @@ static OrderRequest CreateRequest( string currency, string reference, OrderSaveMode saveMode = OrderSaveMode.Buffer, - IReadOnlyDictionary? extraFields = null) => new() + IReadOnlyDictionary? extraFields = null, + bool international = false, + bool eu = false) => new() { CurrencyIso = currency, CustomerCode = customerId, + CompanyIsInternational = international, + CompanyIsInEuropeanUnion = eu, CustomerReference = reference, Notes = "Automatyczny test integracyjny SmartB2B", DocumentDefinition = configuration.DocumentDefinition, @@ -168,6 +176,28 @@ static OrderRequest CreateRequest( ] }; +static async Task AssertSavedOrderAsync(SqlRawHandler sql, OrderRequest request, OrderResult order) +{ + var result = (SqlRawResult?)await sql.HandleAsync(new RpcInvocation([], new Dictionary + { + ["query"] = "SELECT TrN_Export AS export_type, TrN_Waluta AS currency_iso, TrN_RazemNettoWal AS net, TrN_RazemBruttoWal AS gross " + + "FROM CDN.TraNag WHERE TrN_TrNID=@1; SELECT TrE_Stawka AS tax_rate FROM CDN.TraElem WHERE TrE_TrNId=@1;", + ["params"] = new object?[] { order.Id } + }), CancellationToken.None); + var header = result?.Recordsets[0].Single() ?? throw new InvalidOperationException("Brak zapisanego nagłówka."); + var expectedExport = request.CompanyIsInternational ? (request.CompanyIsInEuropeanUnion ? 3 : 1) : 0; + var actualCurrency = Convert.ToString(header["currency_iso"])?.Trim(); + if (string.IsNullOrEmpty(actualCurrency)) actualCurrency = "PLN"; + if (Convert.ToInt32(header["export_type"]) != expectedExport || actualCurrency != request.CurrencyIso || + Convert.ToDecimal(header["net"]) != order.Net || Convert.ToDecimal(header["gross"]) != order.Gross) + throw new InvalidOperationException("Zapisane waluta, rodzaj transakcji lub sumy w walucie nie zgadzają się z zamówieniem."); + var lines = result!.Recordsets[1]; + if (lines.Count != request.Items.Count || (request.CompanyIsInternational && + (lines.Any(line => Convert.ToDecimal(line["tax_rate"]) != 0m) || order.Vat != 0m))) + throw new InvalidOperationException("Nieprawidłowe pozycje lub stawki VAT na zamówieniu zagranicznym."); + Console.WriteLine("PASS waluta, rodzaj transakcji, sumy w walucie i VAT zapisanego dokumentu."); +} + static IReadOnlyDictionary ReadTestAttributes(Dictionary options) { var names = new[] { "attribute-code-1", "attribute-value-1", "attribute-code-2", "attribute-value-2" }; @@ -263,6 +293,8 @@ static Dictionary ParseArguments(string[] arguments) var name = argument[2..]; if (string.Equals(name, "write-order", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "approved", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "international", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "eu", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "negative-tests", StringComparison.OrdinalIgnoreCase)) { result[name] = null; diff --git a/tests/SmartB2B.Optima.Tests/FakeOrderSession.cs b/tests/SmartB2B.Optima.Tests/FakeOrderSession.cs new file mode 100644 index 0000000..2529d72 --- /dev/null +++ b/tests/SmartB2B.Optima.Tests/FakeOrderSession.cs @@ -0,0 +1,81 @@ +namespace SmartB2B.Optima.Tests; + +// Atrapa granicy COM sprawdza przebieg rzeczywistej fasady bez instalacji Optimy. +public sealed class FakeOrderSession +{ + public FakeOrderDocument Document { get; } = new(); + public bool Saved { get; private set; } + + public FakeOrderCollection CreateObject(string name) => new(name, Document); + public void Save() => Saved = true; +} + +public sealed class FakeOrderCollection(string name, FakeOrderDocument document) +{ + public object? Item(string filter) => name switch + { + "CDN.DefinicjeDokumentow" => new FakeOrderDefinition(), + "CDN.Kontrahenci" or "CDN.Magazyny" or "CDN.Towary" => new object(), + "CDN.Waluty" => new[] { "PLN", "EUR", "USD", "CZK" } + .Any(currency => filter == $"WNa_Symbol='{currency}'") ? new object() : null, + _ => throw new InvalidOperationException($"Nieoczekiwana kolekcja: {name}") + }; + + public FakeOrderDocument AddNew() => name == "CDN.DokumentyHaMag" + ? document : throw new InvalidOperationException($"Nieoczekiwana kolekcja: {name}"); +} + +public sealed class FakeOrderDefinition +{ + public int Klasa => 308; +} + +public sealed class FakeOrderDocument +{ + public int Rodzaj { get; set; } + public int TypDokumentu { get; set; } + public object? Podmiot { get; set; } + public int Bufor { get; set; } + public int MagazynZrodlowyID { get; set; } + public string? NumerObcy { get; set; } + public string? Uwagi { get; set; } + public string WalutaSymbol { get; set; } = "PLN"; + public int Export { get; set; } + public int TypNB => 1; + public int ID => 42; + public string NumerPelny => "RO/42/2026"; + public FakeOrderElements Elementy { get; } + public decimal RazemNettoWal => Elementy.Items.Sum(line => line.Net); + public decimal RazemBruttoWal => Elementy.Items.Sum(line => line.Net * (line.ExportWhenProductAssigned == 0 ? 1.23m : 1m)); + + public FakeOrderDocument() => Elementy = new(this); +} + +public sealed class FakeOrderElements(FakeOrderDocument document) +{ + public List Items { get; } = []; + public FakeOrderElement AddNew() + { + var item = new FakeOrderElement(document); + Items.Add(item); + return item; + } +} + +public sealed class FakeOrderElement(FakeOrderDocument document) +{ + public int ExportWhenProductAssigned { get; private set; } + public string? CurrencyWhenProductAssigned { get; private set; } + public object Towar + { + set + { + ExportWhenProductAssigned = document.Export; + CurrencyWhenProductAssigned = document.WalutaSymbol; + } + } + public double Ilosc { get; set; } + public decimal Cena0 { get; set; } + public double Rabat { get; set; } + public decimal Net => Convert.ToDecimal(Ilosc) * Cena0 * (1m - Convert.ToDecimal(Rabat) / 100m); +} diff --git a/tests/SmartB2B.Optima.Tests/Program.cs b/tests/SmartB2B.Optima.Tests/Program.cs index 6734e5e..238e4ea 100644 --- a/tests/SmartB2B.Optima.Tests/Program.cs +++ b/tests/SmartB2B.Optima.Tests/Program.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Globalization; using Newtonsoft.Json.Linq; using SmartB2B.Optima.Adapter; +using SmartB2B.Optima.Tests; using SmartB2B.Sync.Contracts; using SmartB2B.Sync.Service.Diagnostics; using SmartB2B.Sync.Service.Orders; @@ -18,6 +19,9 @@ var tests = new (string Name, Func Run)[] ("Tryb brutto wybiera price_brutto", GrossDocumentSelectsGrossPrice), ("Brak ceny wymaganej przez definicję jest stabilnym błędem", RequiredPriceIsEnforced), ("Handler przekazuje pełny kontrakt do Optimy", HandlerMapsOptimaContract), + ("Flagi z WampOrder rozróżniają zamówienia krajowe i zagraniczne", HandlerMapsInternationalFlags), + ("Fasada ustawia walutę i rodzaj transakcji przed pozycjami", FacadeMapsInternationalOrders), + ("Fasada odrzuca nieznaną walutę przed zapisem", FacadeRejectsUnknownCurrency), ("Handler przyjmuje i pomija opcjonalne extraFields", HandlerMapsOptionalExtraFields), ("Handler odrzuca błędne extraFields", HandlerRejectsInvalidExtraFields), ("Handler loguje pełne wywołanie place_order", HandlerLogsFullInvocation), @@ -34,8 +38,16 @@ var tests = new (string Name, Func Run)[] }; var failures = 0; +var skipped = 0; foreach (var test in tests) { + if (!OperatingSystem.IsWindows() && + (test.Run == (Func)FacadeRunsOnSingleStaThread || test.Run == (Func)AdapterSerializesComCalls)) + { + skipped++; + Console.WriteLine($"SKIP: {test.Name}: test COM STA wymaga Windows."); + continue; + } try { await test.Run(); @@ -48,7 +60,7 @@ foreach (var test in tests) } } -Console.WriteLine($"Wynik: {tests.Length - failures}/{tests.Length} testów zaliczonych."); +Console.WriteLine($"Wynik: {tests.Length - skipped - failures}/{tests.Length - skipped} testów zaliczonych, pominięto: {skipped}."); return failures == 0 ? 0 : 1; static Task NetPriceIsBackwardCompatible() @@ -115,6 +127,76 @@ static async Task HandlerMapsOptimaContract() AssertEqual(123m, request.Items[0].UnitPriceGross, "brutto"); } +static async Task HandlerMapsInternationalFlags() +{ + foreach (var (international, eu) in new[] { (false, true), (true, true), (true, false) }) + { + using var adapter = new CapturingAdapter(); + var arguments = new Dictionary(CreateInvocation().KeywordArguments) + { + ["companyIsInternational"] = new JValue(international), + ["companyIsInEuropeanUnion"] = new JValue(eu) + }; + await new PlaceOrderHandler(adapter, CreateConfiguration()) + .HandleAsync(new RpcInvocation([], arguments), CancellationToken.None); + AssertEqual(international, adapter.LastRequest?.CompanyIsInternational, "companyIsInternational"); + AssertEqual(eu, adapter.LastRequest?.CompanyIsInEuropeanUnion, "companyIsInEuropeanUnion"); + } + using var legacyAdapter = new CapturingAdapter(); + await new PlaceOrderHandler(legacyAdapter, CreateConfiguration()) + .HandleAsync(CreateInvocation(), CancellationToken.None); + AssertEqual(false, legacyAdapter.LastRequest?.CompanyIsInternational, "brak flagi zagranicznej"); + AssertEqual(false, legacyAdapter.LastRequest?.CompanyIsInEuropeanUnion, "brak flagi unijnej"); +} + +static Task FacadeMapsInternationalOrders() +{ + foreach (var currency in new[] { "PLN", "EUR", "USD", "CZK" }) + foreach (var (international, eu, export) in new[] { (false, true, 0), (true, true, 3), (true, false, 1) }) + { + var session = new FakeOrderSession(); + var result = LateBoundOptimaComFacade.CreateOrderInSession(session, new OrderRequest + { + CurrencyIso = currency, + CustomerCode = "123", + WarehouseCode = "7", + DocumentDefinition = "RO", + CompanyIsInternational = international, + CompanyIsInEuropeanUnion = eu, + Items = [new OrderLineRequest { ProductCode = "TOWAR", Quantity = 2m, + UnitPriceNet = 100m, UnitPriceGross = 123m, Discount = 10m }] + }, false); + + AssertEqual(true, session.Saved, "zapis dokumentu"); + AssertEqual(currency, session.Document.WalutaSymbol, "waluta dokumentu"); + AssertEqual(export, session.Document.Export, "rodzaj transakcji"); + AssertEqual(export, session.Document.Elementy.Items.Single().ExportWhenProductAssigned, "rodzaj przed towarem"); + AssertEqual(currency, session.Document.Elementy.Items.Single().CurrencyWhenProductAssigned, "waluta przed towarem"); + AssertEqual(180m, result.Net, "wynik netto w walucie"); + AssertEqual(international ? 180m : 221.40m, result.Gross, "wynik brutto w walucie"); + } + return Task.CompletedTask; +} + +static Task FacadeRejectsUnknownCurrency() +{ + var session = new FakeOrderSession(); + try + { + LateBoundOptimaComFacade.CreateOrderInSession(session, new OrderRequest + { + CurrencyIso = "XXX", CustomerCode = "123", WarehouseCode = "7", DocumentDefinition = "RO", + Items = [CreateLine(10m, 12.30m)] + }, false); + throw new InvalidOperationException("Oczekiwano błędu nieznanej waluty."); + } + catch (ErpOperationException exception) when (exception.ErrorUri == "eu.smartb2b.erp.currency_not_found") + { + AssertEqual(false, session.Saved, "brak zapisu dla nieznanej waluty"); + } + return Task.CompletedTask; +} + static async Task DiagnosticsReadsOpenLogs() { var directory = Path.Combine(Path.GetTempPath(), $"smartb2b-log-test-{Guid.NewGuid():N}");