Add Optima document attributes to place_order

This commit is contained in:
2026-09-17 15:16:44 +00:00
parent cd02e6af5f
commit 31daea55cf
9 changed files with 373 additions and 10 deletions

View File

@@ -45,5 +45,14 @@ Po pomyślnym `--check-config` uruchom usługę z tym samym plikiem konfiguracji
- błędy dla brakującego kontrahenta, produktu, magazynu, waluty i wymaganej ceny;
- parametryzowane `sql_raw`, wiele recordsetów, dane binarne oraz błąd SQL;
- dwa równoczesne wywołania `place_order` — dokumenty mają zostać utworzone kolejno na jednym wątku COM.
- `extraFields` z dwoma istniejącymi atrybutami dokumentu: wartości mają być widoczne na nagłówku RO; nieznany kod ma zwrócić błąd bez zapisu dokumentu.
Runner może sprawdzić ostatni scenariusz po podaniu dwóch kodów atrybutów tekstowych istniejących w 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 --attribute-code-1 SOURCE --attribute-value-1 B2B --attribute-code-2 NOTE --attribute-value-2 test
```
Test odczytuje zapisane wartości z `CDN.DokAtrybuty` i sprawdza, że próba użycia nieznanego kodu nie utworzyła RO.
Przed testami trybu `Approved` wykonaj kopię bezpieczeństwa bazy lub użyj firmy przeznaczonej wyłącznie do testów.

View File

@@ -110,7 +110,7 @@ Wycofanie polega na uruchomieniu `release-enova/daemon/uninstall.cmd` i ponownym
## Procedury WAMP
- `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`. 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`.
- `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`.

View File

