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

@@ -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;
}
}
}