Fix international order VAT and currencies in Enova and Optima
This commit is contained in:
1 parent
75ef1ed69c
commit
248d7bac64
23 files changed
+308
-34
No files matched your search
@@ -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<Task> 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<Task> Run)[]
|
||||
};
|
||||
|
||||
var failures = 0;
|
||||
var skipped = 0;
|
||||
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
|
||||
{
|
||||
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<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()
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ if (!options.TryGetValue("config", out var configPath) || string.IsNullOrWhiteSp
|
||||
Console.Error.WriteLine(
|
||||
"Użycie: --config <optima.json> [--negative-tests] [--write-order [--approved]] " +
|
||||
"[--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>]");
|
||||
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<string, string>? extraFields = null) => new()
|
||||
IReadOnlyDictionary<string, string>? 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<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)
|
||||
{
|
||||
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..];
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Task> 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<Task> Run)[]
|
||||
};
|
||||
|
||||
var failures = 0;
|
||||
var skipped = 0;
|
||||
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
|
||||
{
|
||||
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<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()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"smartb2b-log-test-{Guid.NewGuid():N}");
|
||||
|
||||
Reference in new issue
Block a user