@@ -151,6 +151,7 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade
doc.Uwagi = request.Notes?.Trim() ?? string.Empty;
doc.WalutaSymbol = request.CurrencyIso;
doc.Export = 0;
AddDocumentAttributes(comSession, doc, request.ExtraFields);
elements = doc.Elementy;
var calculationMode = Convert.ToInt32(doc.TypNB);
@@ -184,7 +185,19 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade
}
Verbose(verboseLogging, "Optima: zapis dokumentu przez session.Save().");
comSession.Save();
try
{
comSession.Save();
}
catch (Exception exception) when (request.ExtraFields.Count > 0)
{
var codes = request.ExtraFields.Keys.ToArray();
throw new ErpOperationException(
"eu.smartb2b.erp.invalid_document_attribute",
$"Nie udało się zapisać zamówienia z atrybutami dokumentu: {string.Join(", ", codes)}.",
new Dictionary<string, object?> { ["attributeCodes"] = codes },
exception);
}
var net = Convert.ToDecimal(doc.RazemNettoWal);
var gross = Convert.ToDecimal(doc.RazemBruttoWal);
return new OrderResult(
@@ -212,6 +225,58 @@ internal sealed class LateBoundOptimaComFacade : IOptimaComFacade
}
}
internal static void AddDocumentAttributes(
dynamic session,
dynamic document,
IReadOnlyDictionary<string, string> extraFields)
{
if (extraFields.Count == 0) return;
object? attributes = null;
try
{
attributes = document.Atrybuty;
foreach (var (code, value) in extraFields)
{
object? definition = null;
object? attribute = null;
try
{
definition = FindRequired(
session,
"CDN.DefAtrybuty",
$"DeA_Typ=4 AND DeA_Kod='{EscapeFilter(code)}'",
"eu.smartb2b.erp.document_attribute_not_found",
$"Nie znaleziono atrybutu dokumentu o kodzie '{code}'.");
attribute = ((dynamic)attributes).AddNew();
((dynamic)attribute).DeAID = Convert.ToInt32(((dynamic)definition).ID);
((dynamic)attribute).Wartosc = value;
}
catch (ErpOperationException)
{
throw;
}
catch (Exception exception)
{
throw new ErpOperationException(
"eu.smartb2b.erp.invalid_document_attribute",
$"Nie można przypisać wartości atrybutu dokumentu '{code}'.",
new Dictionary<string, object?> { ["attributeCode"] = code },
exception);
}
finally
{
ReleaseComObject(attribute);
ReleaseComObject(definition);
}
}
}
finally
{
ReleaseComObject(attributes);
}
}
private static object FindRequired(
dynamic session,
string collectionName,

View File

@@ -41,7 +41,7 @@ public sealed class OptimaOrderAdapter : IErpOrderAdapter
"Comarch ERP Optima",
runtime.ErpVersion,
configuration.Connection.DatabaseName,
["price_netto", "price_brutto", "multi_currency", "Buffer", "Approved", "COM STA"],
["price_netto", "price_brutto", "multi_currency", "extraFields", "Buffer", "Approved", "COM STA"],
new Dictionary<string, string>
{
["COM ProgID"] = runtime.ComProgId,

View File

@@ -7,5 +7,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../SmartB2B.Sync.Contracts/SmartB2B.Sync.Contracts.csproj" />
<InternalsVisibleTo Include="SmartB2B.Optima.Tests" />
</ItemGroup>
</Project>

View File

@@ -36,6 +36,8 @@ public sealed class OrderRequest
public OrderSaveMode SaveMode { get; init; } = OrderSaveMode.Buffer;
public IReadOnlyList<OrderLineRequest> Items { get; init; } = [];
public IReadOnlyDictionary<string, string> ExtraFields { get; init; } = new Dictionary<string, string>();
}
public sealed class OrderLineRequest
@@ -126,6 +128,12 @@ public static class OrderValidator
errors.Add("Definicja dokumentu jest wymagana.");
}
foreach (var field in request.ExtraFields)
{
if (string.IsNullOrWhiteSpace(field.Key)) errors.Add("Kod w extraFields nie może być pusty.");
if (field.Value is null) errors.Add($"extraFields['{field.Key}'] musi być tekstem.");
}
if (request.Items is null || request.Items.Count == 0)
{
errors.Add("Zamówienie musi zawierać co najmniej jedną pozycję.");

View File

@@ -1,4 +1,5 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SmartB2B.Sync.Contracts;
using SmartB2B.Sync.Service.Diagnostics;
using SmartB2B.Sync.Service.Rpc;
@@ -34,6 +35,7 @@ public sealed class PlaceOrderHandler
DocumentDefinition = _configuration.DocumentDefinition,
WarehouseCode = invocation.Get<string>("warehouseErpId") ?? _configuration.DefaultWarehouseCode,
SaveMode = _configuration.SaveMode,
ExtraFields = ReadExtraFields(invocation),
Items = inputLines.Select(line => new OrderLineRequest
{
ProductCode = line.Symbol ?? string.Empty,
@@ -93,6 +95,37 @@ public sealed class PlaceOrderHandler
_orderLock.Release();
}
}
private static IReadOnlyDictionary<string, string> ReadExtraFields(RpcInvocation invocation)
{
if (!invocation.Contains("extraFields")) return new Dictionary<string, string>();
JToken? token;
try { token = invocation.Get<JToken>("extraFields"); }
catch (Exception exception) when (exception is JsonException or ArgumentException or InvalidCastException or FormatException)
{
throw InvalidExtraFields("extraFields musi być mapą kodów atrybutów na wartości tekstowe.");
}
if (token is not JObject fields)
throw InvalidExtraFields("extraFields musi być mapą kodów atrybutów na wartości tekstowe.");
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var field in fields.Properties())
{
var code = field.Name.Trim();
if (code.Length == 0) throw InvalidExtraFields("Kod w extraFields nie może być pusty.");
if (field.Value.Type != JTokenType.String)
throw InvalidExtraFields($"extraFields['{code}'] musi być tekstem.");
if (!result.TryAdd(code, field.Value.Value<string>()!))
throw InvalidExtraFields($"Kod '{code}' występuje w extraFields więcej niż raz.");
}
return result;
}
private static Exception InvalidExtraFields(string message) =>
WampErrorFactory.Create("eu.smartb2b.erp.invalid_order_lines", message);
}
public sealed class PlaceOrderLine

View File

