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.
## 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.
+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.
- 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`.
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.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<string, string>
{
["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<string, object?> { ["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<string, object?> { ["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)
@@ -6,6 +6,7 @@
<EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="SmartB2B.Enova.Tests" />
<ProjectReference Include="../SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<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? 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;
@@ -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<string, string>
{
["COM ProgID"] = runtime.ComProgId,
@@ -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; }
@@ -36,6 +36,8 @@ public sealed class PlaceOrderHandler
{
CurrencyIso = invocation.Get<string>("currency_iso")?.Trim().ToUpperInvariant() ?? 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"),
Notes = invocation.Get<string>("notes"),
DocumentDefinition = _configuration.DocumentDefinition,
+47 -13
View File
@@ -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);
}
+83 -1
View File
@@ -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}");