Fix international order VAT and currencies in Enova and Optima

This commit is contained in:
janusz committed 2026-10-29 01:31:13 +01:00
1 parent 75ef1ed69c
commit 248d7bac64
23 files changed
+308 -34

No files matched your search

+25
View File
@@ -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. 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. Przed testami trybu `Approved` wykonaj kopię bezpieczeństwa bazy lub użyj firmy przeznaczonej wyłącznie do testów.
+3 -1
View File
@@ -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. - 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. - 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. - 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. - Sentry raportuje błędy krytyczne procesu oraz nieoczekiwane błędy procedur Enovy i SQL, oznaczone portalem i nazwą procedury WAMP.
## Konfiguracja ## 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`. - `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`. - 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`. - `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`. - Diagnostyka: `eu.smartb2b.sync.get_version`, `get_info`, `get_log`, `get_error_log`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22 -14
View File
@@ -4,18 +4,19 @@ using SmartB2B.Sync.Contracts;
using Soneta.Business; using Soneta.Business;
using Soneta.Business.App; using Soneta.Business.App;
using Soneta.CRM; using Soneta.CRM;
using Soneta.Core;
using Soneta.Handel; using Soneta.Handel;
using Soneta.Magazyny; using Soneta.Magazyny;
using Soneta.Start; using Soneta.Start;
using Soneta.Tools; using Soneta.Tools;
using Soneta.Towary; using Soneta.Towary;
using Soneta.Types; using Soneta.Types;
using Soneta.Waluty;
namespace SmartB2B.Enova.Adapter; namespace SmartB2B.Enova.Adapter;
public sealed class EnovaOrderAdapter : IErpOrderAdapter public sealed class EnovaOrderAdapter : IErpOrderAdapter
{ {
private const string CurrencySymbol = "PLN";
private static readonly object RuntimeLock = new(); private static readonly object RuntimeLock = new();
private static bool _runtimeLoaded; private static bool _runtimeLoaded;
private ErpAdapterConfiguration? _configuration; private ErpAdapterConfiguration? _configuration;
@@ -34,7 +35,7 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter
"enova365", "enova365",
GetAssemblyVersion(typeof(Session).Assembly), GetAssemblyVersion(typeof(Session).Assembly),
configuration.Connection.DatabaseName, configuration.Connection.DatabaseName,
["PLN", "price_netto", "Buffer", "Approved"], ["multi_currency", "international_orders", "price_netto", "Buffer", "Approved"],
new Dictionary<string, string> new Dictionary<string, string>
{ {
["Soneta.Business version"] = GetAssemblyVersion(typeof(Session).Assembly), ["Soneta.Business version"] = GetAssemblyVersion(typeof(Session).Assembly),
@@ -80,14 +81,6 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter
var database = _database var database = _database
?? throw new InvalidOperationException("Połączenie z bazą Enovy nie zostało zainicjalizowane."); ?? 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<string, object?> { ["currency_iso"] = request.CurrencyIso });
}
var errors = OrderValidator.Validate(request); var errors = OrderValidator.Validate(request);
if (errors.Count > 0) if (errors.Count > 0)
{ {
@@ -143,6 +136,11 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter
var crm = CRMModule.GetInstance(session); var crm = CRMModule.GetInstance(session);
var towary = TowaryModule.GetInstance(session); var towary = TowaryModule.GetInstance(session);
var warehouses = MagazynyModule.GetInstance(session).Magazyny; 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<string, object?> { ["currency_iso"] = request.CurrencyIso });
var definition = handel.DefDokHandlowych.WgSymbolu[request.DocumentDefinition] var definition = handel.DefDokHandlowych.WgSymbolu[request.DocumentDefinition]
?? throw new ErpOperationException( ?? throw new ErpOperationException(
@@ -185,6 +183,9 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter
document.Definicja = definition; document.Definicja = definition;
document.Kontrahent = customer; document.Kontrahent = customer;
document.Magazyn = warehouse; 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."); Verbose(verboseLogging, "Enova: dodano nagłówek dokumentu.");
if (!string.IsNullOrWhiteSpace(request.CustomerReference)) if (!string.IsNullOrWhiteSpace(request.CustomerReference))
@@ -217,7 +218,7 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter
line.UnitPriceNet ?? throw new ErpOperationException( line.UnitPriceNet ?? throw new ErpOperationException(
"eu.smartb2b.erp.invalid_order_lines", "eu.smartb2b.erp.invalid_order_lines",
$"lines[{index}].price_netto jest wymagane przez enova365."), $"lines[{index}].price_netto jest wymagane przez enova365."),
CurrencySymbol); request.CurrencyIso);
position.UstawRabat(new Percent(line.Discount / 100m), ręcznie: true); position.UstawRabat(new Percent(line.Discount / 100m), ręcznie: true);
Verbose(verboseLogging, $"Enova: dodano pozycję {index + 1}."); Verbose(verboseLogging, $"Enova: dodano pozycję {index + 1}.");
} }
@@ -242,6 +243,8 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter
verboseLogging, verboseLogging,
$"Enova: session.Save() zakończone; numer={document.Numer.Pelny}, ID={document.ID}."); $"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( return new OrderResult(
document.ID, document.ID,
document.Numer.Pelny.ToString(), document.Numer.Pelny.ToString(),
@@ -250,12 +253,17 @@ public sealed class EnovaOrderAdapter : IErpOrderAdapter
warehouse.Symbol, warehouse.Symbol,
document.Obcy.Numer, document.Obcy.Numer,
request.Items.Count, request.Items.Count,
document.Suma.Netto, net,
document.Suma.VAT, gross - net,
document.Suma.Brutto, gross,
request.Items.Any(item => item.Discount > 0)); 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() private static void EnsureRuntimeLoaded()
{ {
if (_runtimeLoaded) if (_runtimeLoaded)
@@ -6,6 +6,7 @@
<EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization> <EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="SmartB2B.Enova.Tests" />
<ProjectReference Include="../SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" /> <ProjectReference Include="../SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Soneta.Handel" Version="2604.4.4" /> <PackageReference Include="Soneta.Handel" Version="2604.4.4" />
@@ -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? documents = null;
object? document = null; object? document = null;
@@ -150,7 +150,10 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade
doc.NumerObcy = request.CustomerReference?.Trim() ?? string.Empty; doc.NumerObcy = request.CustomerReference?.Trim() ?? string.Empty;
doc.Uwagi = request.Notes?.Trim() ?? string.Empty; doc.Uwagi = request.Notes?.Trim() ?? string.Empty;
doc.WalutaSymbol = request.CurrencyIso; 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); AddDocumentAttributes(comSession, doc, request.ExtraFields);
elements = doc.Elementy; elements = doc.Elementy;
@@ -41,7 +41,7 @@ public sealed class OptimaOrderAdapter : IErpOrderAdapter
"Comarch ERP Optima", "Comarch ERP Optima",
runtime.ErpVersion, runtime.ErpVersion,
configuration.Connection.DatabaseName, 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<string, string> new Dictionary<string, string>
{ {
["COM ProgID"] = runtime.ComProgId, ["COM ProgID"] = runtime.ComProgId,
@@ -25,6 +25,10 @@ public sealed class OrderRequest
public string CustomerCode { get; init; } = string.Empty; public string CustomerCode { get; init; } = string.Empty;
public bool CompanyIsInternational { get; init; }
public bool CompanyIsInEuropeanUnion { get; init; }
public string? CustomerReference { get; init; } public string? CustomerReference { get; init; }
public string? Notes { get; init; } public string? Notes { get; init; }
@@ -36,6 +36,8 @@ public sealed class PlaceOrderHandler
{ {
CurrencyIso = invocation.Get<string>("currency_iso")?.Trim().ToUpperInvariant() ?? string.Empty, CurrencyIso = invocation.Get<string>("currency_iso")?.Trim().ToUpperInvariant() ?? string.Empty,
CustomerCode = invocation.Get<string>("companyErpId") ?? string.Empty, CustomerCode = invocation.Get<string>("companyErpId") ?? string.Empty,
CompanyIsInternational = invocation.Get<bool>("companyIsInternational"),
CompanyIsInEuropeanUnion = invocation.Get<bool>("companyIsInEuropeanUnion"),
CustomerReference = invocation.Get<string>("purchase_order_number"), CustomerReference = invocation.Get<string>("purchase_order_number"),
Notes = invocation.Get<string>("notes"), Notes = invocation.Get<string>("notes"),
DocumentDefinition = _configuration.DocumentDefinition, DocumentDefinition = _configuration.DocumentDefinition,
+47 -13
View File
@@ -3,6 +3,8 @@ using System.Reflection;
using System.Runtime.Loader; using System.Runtime.Loader;
using SmartB2B.Sync.Contracts; using SmartB2B.Sync.Contracts;
using SmartB2B.Sync.Service; using SmartB2B.Sync.Service;
using SmartB2B.Enova.Adapter;
using Soneta.Core;
using SmartB2B.Enova.Service; using SmartB2B.Enova.Service;
using SmartB2B.Enova.Service.Configuration; using SmartB2B.Enova.Service.Configuration;
using SmartB2B.Sync.Service.Orders; using SmartB2B.Sync.Service.Orders;
@@ -29,7 +31,8 @@ var tests = new (string Name, Func<Task> Run)[]
("place_order zwraca nazwany wynik", PlaceOrderKeywordResult), ("place_order zwraca nazwany wynik", PlaceOrderKeywordResult),
("Warstwa WAMP wysyła wynik jako kwargs", WampKeywordWireResult), ("Warstwa WAMP wysyła wynik jako kwargs", WampKeywordWireResult),
("Brak waluty zwraca stabilny błąd", MissingCurrency), ("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), ("Błąd adaptera jest wypisywany na stderr", AdapterErrorIsWrittenToStderr),
("Brak katalogu Enovy jest wykrywany", MissingEnovaDirectory), ("Brak katalogu Enovy jest wykrywany", MissingEnovaDirectory),
("Sekrety są odczytywane z pliku konfiguracji", SecretsComeFromConfigurationFile), ("Sekrety są odczytywane z pliku konfiguracji", SecretsComeFromConfigurationFile),
@@ -43,8 +46,15 @@ var tests = new (string Name, Func<Task> Run)[]
}; };
var failures = 0; var failures = 0;
var skipped = 0;
foreach (var test in tests) foreach (var test in tests)
{ {
if (!OperatingSystem.IsWindows() && test.Run == (Func<Task>)DatabaseRegistrationUsesConnectionString)
{
skipped++;
Console.WriteLine($"SKIP: {test.Name}: test wymaga instalacji Enovy na Windows.");
continue;
}
try try
{ {
await test.Run(); 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; return failures == 0 ? 0 : 1;
static Task SqlProviderIsSupported() static Task SqlProviderIsSupported()
@@ -263,13 +273,42 @@ static async Task MissingCurrency()
await AssertWampError(handler.HandleAsync(invocation, CancellationToken.None), "eu.smartb2b.missing_currency"); 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()); foreach (var currency in new[] { "EUR", "USD", "CZK", "PLN" })
var invocation = CreateInvocation("EUR"); foreach (var (international, eu) in new[] { (false, true), (true, true), (true, false) })
await AssertWampError( {
handler.HandleAsync(invocation, CancellationToken.None), var adapter = new FakeAdapter();
"eu.smartb2b.erp.currency_not_supported"); var arguments = new Dictionary<string, object?>(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() static async Task AdapterErrorIsWrittenToStderr()
@@ -535,11 +574,6 @@ file sealed class FakeAdapter : IErpOrderAdapter
public OrderResult CreateOrder(OrderRequest request) 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; LastRequest = request;
return new OrderResult( return new OrderResult(
1, 1,
@@ -12,6 +12,7 @@ if (!options.TryGetValue("config", out var configPath) || string.IsNullOrWhiteSp
Console.Error.WriteLine( Console.Error.WriteLine(
"Użycie: --config <optima.json> [--negative-tests] [--write-order [--approved]] " + "Użycie: --config <optima.json> [--negative-tests] [--write-order [--approved]] " +
"[--customer-id <ID> --product-code <KOD>] " + "[--customer-id <ID> --product-code <KOD>] " +
"[--currency <ISO> --international --eu] " +
"[--attribute-code-1 <KOD> --attribute-value-1 <TEKST> --attribute-code-2 <KOD> --attribute-value-2 <TEKST>]"); "[--attribute-code-1 <KOD> --attribute-value-1 <TEKST> --attribute-code-2 <KOD> --attribute-value-2 <TEKST>]");
return 2; return 2;
} }
@@ -93,9 +94,12 @@ if (extraFields.Count > 0)
Console.WriteLine("PASS extraFields: nieznany kod nie utworzył zamówienia."); 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); var result = adapter.CreateOrder(request);
await AssertSavedOrderAsync(sql, request, result);
if (extraFields.Count > 0) if (extraFields.Count > 0)
{ {
await AssertSavedAttributesAsync(sql, result.Id, extraFields); await AssertSavedAttributesAsync(sql, result.Id, extraFields);
@@ -145,10 +149,14 @@ static OrderRequest CreateRequest(
string currency, string currency,
string reference, string reference,
OrderSaveMode saveMode = OrderSaveMode.Buffer, OrderSaveMode saveMode = OrderSaveMode.Buffer,
IReadOnlyDictionary<string, string>? extraFields = null) => new() IReadOnlyDictionary<string, string>? extraFields = null,
bool international = false,
bool eu = false) => new()
{ {
CurrencyIso = currency, CurrencyIso = currency,
CustomerCode = customerId, CustomerCode = customerId,
CompanyIsInternational = international,
CompanyIsInEuropeanUnion = eu,
CustomerReference = reference, CustomerReference = reference,
Notes = "Automatyczny test integracyjny SmartB2B", Notes = "Automatyczny test integracyjny SmartB2B",
DocumentDefinition = configuration.DocumentDefinition, 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<string, object?>
{
["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<string, string> ReadTestAttributes(Dictionary<string, string?> options) static IReadOnlyDictionary<string, string> ReadTestAttributes(Dictionary<string, string?> options)
{ {
var names = new[] { "attribute-code-1", "attribute-value-1", "attribute-code-2", "attribute-value-2" }; var names = new[] { "attribute-code-1", "attribute-value-1", "attribute-code-2", "attribute-value-2" };
@@ -263,6 +293,8 @@ static Dictionary<string, string?> ParseArguments(string[] arguments)
var name = argument[2..]; var name = argument[2..];
if (string.Equals(name, "write-order", StringComparison.OrdinalIgnoreCase) || if (string.Equals(name, "write-order", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "approved", 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)) string.Equals(name, "negative-tests", StringComparison.OrdinalIgnoreCase))
{ {
result[name] = null; result[name] = null;
@@ -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<FakeOrderElement> 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);
}
+83 -1
View File
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Globalization; using System.Globalization;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using SmartB2B.Optima.Adapter; using SmartB2B.Optima.Adapter;
using SmartB2B.Optima.Tests;
using SmartB2B.Sync.Contracts; using SmartB2B.Sync.Contracts;
using SmartB2B.Sync.Service.Diagnostics; using SmartB2B.Sync.Service.Diagnostics;
using SmartB2B.Sync.Service.Orders; using SmartB2B.Sync.Service.Orders;
@@ -18,6 +19,9 @@ var tests = new (string Name, Func<Task> Run)[]
("Tryb brutto wybiera price_brutto", GrossDocumentSelectsGrossPrice), ("Tryb brutto wybiera price_brutto", GrossDocumentSelectsGrossPrice),
("Brak ceny wymaganej przez definicję jest stabilnym błędem", RequiredPriceIsEnforced), ("Brak ceny wymaganej przez definicję jest stabilnym błędem", RequiredPriceIsEnforced),
("Handler przekazuje pełny kontrakt do Optimy", HandlerMapsOptimaContract), ("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 przyjmuje i pomija opcjonalne extraFields", HandlerMapsOptionalExtraFields),
("Handler odrzuca błędne extraFields", HandlerRejectsInvalidExtraFields), ("Handler odrzuca błędne extraFields", HandlerRejectsInvalidExtraFields),
("Handler loguje pełne wywołanie place_order", HandlerLogsFullInvocation), ("Handler loguje pełne wywołanie place_order", HandlerLogsFullInvocation),
@@ -34,8 +38,16 @@ var tests = new (string Name, Func<Task> Run)[]
}; };
var failures = 0; var failures = 0;
var skipped = 0;
foreach (var test in tests) foreach (var test in tests)
{ {
if (!OperatingSystem.IsWindows() &&
(test.Run == (Func<Task>)FacadeRunsOnSingleStaThread || test.Run == (Func<Task>)AdapterSerializesComCalls))
{
skipped++;
Console.WriteLine($"SKIP: {test.Name}: test COM STA wymaga Windows.");
continue;
}
try try
{ {
await test.Run(); 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; return failures == 0 ? 0 : 1;
static Task NetPriceIsBackwardCompatible() static Task NetPriceIsBackwardCompatible()
@@ -115,6 +127,76 @@ static async Task HandlerMapsOptimaContract()
AssertEqual(123m, request.Items[0].UnitPriceGross, "brutto"); 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<string, object?>(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() static async Task DiagnosticsReadsOpenLogs()
{ {
var directory = Path.Combine(Path.GetTempPath(), $"smartb2b-log-test-{Guid.NewGuid():N}"); var directory = Path.Combine(Path.GetTempPath(), $"smartb2b-log-test-{Guid.NewGuid():N}");