@@ -11,7 +11,8 @@ 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>]");
"[--customer-id <ID> --product-code <KOD>] " +
"[--attribute-code-1 <KOD> --attribute-value-1 <TEKST> --attribute-code-2 <KOD> --attribute-value-2 <TEKST>]");
return 2;
}
@@ -78,9 +79,28 @@ if (saveMode == OrderSaveMode.Buffer && runtime.AdapterConfiguration.SaveMode !=
}
var reference = $"SMARTB2B-IT-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
var request = CreateRequest(runtime.AdapterConfiguration, customerId!, productCode!, "PLN", reference, saveMode);
var extraFields = ReadTestAttributes(options);
if (extraFields.Count > 0)
{
var missingReference = reference + "-MISSING";
var missingCode = "__SMARTB2B_MISSING_ATTR__";
ExpectError(
() => adapter.CreateOrder(CreateRequest(
runtime.AdapterConfiguration, customerId!, productCode!, "PLN", missingReference,
OrderSaveMode.Buffer, new Dictionary<string, string> { [missingCode] = "test" })),
"eu.smartb2b.erp.document_attribute_not_found");
await AssertNoOrderWithReferenceAsync(sql, missingReference);
Console.WriteLine("PASS extraFields: nieznany kod nie utworzył zamówienia.");
}
var request = CreateRequest(runtime.AdapterConfiguration, customerId!, productCode!, "PLN", reference, saveMode, extraFields);
var result = adapter.CreateOrder(request);
if (extraFields.Count > 0)
{
await AssertSavedAttributesAsync(sql, result.Id, extraFields);
Console.WriteLine("PASS extraFields: dwa atrybuty zapisano na nagłówku zamówienia.");
}
Console.WriteLine("PASS place_order:");
Console.WriteLine(JsonSerializer.Serialize(new
{
@@ -124,7 +144,8 @@ static OrderRequest CreateRequest(
string productCode,
string currency,
string reference,
OrderSaveMode saveMode = OrderSaveMode.Buffer) => new()
OrderSaveMode saveMode = OrderSaveMode.Buffer,
IReadOnlyDictionary<string, string>? extraFields = null) => new()
{
CurrencyIso = currency,
CustomerCode = customerId,
@@ -133,6 +154,7 @@ static OrderRequest CreateRequest(
DocumentDefinition = configuration.DocumentDefinition,
WarehouseCode = configuration.DefaultWarehouseCode,
SaveMode = saveMode,
ExtraFields = extraFields ?? new Dictionary<string, string>(),
Items =
[
new OrderLineRequest
@@ -146,6 +168,56 @@ static OrderRequest CreateRequest(
]
};
static IReadOnlyDictionary<string, string> ReadTestAttributes(Dictionary<string, string?> options)
{
var names = new[] { "attribute-code-1", "attribute-value-1", "attribute-code-2", "attribute-value-2" };
if (!names.Any(options.ContainsKey)) return new Dictionary<string, string>();
if (names.Any(name => !options.ContainsKey(name)))
throw new ArgumentException("Test extraFields wymaga dwóch kodów i dwóch wartości atrybutów tekstowych.");
var first = options["attribute-code-1"]?.Trim();
var second = options["attribute-code-2"]?.Trim();
if (string.IsNullOrWhiteSpace(first) || string.IsNullOrWhiteSpace(second) || first == second)
throw new ArgumentException("Kody dwóch atrybutów muszą być różne i niepuste.");
return new Dictionary<string, string>
{
[first] = options["attribute-value-1"] ?? string.Empty,
[second] = options["attribute-value-2"] ?? string.Empty
};
}
static async Task AssertNoOrderWithReferenceAsync(SqlRawHandler sql, string reference)
{
var result = (SqlRawResult?)await sql.HandleAsync(
new RpcInvocation([], new Dictionary<string, object?>
{
["query"] = "SELECT COUNT(*) AS order_count FROM CDN.TraNag WHERE TrN_NumerObcy=@1",
["params"] = new object?[] { reference }
}), CancellationToken.None);
if (Convert.ToInt32(result?.Recordsets[0][0]["order_count"]) != 0)
throw new InvalidOperationException("Nieznany atrybut pozostawił zapisane zamówienie.");
}
static async Task AssertSavedAttributesAsync(
SqlRawHandler sql, int orderId, IReadOnlyDictionary<string, string> expected)
{
var result = (SqlRawResult?)await sql.HandleAsync(
new RpcInvocation([], new Dictionary<string, object?>
{
["query"] = "SELECT DeA_Kod, DAt_WartoscTxt FROM CDN.DokAtrybuty JOIN CDN.DefAtrybuty ON DAt_DeAId=DeA_DeAId WHERE DAt_TrNId=@1",
["params"] = new object?[] { orderId }
}), CancellationToken.None);
var actual = result?.Recordsets[0].ToDictionary(
row => Convert.ToString(row["DeA_Kod"])!,
row => Convert.ToString(row["DAt_WartoscTxt"]) ?? string.Empty,
StringComparer.Ordinal);
foreach (var (code, value) in expected)
{
if (actual is null || !actual.TryGetValue(code, out var saved) || saved != value)
throw new InvalidOperationException($"Atrybut '{code}' nie ma oczekiwanej wartości na dokumencie {orderId}.");
}
}
static void ExpectError(Action action, string expectedUri)
{
try

View File

@@ -3,6 +3,7 @@ using SmartB2B.Optima.Adapter;
using SmartB2B.Sync.Contracts;
using SmartB2B.Sync.Service.Orders;
using SmartB2B.Sync.Service.Rpc;
using WampSharp.V2.Core.Contracts;
var tests = new (string Name, Func<Task> Run)[]
{
@@ -14,6 +15,11 @@ 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),
("Handler przyjmuje i pomija opcjonalne extraFields", HandlerMapsOptionalExtraFields),
("Handler odrzuca błędne extraFields", HandlerRejectsInvalidExtraFields),
("Fasada przypisuje atrybuty nagłówka przed zapisem", FacadeAddsDocumentAttributes),
("Fasada odrzuca nieznany kod atrybutu", FacadeRejectsMissingAttribute),
("Fasada zgłasza odrzuconą wartość atrybutu", FacadeRejectsAttributeValue),
("Wynik Optimy zachowuje kwargs WAMP", HandlerReturnsKeywordResult),
("Operacje zamówień są serializowane", ConcurrentOrdersAreSerialized),
("Fasada COM działa na jednym wątku STA", FacadeRunsOnSingleStaThread),
@@ -102,6 +108,105 @@ static async Task HandlerMapsOptimaContract()
AssertEqual(123m, request.Items[0].UnitPriceGross, "brutto");
}
static async Task HandlerMapsOptionalExtraFields()
{
using var adapter = new CapturingAdapter();
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
await handler.HandleAsync(CreateInvocation(new Dictionary<string, object?>
{
["SOURCE"] = "B2B",
["NOTE"] = ""
}), CancellationToken.None);
AssertEqual("B2B", adapter.LastRequest?.ExtraFields["SOURCE"], "wartość SOURCE");
AssertEqual("", adapter.LastRequest?.ExtraFields["NOTE"], "pusta wartość");
await handler.HandleAsync(CreateInvocation(), CancellationToken.None);
AssertEqual(0, adapter.LastRequest?.ExtraFields.Count, "brak extraFields");
await handler.HandleAsync(CreateInvocation(new Dictionary<string, string>()), CancellationToken.None);
AssertEqual(0, adapter.LastRequest?.ExtraFields.Count, "puste extraFields");
}
static async Task HandlerRejectsInvalidExtraFields()
{
foreach (var invalid in new object?[]
{
null,
"text",
new[] { "SOURCE" },
new Dictionary<string, object?> { ["SOURCE"] = 123 },
new Dictionary<string, object?> { [" "] = "value" },
new Dictionary<string, object?> { ["SOURCE"] = "one", [" SOURCE "] = "two" }
})
{
using var adapter = new CapturingAdapter();
var handler = new PlaceOrderHandler(adapter, CreateConfiguration());
try
{
await handler.HandleAsync(CreateInvocation(invalid, includeExtraFields: true), CancellationToken.None);
throw new InvalidOperationException("Oczekiwano odrzucenia extraFields.");
}
catch (WampException exception) when (exception.ErrorUri == "eu.smartb2b.erp.invalid_order_lines")
{
AssertEqual(null, adapter.LastRequest, "brak zapisu zamówienia");
}
}
}
static Task FacadeAddsDocumentAttributes()
{
var session = new FakeAttributeSession(new Dictionary<string, int> { ["SOURCE"] = 7, ["NOTE"] = 8 });
var document = new FakeAttributeDocument();
LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary<string, string>
{
["SOURCE"] = "B2B",
["NOTE"] = ""
});
AssertEqual(2, document.Atrybuty.Items.Count, "liczba atrybutów");
AssertEqual(7, document.Atrybuty.Items[0].DeAID, "ID SOURCE");
AssertEqual("B2B", document.Atrybuty.Items[0].Wartosc, "wartość SOURCE");
AssertEqual(8, document.Atrybuty.Items[1].DeAID, "ID NOTE");
AssertEqual("", document.Atrybuty.Items[1].Wartosc, "pusta wartość NOTE");
return Task.CompletedTask;
}
static Task FacadeRejectsMissingAttribute()
{
var session = new FakeAttributeSession(new Dictionary<string, int>());
var document = new FakeAttributeDocument();
try
{
LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary<string, string>
{
["MISSING"] = "value"
});
throw new InvalidOperationException("Oczekiwano błędu nieznanego kodu.");
}
catch (ErpOperationException exception) when (exception.ErrorUri == "eu.smartb2b.erp.document_attribute_not_found")
{
AssertEqual(0, document.Atrybuty.Items.Count, "brak dodanego atrybutu");
return Task.CompletedTask;
}
}
static Task FacadeRejectsAttributeValue()
{
var session = new FakeAttributeSession(new Dictionary<string, int> { ["SOURCE"] = 7 });
var document = new FakeAttributeDocument();
try
{
LateBoundOptimaComFacade.AddDocumentAttributes(session, document, new Dictionary<string, string>
{
["SOURCE"] = "REJECTED"
});
throw new InvalidOperationException("Oczekiwano błędu wartości atrybutu.");
}
catch (ErpOperationException exception) when (exception.ErrorUri == "eu.smartb2b.erp.invalid_document_attribute")
{
AssertEqual("SOURCE", exception.Details["attributeCode"], "kod w błędzie");
return Task.CompletedTask;
}
}
static async Task HandlerReturnsKeywordResult()
{
using var adapter = new CapturingAdapter();
@@ -135,6 +240,7 @@ static Task FacadeRunsOnSingleStaThread()
AssertEqual(1, facade.ThreadIds.Distinct().Count(), "liczba wątków COM");
AssertEqual(ApartmentState.STA, facade.ApartmentStates.Distinct().Single(), "apartment COM");
AssertEqual("Buffer", info.Details?["Save mode"], "tryb zapisu w diagnostyce");
AssertEqual(true, info.Capabilities.Contains("extraFields"), "możliwość extraFields");
return Task.CompletedTask;
}
@@ -152,9 +258,9 @@ static async Task AdapterSerializesComCalls()
AssertEqual(1, facade.MaximumConcurrency, "maksymalna równoległość fasady COM");
}
static RpcInvocation CreateInvocation() => new(
[],
new Dictionary<string, object?>
static RpcInvocation CreateInvocation(object? extraFields = null, bool includeExtraFields = false)
{
var arguments = new Dictionary<string, object?>
{
["currency_iso"] = "eur",
["companyErpId"] = "123",
@@ -172,7 +278,10 @@ static RpcInvocation CreateInvocation() => new(
Discount = 12.5m
}
}
});
};
if (includeExtraFields || extraFields is not null) arguments["extraFields"] = extraFields;
return new RpcInvocation([], arguments);
}
static ErpAdapterConfiguration CreateConfiguration() => new(
new ErpConnectionOptions("Firma demo", "ADMIN", "", "Server=.;Database=demo"),
@@ -317,3 +426,69 @@ file sealed class CapturingComFacade : IOptimaComFacade
return true;
});
}
public sealed class FakeAttributeSession
{
private readonly IReadOnlyDictionary<string, int> _definitions;
public FakeAttributeSession(IReadOnlyDictionary<string, int> definitions) => _definitions = definitions;
public FakeDefinitionCollection CreateObject(string name)
{
if (name != "CDN.DefAtrybuty") throw new InvalidOperationException($"Nieoczekiwana kolekcja: {name}");
return new FakeDefinitionCollection(_definitions);
}
}
public sealed class FakeDefinitionCollection
{
private readonly IReadOnlyDictionary<string, int> _definitions;
public FakeDefinitionCollection(IReadOnlyDictionary<string, int> definitions) => _definitions = definitions;
public FakeDefinition? Item(string filter)
{
if (!filter.StartsWith("DeA_Typ=4 AND DeA_Kod='", StringComparison.Ordinal))
throw new InvalidOperationException($"Niepoprawny filtr: {filter}");
var code = filter["DeA_Typ=4 AND DeA_Kod='".Length..^1].Replace("''", "'", StringComparison.Ordinal);
return _definitions.TryGetValue(code, out var id) ? new FakeDefinition(id) : null;
}
}
public sealed class FakeDefinition
{
public FakeDefinition(int id) => ID = id;
public int ID { get; }
}
public sealed class FakeAttributeDocument
{
public FakeAttributeCollection Atrybuty { get; } = new();
}
public sealed class FakeAttributeCollection
{
public List<FakeDocumentAttribute> Items { get; } = [];
public FakeDocumentAttribute AddNew()
{
var attribute = new FakeDocumentAttribute();
Items.Add(attribute);
return attribute;
}
}
public sealed class FakeDocumentAttribute
{
public int DeAID { get; set; }
private string? _value;
public string? Wartosc
{
get => _value;
set
{
if (value == "REJECTED") throw new ArgumentException("Niepoprawna wartość.");
_value = value;
}
}
}